{
  "markdown": "# logisheets-mcp\n\n[![logsky/logisheets-mcp](https://glama.ai/mcp/servers/logisky/logisheets-mcp/badges/score.svg)](https://glama.ai/mcp/servers/logisky/logisheets-mcp)\n\n**A real spreadsheet engine your agent can think in.** Excel-compatible formulas\nit doesn't have to do in its head, a table it addresses by name instead of by\ncoordinate, and a genuine `.xlsx` at the end that a person can open, audit and\nkeep using.\n\nAn [MCP](https://modelcontextprotocol.io) server over\n[LogiSheets](https://github.com/logisky/LogiSheets), a spreadsheet engine written\nin Rust. MIT, runs on your machine, opens no sockets.\n\n## The trouble with a grid\n\nAsk a model for a five-year projection and it writes twenty formulas, each with\nthe row number adjusted by hand. That is where the silent mistake lives: one of\nthem reads `B7` where it meant `B8`, the total looks plausible, and nothing\nraises an error.\n\nThen the sheet moves. Someone inserts a row at the top, deletes a year, adds a\ncolumn. Every coordinate the model was holding is now off by one and it has no\nway to notice, so it spends the next turns re-reading cells to work out where\nthings went instead of on the question you asked.\n\nAnd every \"what if\" costs a round trip — write the input, recalculate, read the\noutput, put it back. Sixteen scenarios is sixteen of those, and a scan that dies\nhalf way leaves a scenario behind in your model.\n\n## Blocks\n\nA **block** is a named table on the sheet. Rows have keys, columns have names,\nand everything is addressed by those rather than by position.\n\n- **A field's formula is stated once, for the whole column** — not per cell. Add\n  a row and it computes. There is no twentieth formula to get wrong.\n- **A reference names what it means**: *the `pv` field of the row keyed `Y3`*.\n  Insert a row above it and the reference still says the same thing, because it\n  never said \"row 8\".\n- **The engine owns computed values.** A formula field cannot be overwritten with\n  a number the model worked out itself.\n\n```\ncreate_block     proj    fields: year, fcf, df, pv\nset_field_rule   proj.pv = fcf × df          ← once, for the column\nadd_block_rows   Y1 … Y5\ndescribe_block   proj\n  →  Y1 147.2727   Y2 144.5950   Y3 141.9660   Y4 139.3848   Y5 136.8506\n\n… the sheet is then reshaped: two rows inserted at the top, a column at the left …\n\ndescribe_block   proj\n  →  Y3 141.9660               ← same answer, same address, nothing re-derived\n```\n\nBlocks are created by the agent as it works, so nothing needs preparing. Point it\nat a blank workbook or at a spreadsheet someone emailed you — `convert_to_block`\nadopts a table that is already in ordinary cells, reading the field names off the\nheader row and working out which column is the key.\n\n## The second session\n\nThe conversation that builds a spreadsheet is almost never the conversation that\nhas to answer a question about it. A week later there is a new session, with\nnone of the context, holding only the file — and what the file records is what\nthat session can know.\n\nA grid records coordinates. `=B11*$B$3*(1-$B$4)` is correct and means nothing\nuntil the agent fetches the label column and *infers* that `A3` describes `B3`.\nThe schema is where the meaning goes instead, and it is written into the\n`.xlsx`: field names, the key column, which fields the engine computes, and the\nrule behind each one. One `list_blocks` call and the workbook introduces itself;\none `describe_block` and the rules come back as\n`#FIELD(\"revenue\")*BLOCKREF(\"assum\",\"margin\",\"v\")` — an explanation rather than\na second lookup problem.\n\nThe schema still only says what *shape* the records are. What they **mean** is\nprose, so a block carries a description: a sentence or two on `create_block`, or\n`set_block_description` for a table adopted later, saying what one row is, what\nthe non-obvious fields hold, and what a later reader must not touch. It is\nstored in the file and comes back from `describe_block`, which is the difference\nbetween the next session reading the intent and inferring it from column names.\n\n[`src/cold-read.test.ts`](src/cold-read.test.ts) pins that down rather than\nasserting it. It builds a model in one session, saves it, and reopens the file\nin a second session sharing nothing with the first — own server, own workbook,\nno memory. Then: `list_blocks` recovers every block's fields, key field,\ncomputed fields and row count in one call; every returned rule is checked to\ncontain `#FIELD` or `BLOCKREF` and **no A1 coordinate at all**; the fresh\nsession writes a `BLOCKREF` formula from orientation alone and the engine agrees\nwith arithmetic done independently in the test; and `trace` names what reads an\nassumption before anyone edits it. Cost is metered on the wire, over the same\ntext a host shows the model: **540 B for a five-row model, 545 B for a\nhundred-and-five-row one**, one call each. Reading a schema is `O(columns)`;\nreading a grid to understand it is `O(cells)`. Asking for the data still costs\nwhat the data costs — 11 kB for those 105 rows — and the point is that the\nsecond session gets to choose.\n\nLonger version, with the reasoning: [`docs/the-second-session.md`](docs/the-second-session.md).\n\n## Charts that recompute\n\nAn agent asked for a chart usually renders an image. The image is right once,\nand then the human changes an assumption and it is a picture of a number that is\nno longer true.\n\n`chart_from_block` writes a chart into the workbook instead, and a chart there\nstores *references*, never values:\n\n```\nchart_from_block   rev   value_fields: q1, q2   category_field: region\n  →  <c:val><c:numRef><c:f>Rev!$B$1:$B$3</c:f></c:numRef>\n```\n\nThat is a native Excel `c:chartSpace` in the saved `.xlsx` — the same object\nExcel writes itself. Edit a source cell and the chart follows, in Excel or here.\nAdd a region to the block and it appears in the chart on its own, because the\nseries is bound to the *field*, not to the cells the field happened to occupy\nwhen the chart was made: inserting rows or columns cannot leave it pointing\nsomewhere wrong. `chart_insert` does the same for arbitrary A1 ranges, for data\nthat never became a block.\n\n[`src/agent-loop.test.ts`](src/agent-loop.test.ts) asserts this from the file's\nbytes rather than from the tool's return value: build a block, chart it, save,\nunzip the `.xlsx` and check that the chart part exists and that its series are\n`<c:f>` references into the sheet.\n\n## Benchmarks\n\nMeasured, not asserted. Against the two other MCP servers that work on a local\n`.xlsx` — [spreadsheet-kit](https://github.com/PSU3D0/spreadsheet-mcp) 0.11.1,\nwhich has its own Rust recalc engine, and\n[excel-mcp-server](https://github.com/haris-musa/excel-mcp-server) 0.1.8, the\nmost-installed one, on openpyxl:\n\n| | this | spreadsheet-kit | excel-mcp-server |\n| --- | --- | --- | --- |\n| Write a formula, read its value | **30** | **30** | `\"=SUM(A1:A2)\"` |\n| Five-year DCF, value per share | **20.803603** · 15 calls | **20.803603** · 6 calls | formula text |\n| 4×4 sensitivity, 16 answers | **1 call**, 950 B | 16 calls, 1245 B | can't |\n| Solve backwards for an input | **1 call**, 202 B | 18 calls, 1399 B | can't |\n| Reopen it later and explain it | 4 calls, **2.4 kB** | 5 calls, 21 kB | 2 calls, 24 kB |\n| Answer again after the shape changed | **19.383943** | `#VALUE!` | formula text |\n| Keep a handed-over file's features | **8 of 8** | **8 of 8** | **8 of 8** |\n\nReproduce it — one file per task, and each one runs all three servers:\n\n```bash\nnpm run build                     # ours is driven as dist/cli.js\npython3 bench/t1_compute.py       # bench/t*.py\n```\n\nThe other two contestants have to be reachable first: spreadsheet-kit as an\namd64 Docker image, excel-mcp-server in a virtualenv at `$BENCH_WORK/.venv`\n(default `/tmp/bench-work`). See [`bench/contestants.py`](bench/contestants.py)\nfor exactly how each is started.\n\nThe tasks were committed *before* any other server's tool list was read\n([`bench/TASKS.md`](bench/TASKS.md)), every expected value is derived\nindependently in Python rather than read off a server's output, and tasks we\nexpected to lose are in the list on purpose.\n\nThree caveats, so the table is not read for more than it says. `\"=SUM(A1:A2)\"` is\nnot a bug: openpyxl stores formulas without evaluating them, so that server\nwrites correct models but cannot answer a question about one. spreadsheet-kit is\na genuine peer, correct on everything it can attempt, and builds the model in\nfewer calls than we do — our extra calls declare a schema, which is the trade\nthat pays off in the rows below. And on the reading row each server was reading\nback a file *it* wrote, so only half of that margin transfers to a spreadsheet\nthat came from a person. The last row started at 0 of 8; writing the task is what\nfound that saves were dropping everything the engine had no opinion about.\n\n## Install\n\nRequires Node 20+.\n\n```bash\nnpm install -g logisheets-mcp\n```\n\nFor Claude Desktop, add to `claude_desktop_config.json` (macOS:\n`~/Library/Application Support/Claude/claude_desktop_config.json`; Windows:\n`%APPDATA%\\Claude\\claude_desktop_config.json`), then restart:\n\n```json\n{\n    \"mcpServers\": {\n        \"logisheets\": {\n            \"command\": \"npx\",\n            \"args\": [\"-y\", \"logisheets-mcp\"]\n        }\n    }\n}\n```\n\nAny MCP host that spawns a stdio server works the same way — Cursor reads the\nsame block from `~/.cursor/mcp.json`.\n\n## Try it\n\n> Build me a three-year revenue model: 100 units at $9.50 growing 40% a year,\n> with a 30% cost of goods. Then save it to ~/model.xlsx.\n\nThe numbers come back from the engine rather than from the model's guesses, and\nthe `.xlsx` has live formulas in it — change an assumption in Excel and watch it\nrecompute. To see the same thing with no LLM involved,\n`npm run build && npm run demo` drives the real server over stdio and checks\nevery claim as it goes.\n\n## Tools\n\nTwenty-six by default. Tool-selection accuracy falls as the list grows and\nevery description costs context on every turn.\n\n| Tool | What it does |\n| --- | --- |\n| `open_workbook` | Start a fresh workbook, or load an existing `.xlsx`. Optional — one appears on first use. |\n| `save_workbook` | Write a real `.xlsx`. This is how work gets handed back. |\n| `export_xlsx` | The file as base64, for hosts with no shared filesystem. |\n| `list_blocks` | Every sheet and block, plus where the next block should go. |\n| `describe_block` | A block's schema, keys, field rules, description, and optionally its values. |\n| `eval_formula` | Evaluate a formula and return the value. Nothing is stored. |\n| `create_block` | Create a named table. First field is the row key. |\n| `convert_to_block` | Adopt a table that is already in ordinary cells, in place. |\n| `set_block_description` | Write what a block is for, in prose, into the file. |\n| `add_block_rows` | Add records — at the end, or `after_key` / `before_key`. |\n| `delete_block_rows` | Remove records. |\n| `move_block_row` | Reorder rows by key. Presentation only: no value changes. |\n| `set_block_cells` | Write cells by `(block, row_key, field)`. Batched, atomic. |\n| `set_field_rule` | Give a field a formula, a validation rule, or an editability rule. |\n| `list_violations` | Which cells break their field's validation rule, and why. |\n| `preview_changes` | What edits *would* do, without doing them — one hypothetical, or a whole grid of scenarios in a single call. |\n| `trace` | What a cell reads, and what reads it, from the dependency graph. |\n| `goal_seek` | What input makes an output hit a target. Searches inside the engine. |\n| `create_sheet` | Add a sheet. |\n| `chart_from_block` | Chart a block by naming its fields. Follows rows added later. |\n| `chart_insert` | Chart arbitrary A1 ranges — the raw-cell counterpart. |\n| `chart_list` | The charts on a sheet: type, title, and the ranges each series reads. |\n| `chart_update` | Reconfigure a chart in place — type, series, axes, labels. |\n| `chart_delete` | Remove a chart. |\n| `get_cells` / `set_cells` | Raw-cell escape hatch for data with no structure. |\n\nFormulas are Excel-compatible plus `BLOCKREF(block, key, field)` for reading a\nblock cell by name. Inside a field rule, `#FIELD(\"name\")` is the same row's\nsibling and `#FIELD(\"name\", \"key\")` is another row of the same block — the row\ncarrying that key, never a positional offset.\n\n`preview_changes` and `goal_seek` are the two that change how a model gets\nexplored: each scenario runs on its own temp branch and is discarded, so a 4×4\nsensitivity grid is one call returning sixteen numbers with nothing written to\nthe workbook, and an inverse solve is one call rather than one per bisection\nstep. `trace` answers the question formula text cannot — not what a cell reads,\nbut what reads *it*, which is what you want before touching an assumption.\n\nSet `LOGISHEETS_MCP_TOOLS=full` for 64: undo/redo, formatting, merges, comments,\ncheckpoints, block move/resize, cross-block links, block permissions,\n`chart_suggest`, raw row/column structure. Mutating tools carry MCP's\n`readOnlyHint` / `destructiveHint` annotations so a host can gate them behind\napproval.\n\nChart tools keep a namespace prefix where every other tool drops one. Inside\ntheir namespace they are called `list`, `insert`, `update` and `delete`, and a\nbare `delete` sitting next to `delete_rows` and `delete_block_rows` is a coin\nflip for the model — which is the cost this whole section exists to avoid.\n\n## The file you get back\n\n`save_workbook` writes a real `.xlsx` and returns an MCP **resource link** — a\nuri, media type and size — rather than the bytes, which would cost ~280 KB of\ncontext for a 200 KB workbook and teach the model nothing. Hosts that want the\nfile read it from `workbook://current.xlsx`; `export_xlsx` returns base64 for\nhosts implementing no resources at all.\n\nFormulas can be written out as `BLOCKREF(\"proj\",\"Y3\",\"pv\")` for a person to read,\nor resolved to plain coordinates for Excel to chew on.\n\nOne MCP session holds one active workbook, alive across tool calls — that\npersistence is what makes it memory rather than a calculator.\n\nReads and writes go wherever the server process can reach, which is normal for a\nlocal stdio server and the same posture as the official filesystem server. Run it\nas a user with only the access you intend it to have.\n\n## No network\n\nNo sockets, no ports, no telemetry. Your host spawns this as a child process and\nthey exchange newline-delimited JSON-RPC over stdin and stdout; the engine is\nWASM in that same process, so a formula is a function call rather than a request.\nAn air-gapped machine is a supported way to run this. Checked rather than\nasserted: after a full session — create a block, attach a field rule, evaluate a\nformula, save an `.xlsx` — the process holds six pipes and no sockets, on no\nlistening port.\n\n## Development\n\nA thin shell over three LogiSheets packages:\n[`logisheets-runtime`](https://www.npmjs.com/package/logisheets-runtime) (the\nheadless engine), `logisheets-logician` (the tool definitions), and the\nRust/WASM core.\n\n```bash\nnpm install && npm test\n```\n\nTo work on the engine at the same time, check out\n[LogiSheets](https://github.com/logisky/LogiSheets) as a sibling directory, build\nits packages, and run `npm run link:local` — that symlinks the three into\n`node_modules` so local engine changes take effect without reinstalling. Re-run\nit after any `npm install`.\n\nTo use it as a library, `createServer` returns the MCP `Server`, the\n`WorkbookSession` and the tool map, so you can host it over any transport:\n\n```ts\nimport {createServer} from 'logisheets-mcp'\nconst {server, session, tools} = createServer({mode: 'full'})\n```\n\n## License\n\nMIT. Part of the [LogiSheets](https://github.com/logisky/LogiSheets) project.\n",
  "bytes": 15693,
  "sha": "b0bceeebcb995d53c8c8f8cb669f5c55418445855f96a92990ff0541a70b3cb5",
  "repo_slug": "logisky/logisheets-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_logisky_logisheets_mcp_a4431e81/readme"
}