{
  "markdown": "# xlsx-tools-mcp\n\nAn MCP server for reading and writing Excel (.xlsx) files with high accuracy, while preserving the file's existing structure, styles, and formulas.\n\n<!-- mcp-name: io.github.ruriazz/xlsx-tools-mcp -->\n\n[![CI](https://github.com/ruriazz/xlsx-tools-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/ruriazz/xlsx-tools-mcp/actions/workflows/ci.yml) ![PyPI Version](https://img.shields.io/pypi/v/xlsx-tools-mcp) [![Downloads/month](https://img.shields.io/pypi/dm/xlsx-tools-mcp)](https://pypistats.org/packages/xlsx-tools-mcp) [![Listed on mcpservers.org](https://mcpservers.org/badge.svg)](https://mcpservers.org/servers/ruriazz/xlsx-tools-mcp)\n\n---\n\n## Overview\n\n`xlsx-tools-mcp` exposes 20 Model Context Protocol (MCP) tools that give an LLM agent accurate, structure-preserving read **and** write access to Excel `.xlsx` files. It runs as a standard stdio MCP server: you install it and register it with an MCP client (Claude Code, OpenCode, etc.), and the client's agent can list sheets, read cell ranges, search values, aggregate data, write cells/formulas, manage sheets/rows/columns, apply styles, and force formula recalculation.\n\nIt is built around the principle that editing an existing workbook should **not** destroy what it doesn't touch.\n\n### Features\n\n- **Structure-preserving writes via openpyxl** — writes load the existing workbook and save it back, preserving styles, merged cells, comments, and any aspect the edit doesn't touch.\n- **Never-stale formula results via LibreOffice recalculation** — openpyxl writes formula *strings* but never evaluates them. After every value/formula write the server runs a headless LibreOffice pass to recompute real results, then returns `errors_found` — any Excel error values (`#REF!`, `#DIV/0!`, `#N/A`, …) produced by the recalculation.\n- **Fast reads via python-calamine** — a Rust-backed parser for accurate, fast type inference, with an automatic openpyxl fallback when you need formulas/styles/comments or when calamine can't parse the file.\n- **pandas-based grouping/aggregation** — `aggregate_sheet` groups and aggregates on top of the normal read path, so merged cells and styling in the source range are preserved before flattening.\n- **Per-file locking** — concurrent tool calls (or other processes) touching the same workbook are serialized via a sibling `<path>.lock` file (filelock), so writes never interleave and corrupt the file.\n- **XML-bomb protection** — the `defusedxml` package is an automatic dependency; openpyxl detects it and uses its hardened XML parser, so hostile `xlsx` XML can't expand into resource exhaustion.\n- **Preload files at startup** — set `XLSX_MCP_FILES` to preload one or more workbooks; tools can then be called with `path` omitted or with a short alias instead of a full filesystem path.\n\n---\n\n## Download Stats\n\n[![Downloads/month](https://img.shields.io/pypi/dm/xlsx-tools-mcp)](https://pypistats.org/packages/xlsx-tools-mcp) [![Downloads/week](https://img.shields.io/pypi/dw/xlsx-tools-mcp)](https://pypistats.org/packages/xlsx-tools-mcp)\n\nLive via [pypistats.org](https://pypistats.org/packages/xlsx-tools-mcp), non-mirror downloads. These count download events, not unique users or installs — one user can trigger many downloads (CI, reinstalls, Docker rebuilds, mirrors).\n\n---\n\n## Architecture\n\n```\n┌──────────────────────── Supervisor (MCP transport, stdio)\n│  src/xlsx_tools_mcp/server.py     20 MCP tools + instructions\n│  src/xlsx_tools_mcp/settings.py   env vars, preloaded files, path resolution\n│  src/xlsx_tools_mcp/locking.py    per-file <path>.lock serialization\n│  src/xlsx_tools_mcp/errors.py     domain error types\n│  src/xlsx_tools_mcp/recalc.py     LibreOffice headless recalc + error scanning\n│\n├─ Read path\n│  src/xlsx_tools_mcp/io/reader.py      calamine primary → openpyxl fallback\n│  src/xlsx_tools_mcp/io/transform.py   pandas aggregation on read results\n│\n└─ Write path\n   src/xlsx_tools_mcp/io/writer.py      openpyxl → LibreOffice recalc → scan errors\n```\n\nThe **io layer** (`io/`) is deliberately decoupled from the MCP transport (`server.py`). Each MCP tool is a thin wrapper that resolves the target path, takes the per-file lock, and calls one io-layer function. This keeps the core logic independent of MCP, so it can be tested directly (see `tests/`).\n\n### The recalculation tradeoff\n\nAfter a write that touches cell values or formulas, the server runs `soffice --headless --convert-to xlsx` on the file so every formula gets a real computed value. This round-trip recomputes formulas but **re-exports the whole workbook** — it is a tradeoff, **not** a guarantee of bit-perfect preservation. Features that openpyxl would otherwise preserve may not survive identically: pivot tables, charts, data validation, some formats, and some defined names.\n\nIf you're working on a structurally complex workbook where that risk matters, you can pass `recalculate=False` on the value/formula-writing tools (`write_cells`, `append_rows`, `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`) to save with openpyxl only and skip the round-trip entirely.\n\n---\n\n## Requirements\n\n- **Python ≥ 3.10**\n- **LibreOffice** — *optional but recommended*. Needed only for formula recalculation. Without it, writes still succeed (saved via openpyxl) but formulas are **not** recomputed and a warning is returned in the `message` field.\n\nInstall LibreOffice:\n\n```bash\n# macOS\nbrew install --cask libreoffice\n\n# Debian / Ubuntu\nsudo apt-get install -y libreoffice-calc\n```\n\nThe server finds LibreOffice by checking `soffice` / `libreoffice` on `PATH` and the standard macOS install location (`/Applications/LibreOffice.app/Contents/MacOS/soffice`).\n\n---\n\n## Installation\n\nThe server speaks **stdio** transport (standard MCP): after installation it waits for an MCP client to connect and call tools. You don't usually run it yourself; you register it with a client.\n\n### 1. From PyPI via `uvx` (recommended — no clone)\n\n```bash\nuvx xlsx-tools-mcp\n```\n\n`uvx` fetches and runs the published package without polluting your project. This is the simplest way to power up an MCP client (see configuration snippets below).\n\n### 2. From source\n\n```bash\ngit clone https://github.com/ruriazz/xlsx-tools-mcp.git\ncd xlsx-tools-mcp\nuv sync\n# run the server (useful for local dev / debugging):\nuv run xlsx-tools-mcp\n```\n\n### 3. Via `pip`\n\n```bash\npip install xlsx-tools-mcp\n```\n\nThis installs the console entry point, so you can run the server directly:\n\n```bash\nxlsx-tools-mcp\n```\n\n---\n\n## Configuration for MCP clients\n\nThe simplest registration for every client uses `uvx xlsx-tools-mcp` (no clone, always the published version).\n\n### Claude Code\n\n```bash\nclaude mcp add xlsx-tools-mcp -- uvx xlsx-tools-mcp\n```\n\nOr via `.mcp.json` in your project:\n\n```json\n{\n  \"mcpServers\": {\n    \"xlsx-tools-mcp\": { \"command\": \"uvx\", \"args\": [\"xlsx-tools-mcp\"] }\n  }\n}\n```\n\n### OpenCode\n\nIn `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):\n\n```json\n{\n  \"mcp\": {\n    \"xlsx-tools-mcp\": { \"type\": \"local\", \"command\": [\"uvx\", \"xlsx-tools-mcp\"], \"enabled\": true }\n  }\n}\n```\n\n### When running from a source clone\n\nIf you cloned the repo instead of installing from PyPI, point the client at your local checkout by swapping `uvx xlsx-tools-mcp` for the dynamic `uv run` form (use the **absolute** path to the clone):\n\n**Claude Code `.mcp.json`:**\n\n```json\n{\n  \"mcpServers\": {\n    \"xlsx-tools-mcp\": {\n      \"command\": \"uv\",\n      \"args\": [\"--directory\", \"/absolute/path/to/xlsx-reader\", \"run\", \"xlsx-tools-mcp\"]\n    }\n  }\n}\n```\n\n**OpenCode:**\n\n```json\n{\n  \"mcp\": {\n    \"xlsx-tools-mcp\": {\n      \"type\": \"local\",\n      \"command\": [\"uv\", \"--directory\", \"/absolute/path/to/xlsx-reader\", \"run\", \"xlsx-tools-mcp\"],\n      \"enabled\": true\n    }\n  }\n}\n```\n\nReplace `/absolute/path/to/xlsx-reader` with the actual location of your clone.\n\n---\n\n## Preloading files (`XLSX_MCP_FILES`)\n\nSet the `XLSX_MCP_FILES` environment variable in the **MCP server config `env`** section (not your interactive shell — the server is launched by the client) to preload workbooks at startup. Format: comma-separated `alias=absolute/path` entries, or bare absolute paths:\n\n```\nXLSX_MCP_FILES=name=/abs/path/to/name.xlsx,report=/data/report.xlsx\n```\n\nBare paths get an alias defaulting to the filename:\n\n```\nXLSX_MCP_FILES=/abs/path/to/sales.xlsx\n```\n\nWith `alias`/`filename` as the alias:\n\n- **One file configured** → every tool can be called with `path` omitted entirely.\n- **Multiple files configured** → pass the alias (or filename) as `path`.\n- `list_configured_files()` returns the alias → absolute-path mapping.\n- Raw absolute **and relative** paths still work for files you didn't preload.\n\n**Claude Code — `.mcp.json` with preloading:**\n\n```json\n{\n  \"mcpServers\": {\n    \"xlsx-tools-mcp\": {\n      \"command\": \"uvx\",\n      \"args\": [\"xlsx-tools-mcp\"],\n      \"env\": {\n        \"XLSX_MCP_FILES\": \"report=/data/report.xlsx,sales=/data/sales.xlsx\"\n      }\n    }\n  }\n}\n```\n\n**OpenCode with preloading:**\n\n```json\n{\n  \"mcp\": {\n    \"xlsx-tools-mcp\": {\n      \"type\": \"local\",\n      \"command\": [\"uvx\", \"xlsx-tools-mcp\"],\n      \"env\": { \"XLSX_MCP_FILES\": \"report=/data/report.xlsx,sales=/data/sales.xlsx\" },\n      \"enabled\": true\n    }\n  }\n}\n```\n\n---\n\n## Tool reference\n\nAll 20 tools. Unless noted, `path` accepts a filesystem path, a preloaded alias/filename, or may be omitted when exactly one file is preloaded. `create_workbook` is the exception — its `path` is required because a new file is never preloaded.\n\n> **Response shape (all write tools):** every write tool returns `{\"saved\": bool, \"recalculated\": bool, \"errors_found\": list, \"message\": str}`. When non-empty, `errors_found` is a list of `{\"sheet\": \"...\", \"cell\": \"B2\", \"error\": \"#DIV/0!\"}`.\n\n### Inspect / Read\n\n| Tool | Description |\n|------|-------------|\n| `list_configured_files()` | List files preloaded at startup via `XLSX_MCP_FILES`, as an alias → absolute-path map. Call this first if unsure what's available. |\n| `list_sheets(path?)` | List every sheet in the workbook with approximate row/column counts (calamine). |\n| `get_workbook_info(path?)` | Workbook-level metadata: per-sheet exact dimensions, `max_row`/`max_column`, sheet state, the active sheet, and defined names. |\n| `read_sheet(sheet, cell_range?, max_rows?, path?)` | Read cell values as a 2D array addressed absolutely from A1. `cell_range` is an optional A1-style range (e.g. `\"B2:F20\"`); omit to read the full used area. `max_rows` optionally caps the number of rows returned. |\n| `get_cell(sheet, cell, path?)` | Full detail for a single cell: value (cached computed), formula, number format, font (bold/italic/size/color), fill color, merge state, comment. |\n| `search_workbook(query, sheet?, match_case?, limit?, path?)` | Substring search across one or all sheets. `sheet` restricts to one sheet; `match_case=True` makes it case-sensitive; `limit` caps matches. Returns `{\"sheet\", \"cell\", \"value\"}`. |\n| `aggregate_sheet(sheet, group_by, agg, cell_range?, has_header?, path?)` | Group and aggregate with pandas. `group_by` is a list of column names (taken from the header row); `agg` maps column name → aggregation function, e.g. `{\"amount\": \"sum\"}`. `has_header=True` (default) reads column names from the first row. Returns `{columns, records, row_count}`. |\n\n### Write\n\n| Tool | Description |\n|------|-------------|\n| `create_workbook(path, sheets?, overwrite?)` | Create a new `.xlsx`/`.xlsm` workbook. `sheets` defaults to `[\"Sheet1\"]`. `overwrite=True` replaces an existing file. `path` is **required** (new files are never preloaded). |\n| `write_cells(sheet, cells, create_sheet_if_missing?, recalculate?, path?)` | Write values and/or formulas into specific cells. `cells` is a list of `{\"cell\": \"A1\", \"value\": ...}` or `{\"cell\": \"B1\", \"formula\": \"=A1*2\"}`. Optionally create the sheet first; `recalculate=True` (default) runs the LibreOffice recompute. |\n| `append_rows(sheet, rows, create_sheet_if_missing?, recalculate?, path?)` | Append rows after the last used row. `rows` is a list of rows, each a list of cell values in column order. |\n| `create_sheet(sheet, index?, path?)` | Add a new empty sheet. `index` is a zero-based insert position; omit to append at the end. |\n| `delete_sheet(sheet, path?)` | Delete a sheet. Fails if it's the only sheet left. |\n| `insert_rows(sheet, start_row, count?, recalculate?, path?)` | Insert blank rows before `start_row` (1-based), shifting existing rows down. `count` defaults to 1. |\n| `delete_rows(sheet, start_row, count?, recalculate?, path?)` | Delete rows starting at `start_row` (1-based), shifting rows below upward. `count` defaults to 1. |\n| `insert_columns(sheet, start_column, count?, recalculate?, path?)` | Insert blank columns before `start_column` (1-based), shifting existing columns right. `count` defaults to 1. |\n| `delete_columns(sheet, start_column, count?, recalculate?, path?)` | Delete columns starting at `start_column` (1-based), shifting columns to the right leftward. `count` defaults to 1. |\n| `merge_cells(sheet, cell_range, path?)` | Merge a rectangular range (e.g. `\"A1:C1\"`) into one cell. |\n| `unmerge_cells(sheet, cell_range, path?)` | Undo a merge on a previously-merged range. |\n| `set_cell_style(sheet, cell_range, style, path?)` | Apply formatting to a range (e.g. `\"A1:D1\"`). `style` keys: `bold`, `italic`, `font_size`, `font_color` (hex RGB, e.g. `\"FF0000\"`), `bg_color` (hex RGB), `horizontal`, `vertical` (alignment), `border` (`\"thin\"`, `\"medium\"`, `\"thick\"`, …), `number_format` (e.g. `\"#,##0.00\"`). |\n| `recalculate_workbook(path?)` | Force a LibreOffice headless recalculation pass and report any formula errors found. |\n\n### Example payload — `write_cells`\n\nA call writing a formula and a value:\n\n```json\n{\n  \"sheet\": \"Sheet1\",\n  \"cells\": [\n    { \"cell\": \"A1\", \"value\": 100 },\n    { \"cell\": \"B1\", \"formula\": \"=A1*2\" }\n  ],\n  \"recalculate\": true,\n  \"path\": \"/data/budget.xlsx\"\n}\n```\n\nMatching response:\n\n```json\n{\n  \"saved\": true,\n  \"recalculated\": true,\n  \"errors_found\": [],\n  \"message\": \"Recalculated with LibreOffice headless.\"\n}\n```\n\nIf a formula this touches produced an error, `errors_found` would look like:\n\n```json\n{\n  \"saved\": true,\n  \"recalculated\": true,\n  \"errors_found\": [\n    { \"sheet\": \"Sheet1\", \"cell\": \"C5\", \"error\": \"#DIV/0!\" }\n  ],\n  \"message\": \"Recalculated with LibreOffice headless.\"\n}\n```\n\n---\n\n## Security & concurrency\n\n- **XML-bomb protection** — `defusedxml` is an automatic dependency of this package. openpyxl auto-detects it and uses its hardened XML parser, so a malicious `.xlsx` (a zip of XML) can't trigger entity-expansion resource exhaustion. No configuration needed.\n- **Per-file locking** — every read/write acquires a sibling `<path>.lock` file (via `filelock`). Concurrent tool calls or other processes touching the same workbook are serialized so writes never interleave and corrupt the file.\n- **Recalc timeout** — `XLSX_MCP_RECALC_TIMEOUT` (seconds, default `60`) caps how long the LibreOffice recalculation pass may run.\n- **Lock timeout** — `XLSX_MCP_LOCK_TIMEOUT` (seconds, default `10`) caps how long a tool will wait to acquire the per-file lock before failing.\n\n---\n\n## Troubleshooting\n\n- **`errors_found` is empty even though my formula is broken** — recalculation likely didn't run. Check the `message` field: if it says LibreOffice wasn't found, the file was saved via openpyxl as-is and formulas were **not** recomputed (cached values may be stale). Install LibreOffice (see [Requirements](#requirements)).\n- **Recalculation is slow or times out** — raise `XLSX_MCP_RECALC_TIMEOUT` (default 60s). On timeout, the file is still saved, but `recalculated` will be `false` and `message` says the recalc timed out.\n- **`LockTimeoutError` on concurrent access** — another operation holds the lock. Raise `XLSX_MCP_LOCK_TIMEOUT` (default 10s), or retry when the other operation finishes.\n- **\"Sheet not found\"** — the error message lists the available sheet names, so you can pick the correct one.\n- **`path` required / no file configured** — you called a tool without `path` but no (or multiple) files are preloaded. Preload one file via `XLSX_MCP_FILES`, pass an explicit alias, or pass a raw path.\n\n---\n\n## Development / Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md). Run the test suite with:\n\n```bash\nuv run pytest\n```",
  "bytes": 16327,
  "sha": "e3b55b122aa2b8f4ce503fc8da37ba39bd2ce15f5896288fc0edd3aaf8c7f38d",
  "repo_slug": "ruriazz/xlsx-tools-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_ruriazz_xlsx_tools_mcp_96da6fb2/readme"
}