Imported from ForLegalAI/mcp-ms-office-documents (
AGENTS.md). Install upstream withnpx skills add ForLegalAI/mcp-ms-office-documents. Copyright stays with the author.
AGENTS.md
Operational brief for coding agents and humans alike. Rules here, reasons in
docs/development/.
What this is
An MCP server (FastMCP 3, Python 3.12) that turns Markdown or structured
input into Office files. Runs in Docker on port 8958, endpoint /mcp,
streamable-HTTP. Entry point main.py. Six static tools (Word, Excel,
PowerPoint, template listing, email, XML) plus dynamic tools registered from
YAML templates.
Commands
pip install -r requirements-dev.txt
ruff check . # lint (CI)
pytest -m "not network" # tests (CI); plain `pytest` includes network tests
pytest tests/test_docx_base.py # one module
python main.py # run locally, no .env needed
Layout
main.py tool handlers, startup order, health routes, server launch
config.py the only module that reads os.environ; get_config() singleton
async_runner.py run_blocking(): bounded thread pool for every blocking call
librechat_integration.py request-header user context; upload_and_format_response()
upload_tools/ upload_file() / upload_file_async() dispatch; backends/<strategy>.py
template_utils.py template file resolution (custom → default, container → local)
template_registry.py YAML merging (master + *.d/) for specs and for the
kind-wide _global.yaml; live tool removal
inline_markdown.py the inline-emphasis grammar shared by Word and PowerPoint
warning_channel.py DocumentWarning + WarningChannel: what a build worked around
image_utils.py image download/decode with the SSRF guard
metrics.py in-process counters for the admin Status page
docx_tools/ xlsx_tools/ pptx_tools/ email_tools/ xml_tools/ one package per type
admin/ optional FastHTML admin UI (ADMIN_ENABLED); app.py holds
routes only — components.py (markup + theme),
sections.py (the product areas and their tabs), kinds.py
(dynamic kinds), base_templates.py (static base
templates), forms.py, views/ (views/sections.py = the
tabbed shell, Overview and the dashboard;
views/settings.py = the Word style mapping)
docs/ user reference (docs/*.md) and development docs (docs/development/)
Request path: handler in main.py → await run_blocking(_<type>_buffer, …)
→ extract_user_context_from_request() → upload_and_format_response() →
backend → URL string or LibreChat artifact dict. Details:
architecture.md.
Rules
Structure
- Every tool package exposes a private
_…_buffer()that returnsBytesIO— or(BytesIO, warnings)where the tool has a warnings channel (Word, Excel, PowerPoint) — and a public wrapper that also uploads.main.pycalls the buffer function only. New tool: follow adding-a-tool.md. - Blocking work goes through
await run_blocking(...), always. Never call a buffer function orupload_file()directly from anasync defhandler. - Read request headers on the event loop, before dispatch; never inside a buffer function.
- Dynamic template tools call
upload_file()inside their own offloaded body and do not go throughupload_and_format_response(). - Config: read via
get_config(); neveros.environoutsideconfig.py. A new variable goes intoconfig.py,.env.exampleanddocs/configuration.md;tests/test_config_docs.pyenforces it. - Templates: resolve through
template_utils; never hard-code a path. - Images:
image_utils.load_image(); neverrequestsdirectly. - Logging:
logging.getLogger(__name__). Level comes fromDEBUGonly.
Errors
- Raise
fastmcp.exceptions.ToolErroronly at the handler boundary inmain.pyor a dynamic tool body.RuntimeErrorin the upload layer.ValueErrorfor input the caller can fix. - A backend returns a string on success,
Noneor raises on failure. Never return an error message as a string.
Warnings
- A branch that skips, substitutes or degrades something the caller asked for
reports it:
warnings.add(code, message, **location)beside theloggercall, never the log alone. The caller gets a success response and never reads the server log. - The channel is a
WarningChannelfromwarning_channel.py, created in the_…_buffer()function and threaded down as an argument. Never module state: builds run concurrently on worker threads. - Every code lives in the package's
warnings.pywith a severity in itsWARNING_SEVERITY(error= not in the file,warning= there but not as asked,info= a substitution the caller will not mind). Each package'stest_every_code_has_a_severityenforces it. - A tool with a channel returns
(BytesIO, warnings)from its buffer function;main.pyattaches them with_with_warnings(), passing the tool'skindandname— that call also records them for the admin Status page, and the arguments are required so a new tool cannot go uncounted. PowerPoint keeps its ownSlideWarningrecord and shares the severities.
Word
- One line-break model: every newline reaching the inline layer is a soft
break; two-space runs are assembled by
_soft_break_run()in the block dispatcher and stop before any block. Never giveexpand_br_to_block_breaks()the renderer's numbering state (#110). Details in word.md.
PowerPoint
- Never index
slide_layouts[N]in a builder; go through_new_slide(). - Never measure text against a hardcoded point size: read what the template
really renders at with
read_body_font_size(). The fit estimate is wrong by the square of any error, and the overflow warning comes from the same number. - Never match a content placeholder by
idx: a customer template numbers them however it likes. Columns come fromcontent_columns(), a single body from_content_placeholders(). Only footers and slide numbers still readidx(11 and 12). Details in powerpoint.md. - Keep the published slide schema flat (no
oneOf/$ref/discriminator); validation iscoerce_slides()in the build step. - Write a caller's text with
inline_formatting.write_text(), neverparagraph.text— the tool promises inline markdown and links in every text field, and a direct assignment prints the markers instead. - Every warning takes a code from
pptx_tools/warnings.py, and every code takes a severity inWARNING_SEVERITY.
Admin UI
- Build pages from
admin/components.py, never hand-written FastHTML trees. A labelled control goes throughfield()— it is what mints theidand points the<label>at it; a table goes throughdata_table(), which is what keeps a wide table from scrolling the whole page. - Per-kind wording and flags live in
admin/kinds.py, storage metadata inadmin/store.py. Add a kind by editing those two tables, not by adding a branch to a view. - The top-level navigation lives in
admin/sections.py: oneSectionper product area (Word, PowerPoint, Excel, Email, XML, Server) and its tabs. The nav, the tab bars, the routes and the dashboard are all derived from that table — add a section by adding a row, never by adding a route. - A tab renderer is a panel: a list of cards with no shell, wrapped by
views.sections.section_page().app.py's_panel()is the one (section, tab) → renderer map, and every route that re-renders a tab after a POST goes back through it. A page reached from a tab (an editor, a confirmation) is a full page and names its section withviews.shell.kind_page(), so the nav keeps the right item lit. - Section routes are registered before the
/{kind}/…ones and are GET-only; every two-segment/{kind}/…route is a POST. That is what lets the Email section live at/emailbeside theemailkind. Slugs are literal, never a/{slug}pattern — a catch-all would swallow/new/docx.sections.assert_slugs_free()fails at startup on a slug that shadows a page, andtests/test_admin_sections.pywalks the route table for the actual invariant. admin/kinds.py'sSTYLE_KEYSmust match the keysdocx_tools/style_map.pyrecognises;tests/test_admin_style_keys.pyenforces it. Style-name labels come fromDEFAULT_STYLE_MAP, never a second copy.- No external assets, ever: no CDN stylesheet, no
<script src>, no web font. The theme and scripts are inlined bycomponents.head_tags()andtests/test_admin_assets.pyenforces it on every rendered page. - Colours are CSS custom properties on
:root, redefined underprefers-color-scheme: dark. A new rule takes a token, never a literal, or it will be wrong in one of the two themes. Spacing, type sizes, radii and shadows are tokens too (--sp-*,--fs-*,--r-*); use the nearest one rather than a value between two. - Navigation is links, not script:
tab_bar()renders anchors to real URLs so a tab is bookmarkable and works with JavaScript off. The only script on any page is the argument-row cloner. - A card's own buttons go in
card(..., actions=[...]), a page's primary action inpage_header(). Never append them to a card's body, where they read as part of the content above them.
Dynamic tools and schemas
- Never
Optional[...]on a dynamic-tool argument; optionality is the default alone. Descriptions must be siblings of a flat type. - Do not give
add_unique_prefixa default in a dynamic tool body; it must reachupload_file()asNone. - A disabled template (
enabled: false) is filtered intemplate_registry.gather_specs()— the one merge point every registration path uses — never at a call site. A new consumer ofgather_specs()gets the filtering for free; only the admin UI passesinclude_disabled=True. Absent means enabled, so specs written before #165 stay live. - Kind-wide settings (a master file's top-level keys, e.g. Word's
style_mapping) merge intemplate_registry.global_config()— the one merge point, asgather_specs()is for templates. A*.d/_global.yamlkey replaces the master's key, never merges into it, andtemplates:is never taken from it. The filename is reserved:read_spec_dir()skips it andstore.validate_name()refuses the name_global(#161). - The global style map is cached against its config files' mtime and size,
never for the process lifetime: a UI edit must apply without a restart.
Anything that writes it calls
invalidate_global_style_map()— the fingerprint cannot see two writes in one timestamp tick — and must also re-register the Word tools, which bake the map in at registration.AdminContext.resync_docx_style_map()does both.
Docs — part of every change, never a follow-up
- Before you finish any change, find every page under
docs/that describes what you touched and update it in the same commit. Concretely:- Internal change (new, renamed, moved or removed module or function; a
pipeline step added or reordered; a new invariant, gotcha or extension
point): the package's page under
docs/development/tools/or the relevantdocs/development/*.md— its pipeline diagram, module map, "how it works", extension points and invariants. Change this file too if a rule here changes. - User-visible change (input format, parameter, output shape,
behaviour): the tool description in
main.py, the reference page underdocs/, and the tests, together. - New environment variable:
config.py,.env.example,docs/configuration.md;tests/test_config_docs.pyenforces this. - A limitation you fixed: remove it from the page's "Known limitations" and close or update the issue it links to.
- A new package or backend: a new page in the same shape as the others,
linked from
docs/README.md.
- Internal change (new, renamed, moved or removed module or function; a
pipeline step added or reordered; a new invariant, gotcha or extension
point): the package's page under
- A change with no doc update is only correct if you checked and nothing described what you touched. Say so in the commit message.
- Each package's module docstring names what it owns and which entry point
main.pyuses. Keep it true.
Tests
- One file per behaviour under
tests/. Regression tests name the issue. - Build without uploading: patch
upload_filewhere it is looked up (<pkg>.base_<type>_tool.upload_file), or call the buffer function. PowerPoint tests instantiatePowerpointPresentationand call.save(). - Output for manual inspection goes to
tests/output/{docx,pptx,xlsx}/. - Config is a singleton read at import; see testing.md for the reload pattern.
Where to read more
architecture · word · excel · powerpoint · email · xml · dynamic templates · upload backends · shared modules · testing · CONTRIBUTING