Built on the official Go SDK (modelcontextprotocol/go-sdk v1.8.0) over stdio. Tools describe, read_range, evaluate, find and list_errors read or compute on a copy; write_cells, apply_operations, sort, filter, create_chart, create_pivot and run_notebook_cell change the workbook through Batch with 012 set's checks, one undo step and an atomic save, and return the change as 012 diff lists it (dry_run keeps nothing). Resources address ranges, tables and notebook cells as o12://book/...; four prompts start common jobs. The tools sit on a small Backend interface so a live session can replace the file. Notebook cells run only with --notebooks (and --trust), JEV only with --jev.
- commit
- 7266433d51507e6ed52ccde405f1eb1e50b05a16
- parent
- 2c3ad01363ed9b9b081309789ca52de2ffe77a22
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.1 of 3, Jev's estimate
| M | ROADMAP.md | 2 | +- |
| M | cmd/ | 3 | +- |
| A | cmd/ | 86 | ++++++++ |
| A | cmd/ | 50 | ++++ |
| M | cmd/ | 4 | + |
| M | docs/ | 3 | +- |
| A | docs/ | 119 | +++++++++++ |
| M | docs/ | 6 | +- |
| M | docs/ | 36 | +++ |
| M | go.mod | 7 | + |
| M | go.sum | 16 | + |
| A | internal/ | 151 | +++++++++++++ |
| M | internal/ | 8 | +- |
| A | internal/ | 182 | ++++++++++++++++ |
| A | internal/ | 87 | ++++++++ |
| A | internal/ | 55 | +++++ |
| M | internal/ | 3 | +- |
| A | internal/ | 177 | ++++++++++++++++ |
| A | internal/ | 100 | +++++++++ |
| A | internal/ | 60 | +++++ |
| A | internal/ | 103 | +++++++++ |
| A | internal/ | 225 | ++++++++++++++++++++ |
| A | internal/ | 96 | ++++++++ |
| A | internal/ | 340 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 18 | ++ |
| A | internal/ | 169 | +++++++++++++++ |
@@ -76,7 +76,6 @@ section 3's shared editing with an agent as one participant.
7676 7777 | Step | Result | Size |7878 |---|---|---|79-| `012 mcp` over stdio on a workbook file: tools (describe, read, write, apply operations, evaluate without writing, find, sort, filter, chart, pivot, run a notebook cell), resources (`012://book/Sheet1!A1:D40`, tables, notebook cells), prompts | Any MCP host works with workbooks | M |8079 | MCP Apps views: a sheet or chart rendered as an interactive view inside the host, from the HTML export | Workbooks shown in the chat | S to M |8180 | Live mode: `012 --listen` and `012 mcp --attach`; the agent's cursor and name in the grid, its changes arriving as a suggestion (marked cells, accepted or rejected whole or by cell) unless direct edits are allowed, its own undo, a scope (sheet, range, read-only), elicitation to ask the person | Coworking with an agent in the grid | M to L |8281 @@ -163,6 +162,7 @@ stream above rather than a design of their own.
163162 **Agents**164163 165164 - `012 describe`, results as JSON with stable schemas, `012 set --dry-run` as a diff, and a Claude Code skill installed by `012 agent --install-skill`: [Agents](docs/agents/README.md)165+- `012 mcp`, an MCP server on a workbook file: tools that read, evaluate, write, sort, filter, chart, pivot and run notebook cells through the same checks as `012 set`, resources and prompts: [MCP server](docs/agents/mcp.md)166166 167167 **Upkeep**168168 @@ -82,6 +82,7 @@ func usage() error {
8282 " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/terminal/ssh.md)\n" +8383 " 012 get|set|recalc|export|describe file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +8484 " 012 diff a.012 b.012, 012 merge-driver base ours theirs: compare and merge workbooks (see docs/files/git.md)\n" +85+ " 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]: an MCP server on the workbook (see docs/agents/mcp.md)\n" +8586 " 012 agent --skill | --install-skill [--force] [dir]: the Claude Code skill (see docs/agents/README.md)\n" +8687 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +8788 " 012 version")@@ -92,7 +93,7 @@ func usage() error {
9293 var subcommands = map[string]func([]string, env) error{9394 "config": runConfig, "serve": runServe,9495 "get": runGet, "set": runSet, "recalc": runRecalc, "export": runExport, "describe": runDescribe,95- "diff": runDiff, "merge-driver": runMergeDriver, "agent": runAgent,96+ "diff": runDiff, "merge-driver": runMergeDriver, "agent": runAgent, "mcp": runMCP,9697 }9798 9899 func run(args []string, e env) error {@@ -0,0 +1,86 @@
1+package main2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "os"8+ "os/signal"9+ "sync"10+11+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"12+13+ "github.com/FelineStateMachine/012/internal/config"14+ "github.com/FelineStateMachine/012/internal/headless"15+ "github.com/FelineStateMachine/012/internal/jev"16+ "github.com/FelineStateMachine/012/internal/mcp"17+ "github.com/FelineStateMachine/012/internal/sheet"18+)19+20+// 012 mcp: a Model Context Protocol server over standard input and21+// output on one workbook file (see docs/agents/mcp.md).22+23+const mcpUsage = "usage: 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]"24+25+// runMCP is 012 mcp: it serves until the client closes standard input.26+// Nothing runs a program or reaches the network unless its flag says:27+// --notebooks offers run_notebook_cell, --jev answers JEV functions.28+func runMCP(args []string, e env) error {29+ a, err := parseArgs(args, nil, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})30+ switch {31+ case err != nil:32+ return usageError(err.Error(), mcpUsage)33+ case a.has("help"):34+ fmt.Fprintln(e.stdout, mcpUsage)35+ return nil36+ case len(a.pos) != 1:37+ return usageError("", mcpUsage)38+ case a.has("trust") && !a.has("notebooks"):39+ return usageError("--trust goes with --notebooks", mcpUsage)40+ }41+ path := a.pos[0]42+ if _, err := headless.Open(path, true); err != nil {43+ return err44+ }45+ cfg, err := loadConfig(e, nil)46+ if err != nil {47+ return err48+ }49+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)50+ defer stop()51+ b := &mcp.FileBackend{Path: path, Save: func(f *headless.File) error { return save(f, e) }}52+ if a.has("jev") {53+ b.Prepare = jevPreparer(cfg, e)54+ }55+ o := mcp.Options{Version: buildVersion(), ReadOnly: a.has("read-only"), Force: a.has("force")}56+ if a.has("notebooks") && !a.has("read-only") {57+ o.Notebooks = &headless.NotebookOptions{Runner: e.nuRunner(), Timeout: cfg.Duration("nu-timeout"), NuConfig: cfg.Bool("nu-config")}58+ o.MayRun = func(w *sheet.Workbook) error {59+ return mayRun(&headless.File{Path: path, Book: w}, cfg, a.has("trust"))60+ }61+ }62+ t := &sdk.IOTransport{Reader: io.NopCloser(e.stdin), Writer: nopWriteCloser{e.stdout}}63+ return mcp.New(b, o).Run(ctx, t)64+}65+66+// jevPreparer answers a workbook's JEV functions each time it's opened,67+// connecting once and keeping the answers, so each question is asked68+// once a session.69+func jevPreparer(cfg *config.Config, e env) func(context.Context, *headless.File) error {70+ var once sync.Once71+ var client jev.Client72+ var connErr error73+ cache := jev.NewCache()74+ return func(ctx context.Context, f *headless.File) error {75+ once.Do(func() { client, connErr = jevClient(ctx, cfg, e) })76+ if connErr != nil {77+ return connErr78+ }79+ headless.AnswerJEVFrom(ctx, f.Book, client, cache, jevTimeout)80+ return nil81+ }82+}83+84+type nopWriteCloser struct{ io.Writer }85+86+func (nopWriteCloser) Close() error { return nil }@@ -0,0 +1,50 @@
1+package main2+3+import (4+ "context"5+ "io"6+ "path/filepath"7+ "slices"8+ "testing"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+)12+13+// TestMCPOverStdio runs 012 mcp on pipes standing for standard input14+// and output, as a host runs it, and lists its tools.15+func TestMCPOverStdio(t *testing.T) {16+ e, _, _ := scriptEnv(t)17+ path := filepath.Join(t.TempDir(), "book.012")18+ status(e, "set", path, "A1", "1")19+ inR, inW := io.Pipe()20+ outR, outW := io.Pipe()21+ e.stdin, e.stdout = inR, outW22+ done := make(chan error, 1)23+ go func() {24+ done <- run([]string{"mcp", path, "--read-only"}, e)25+ outW.Close()26+ }()27+ ctx := context.Background()28+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, &sdk.IOTransport{Reader: outR, Writer: inW}, nil)29+ if err != nil {30+ t.Fatal(err)31+ }32+ var names []string33+ for tl, err := range cs.Tools(ctx, nil) {34+ if err != nil {35+ t.Fatal(err)36+ }37+ names = append(names, tl.Name)38+ }39+ if !slices.Contains(names, "read_range") || slices.Contains(names, "write_cells") {40+ t.Errorf("tools of a read-only server: %v", names)41+ }42+ cs.Close()43+ inW.Close()44+ if err := <-done; err != nil {45+ t.Errorf("012 mcp: %v", err)46+ }47+ if code, _ := status(e, "mcp", path, "--trust"); code != 2 {48+ t.Errorf("--trust without --notebooks: status %d", code)49+ }50+}@@ -88,4 +88,8 @@ model service. Don't add them unless the user wants that.
8888 errors; for `diff`, the workbooks differ); 2 the command was used8989 wrongly, with its usage. Every command's `--help` prints its usage.9090 91+When the 012 MCP server (`012 mcp book.012`) is connected, its tools92+(describe, read_range, write_cells with dry_run, evaluate, list_errors)93+do the same with the same checks.94+9195 The full reference: https://github.com/FelineStateMachine/012/blob/main/docs/agents/README.md@@ -15,7 +15,8 @@ started with the flags that allow it, as for scripts.
1515 | Page | For |1616 |---|---|1717 | This page | Agents with a shell: the commands, their JSON, and the Claude Code skill |18-| [JSON output](../reference/json.md) | The schemas of what commands write with `--format json` |18+| [MCP server](mcp.md) | `012 mcp`, for any MCP host: tools, resources, prompts, and adding it to Claude Code and Claude Desktop |19+| [JSON output](../reference/json.md) | The schemas of what commands write with `--format json`, and what the MCP tools return |1920 2021 ## With a shell2122 @@ -0,0 +1,119 @@
1+---2+title: "MCP server"3+sidebar_position: 24+---5+6+# MCP server7+8+`012 mcp book.012` is a [Model Context Protocol](https://modelcontextprotocol.io)9+server on one workbook, over standard input and output, so any MCP host10+(Claude Code, Claude Desktop, editors) can read and change it. It is the11+same code as [the commands](README.md#with-a-shell): reads are12+`012 describe` and `012 get`, and every write is typed through the13+checks `012 set` makes, as one undoable change, saved atomically at14+once.15+16+```mermaid17+flowchart LR18+ host[MCP host] -- "stdio: tools, resources, prompts" --> server["012 mcp book.012"]19+ server -- "open, read" --> file[(book.012)]20+ server -- "one change, Batch, atomic save" --> file21+ person["012 book.012, 012 set, git"] --> file22+```23+24+The file is opened afresh for every call, so what the screen, a script25+or git saved meanwhile is what the next call sees. A file that doesn't26+exist is an empty workbook, saved on the first change.27+28+## Adding it to a host29+30+Claude Code, for one workbook (`--scope project` writes `.mcp.json`31+to commit with the project; the default keeps it to you):32+33+```sh34+claude mcp add budget -- 012 mcp /path/to/budget.01235+claude mcp add --scope project budget -- 012 mcp budget.01236+```37+38+Claude Desktop, in `claude_desktop_config.json` (Settings > Developer >39+Edit Config), with the full paths, since Desktop doesn't start in your40+folder:41+42+```json43+{44+ "mcpServers": {45+ "budget": {46+ "command": "/usr/local/bin/012",47+ "args": ["mcp", "/Users/me/Documents/budget.012"]48+ }49+ }50+}51+```52+53+Other hosts take the same command and arguments.54+55+## Flags56+57+| Flag | Does |58+|---|---|59+| `--read-only` | Offers only the tools that read |60+| `--force` | Lets writes change [protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges), as `012 set --force` |61+| `--notebooks` | Offers `run_notebook_cell`, which runs a [notebook](../nushell/notebooks.md) cell with nu, if the workbook was saved on this computer |62+| `--trust` | With `--notebooks`, runs the cells of a workbook saved on another computer, and marks it trusted here |63+| `--jev` | Answers [JEV functions](../formulas/jev.md) over the network, each question once a session |64+65+Without them nothing runs a program or reaches the network: notebook66+outputs are read as the file keeps them, and JEV functions show `#N/A`.67+`shell = off` in the [configuration](../reference/config.md#shell) keeps68+notebook cells from running even with `--notebooks`.69+70+## Tools71+72+References are written as in formulas (`B7`, `Q3!A1:C9`,73+`'Q3 plan'!A:A`, a named range, a table, `Sales[Amount]`, a sheet's74+name), and inputs as a person types them, in en-US form. Every write75+takes `dry_run`, which returns the change without making it.76+77+| Tool | Does |78+|---|---|79+| `describe` | The workbook's sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |80+| `read_range` | A range's values as rows, its formulas by cell, and with `text` each cell as shown |81+| `evaluate` | A formula's value, computed in the workbook (at `at`, or below the data) without writing it: its text, why it's an error, an array's spill |82+| `find` | Cells by what they show or, with `in_formulas`, their formulas' text, as Edit > Find |83+| `list_errors` | The formulas showing errors after recalculating, as `012 recalc` lists them, without saving |84+| `write_cells` | Entries typed into cells as one change, as `012 set` |85+| `apply_operations` | Several operations as one change: `set`, `clear`, `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`, `add_sheet`, `rename_sheet`, `delete_sheet`, `define_name`, `sort` |86+| `sort` | A range's rows sorted by columns, named by letter or header |87+| `filter` | A filter on a range by conditions (`gt`, `contains` and the rest) or values to hide, or the sheet's filter removed |88+| `create_chart` | A chart of a range, its type, title and place given or guessed as Insert > Chart guesses them |89+| `create_pivot` | A pivot table on a new sheet: rows, columns and summarized values by header |90+| `run_notebook_cell` | With `--notebooks`: one code cell run, its output kept and sent on to its sheet |91+92+A write returns `saved`, `changes` (as [`012 diff`](../reference/json.md#diff)93+lists them) and `warnings`; a refused one is an error naming the cell,94+with nothing changed. Every result's schema is in95+[JSON output](../reference/json.md#mcp-tools).96+97+## Resources98+99+Hosts that let people attach data to a conversation list these; the100+scheme is `o12` since a URI's scheme starts with a letter, and a sheet's101+name is percent-encoded:102+103+| URI | Is |104+|---|---|105+| `o12://book/Sheet1!A1:D40` | A range, as `read_range` returns it; each sheet's used range is listed |106+| `o12://book/table/Sales` | A table, header row included; each table is listed |107+| `o12://book/notebook/Notes/2` | A notebook cell's source, and its output as NUON or why it failed; each code cell is listed |108+109+The list follows the workbook: a change that adds a sheet or table110+updates it, and hosts that listen are told.111+112+## Prompts113+114+| Prompt | Asks |115+|---|---|116+| `summarize_workbook` | What each sheet holds and what its numbers say, without changing anything |117+| `fix_errors` | The formulas showing errors explained, fixes checked with `evaluate` and previewed before they're made |118+| `add_column` | A column of formulas computing something beside a range (`range`, `column`) |119+| `chart` | The chart that best answers a question about a range (`range`, `question`) |@@ -29,6 +29,7 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
2929 | `012 recalc file.012` | Recalculates, saves and lists the cells showing errors: [Scripts](../files/scripts.md#recalc) |3030 | `012 export file.012 out [ref]` | Writes the workbook, a sheet or a range in another format: [Scripts](../files/scripts.md#export) |3131 | `012 describe file.012` | Lists the sheets and what's on them: [Agents](../agents/README.md#with-a-shell) |32+| `012 mcp file.012` | Serves the workbook to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |3233 | `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |3334 | `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |3435 | `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |@@ -37,8 +38,8 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
3738 | `012 config [path\|edit\|default\|themes\|set-key\|delete-key]` | The settings: [Configuration](config.md#commands) |3839 | `012 version [--format text\|json\|nuon]` | Prints the version |3940 40-`get`, `set`, `recalc`, `export`, `describe`, `diff`, `merge-driver`41-and `agent` take their flags anywhere after the command's name, and `--help` prints their41+`get`, `set`, `recalc`, `export`, `describe`, `mcp`, `diff`,42+`merge-driver` and `agent` take their flags anywhere after the command's name, and `--help` prints their4243 usage. `--` ends the flags, so what follows is taken as it is4344 (`012 set book.012 -- A1 --`), and an argument such as `-5` is never a4445 flag.@@ -56,6 +57,7 @@ flag.
5657 | `--chart n\|title` | `export` | One chart of the sheet as a web page, by its number or title |5758 | `--notebooks` | `get`, `recalc`, `export`, `describe` | Runs the notebooks' cells first, when the file is trusted here |5859 | `--trust` | with `--notebooks` | Runs the cells of a file saved on another computer |60+| `--read-only`, `--force`, `--notebooks`, `--jev` | `mcp` | The tools offered and what they may do: [MCP server](../agents/mcp.md#flags) |5961 | `--jev` | `get`, `recalc`, `export`, `describe` | Asks JEV for the JEV functions' answers first |6062 | `--format text\|json\|nuon` | `diff` | The form of the changes; text by default |6163 | `--color auto\|always\|never` | `diff` | Colors the text; auto colors on a terminal or git's pager unless `NO_COLOR` is set |@@ -126,6 +126,42 @@ See [Diff and merge in git](../files/git.md#012-diff).
126126 `what` for a conflict about a whole sheet. The merge and exit status127127 are as without it.128128 129+## MCP tools130+131+The [MCP server](../agents/mcp.md)'s tools return these, as structured132+content with the same JSON as text beside it. `describe` returns133+[describe's](#describe) schema, `list_errors` recalc's `errors` and134+`circular`.135+136+`read_range`, and the range resources:137+138+```json139+{140+ "range": "Sales!A1:C3", "rows": 3, "cols": 3,141+ "values": [["Region", "Units", "Price"], ["North", 3, 12.5], ["South", 5, null]],142+ "text": [["Region", "Units", "Price"], ["North", "3", "$12.50"], ["South", "5", ""]],143+ "formulas": {"C3": "=IF(B3>4, \"\", 9.75)"},144+ "truncated": false145+}146+```147+148+`values` are typed as [`get`](#get) types one cell (blank is `null`, an149+error its text); `text` comes only when asked for; `truncated` is set150+when the range held more cells than `max_cells`, and `rows` says how151+many came back.152+153+Writes (`write_cells`, `apply_operations`, `sort`, `filter`) return154+`{"saved", "changes", "warnings"}` as [`set`](#set) does, `saved` false155+for a dry run or a change that changed nothing; `create_chart` adds156+`"chart": {"sheet", "number", "title"}` and `create_pivot` `"sheet"`,157+the pivot's.158+159+| Tool | Returns |160+|---|---|161+| `evaluate` | `cell` where it was computed, `value`, `text` as shown, `error` explaining an error value, and `spill`, a `read_range` result, when the formula spilled |162+| `find` | `matches`, each `cell`, `text` and `input`, the first `limit` of them, and `total` |163+| `run_notebook_cell` | `notebook`, `cell`, `name`, `state` (`ran` or `failed`), `error`, `output` as NUON and `cut` when the output was longer than 64 KB |164+129165 ## version130166 131167 ```json@@ -12,6 +12,8 @@ require (
1212 github.com/charmbracelet/x/ansi v0.11.81313 github.com/charmbracelet/x/term v0.2.21414 github.com/cockroachdb/apd/v3 v3.2.315+ github.com/google/jsonschema-go v0.4.316+ github.com/modelcontextprotocol/go-sdk v1.8.01517 github.com/parquet-go/parquet-go v0.32.01618 github.com/sahilm/fuzzy v0.1.31719 github.com/xuri/excelize/v2 v2.11.0@@ -52,14 +54,19 @@ require (
5254 github.com/richardlehane/mscfb v1.0.7 // indirect5355 github.com/richardlehane/msoleps v1.0.6 // indirect5456 github.com/rivo/uniseg v0.4.7 // indirect57+ github.com/segmentio/asm v1.1.3 // indirect58+ github.com/segmentio/encoding v0.5.4 // indirect5559 github.com/tiendc/go-deepcopy v1.7.2 // indirect5660 github.com/twpayne/go-geom v1.6.1 // indirect5761 github.com/xo/terminfo v1.0.0 // indirect5862 github.com/xuri/efp v0.0.1 // indirect5963 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect64+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect6065 golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect6166 golang.org/x/net v0.59.0 // indirect67+ golang.org/x/oauth2 v0.35.0 // indirect6268 golang.org/x/sync v0.23.0 // indirect69+ golang.org/x/time v0.16.0 // indirect6370 google.golang.org/protobuf v1.36.11 // indirect6471 modernc.org/libc v1.75.7 // indirect6572 modernc.org/mathutil v1.7.1 // indirect@@ -56,8 +56,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
5656 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=5757 github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=5858 github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=59+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=60+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=5961 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=6062 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=63+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=64+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=6165 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=6266 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=6367 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=@@ -78,6 +82,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
7882 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=7983 github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=8084 github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=85+github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=86+github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=8187 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=8288 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=8389 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=@@ -104,6 +110,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
104110 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=105111 github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=106112 github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=113+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=114+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=115+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=116+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=107117 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=108118 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=109119 github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=@@ -120,6 +130,8 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
120130 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=121131 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=122132 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=133+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=134+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=123135 go.starlark.net v0.0.0-20260908191801-89a6a09411d5 h1:X8HyonnLxrmAbdeMIEGEJVZ/yg6WykLZyAZmpCLSfMA=124136 go.starlark.net v0.0.0-20260908191801-89a6a09411d5/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=125137 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=@@ -132,6 +144,8 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
132144 golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=133145 golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=134146 golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=147+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=148+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=135149 golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=136150 golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=137151 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=@@ -140,6 +154,8 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
140154 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=141155 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=142156 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=157+golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=158+golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=143159 golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=144160 golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=145161 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=@@ -0,0 +1,151 @@
1+package headless2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// ChartSpec is a chart to add, as Insert > Chart makes one: over Data,11+// with the header row, category labels and title guessed as Sheets12+// guesses them unless given.13+type ChartSpec struct {14+ Data string `json:"data" jsonschema:"the range to chart, e.g. Q3!A1:C9"`15+ Type string `json:"type,omitempty" jsonschema:"column, bar, line, pie, area or scatter; column when empty"`16+ Title string `json:"title,omitempty" jsonschema:"the title; guessed from the series when empty"`17+ At string `json:"at,omitempty" jsonschema:"the cell under the chart's top-left corner; right of the data when empty"`18+ Width int `json:"width,omitempty" jsonschema:"the width in terminal cells, 20 to 240; 60 when 0"`19+ Height int `json:"height,omitempty" jsonschema:"the height in terminal cells, 8 to 120; 18 when 0"`20+ ByRow bool `json:"by_row,omitempty" jsonschema:"series run along rows rather than down columns"`21+ Stack string `json:"stack,omitempty" jsonschema:"stacked or percent, for column, bar and area charts"`22+}23+24+// ChartMade says which chart AddChart made: its sheet and number, as25+// describe lists it.26+type ChartMade struct {27+ Sheet string `json:"sheet"`28+ Number int `json:"number"`29+ Title string `json:"title"`30+}31+32+// AddChart adds a chart as spec says.33+func AddChart(w *sheet.Workbook, spec ChartSpec) (ChartMade, error) {34+ t, err := Resolve(w, spec.Data)35+ if err != nil {36+ return ChartMade{}, err37+ }38+ s, r := t.Sheet, targetRect(t)39+ c := s.GuessChart(r)40+ if spec.Type != "" {41+ typ, ok := sheet.ParseChartType(spec.Type)42+ if !ok {43+ return ChartMade{}, fmt.Errorf("no chart type %q: column, bar, line, pie, area or scatter", spec.Type)44+ }45+ c.Type = typ46+ }47+ if spec.Title != "" {48+ c.Title = spec.Title49+ }50+ c.ByRow = spec.ByRow51+ if c.Stack, err = parseStack(spec.Stack); err != nil {52+ return ChartMade{}, err53+ }54+ c.W, c.H = orDefault(spec.Width, 60), orDefault(spec.Height, 18)55+ c.At = sheet.Addr{Col: min(r.To.Col+2, sheet.MaxCols-1), Row: r.From.Row}56+ if spec.At != "" {57+ cs, a, err := ResolveCell(w, spec.At)58+ if err != nil {59+ return ChartMade{}, err60+ }61+ if cs != s {62+ return ChartMade{}, fmt.Errorf("a chart sits on its data's sheet, %s", s.Name())63+ }64+ c.At = a65+ }66+ i := s.AddChart(c)67+ return ChartMade{Sheet: s.Name(), Number: i + 1, Title: ChartTitle(s.Charts()[i])}, nil68+}69+70+func parseStack(s string) (sheet.ChartStack, error) {71+ switch strings.ToLower(s) {72+ case "", "none":73+ return sheet.StackNone, nil74+ case "stacked":75+ return sheet.StackNormal, nil76+ case "percent":77+ return sheet.StackPercent, nil78+ }79+ return 0, fmt.Errorf("stack %q: stacked or percent", s)80+}81+82+func orDefault(v, d int) int {83+ if v <= 0 {84+ return d85+ }86+ return v87+}88+89+// PivotSpec is a pivot table to add, as Insert > Pivot table makes one:90+// a new sheet summarizing Source, whose first row names its columns.91+type PivotSpec struct {92+ Source string `json:"source" jsonschema:"the data, header row included, e.g. Sales!A1:F99"`93+ Name string `json:"name,omitempty" jsonschema:"the new sheet's name; Pivot Table N when empty"`94+ Rows []string `json:"rows,omitempty" jsonschema:"columns whose values become rows, by header or letter"`95+ Columns []string `json:"columns,omitempty" jsonschema:"columns whose values become columns, by header or letter"`96+ Values []PivotValue `json:"values" jsonschema:"columns to summarize"`97+ Totals bool `json:"totals,omitempty" jsonschema:"add grand totals"`98+}99+100+// PivotValue is a column to summarize, and how.101+type PivotValue struct {102+ Column string `json:"column" jsonschema:"by header or letter"`103+ Summarize string `json:"summarize,omitempty" jsonschema:"sum, counta, count, countunique, average, max or min; sum when empty"`104+}105+106+// AddPivot adds a pivot table's sheet after its source's, returning its107+// name.108+func AddPivot(w *sheet.Workbook, spec PivotSpec) (string, error) {109+ t, err := Resolve(w, spec.Source)110+ if err != nil {111+ return "", err112+ }113+ src, r := t.Sheet, targetRect(t)114+ p := sheet.Pivot{RowTotals: spec.Totals, ColumnTotals: spec.Totals}115+ for _, name := range spec.Rows {116+ col, err := columnOf(src, r, name, true)117+ if err != nil {118+ return "", err119+ }120+ p.Rows = append(p.Rows, sheet.PivotGroup{Col: col})121+ }122+ for _, name := range spec.Columns {123+ col, err := columnOf(src, r, name, true)124+ if err != nil {125+ return "", err126+ }127+ p.Columns = append(p.Columns, sheet.PivotGroup{Col: col})128+ }129+ if len(spec.Values) == 0 {130+ return "", fmt.Errorf("name a column to summarize")131+ }132+ for _, v := range spec.Values {133+ col, err := columnOf(src, r, v.Column, true)134+ if err != nil {135+ return "", err136+ }137+ sum := sheet.SumBy138+ if v.Summarize != "" {139+ var ok bool140+ if sum, ok = sheet.ParseSummarize(strings.ToLower(v.Summarize)); !ok {141+ return "", fmt.Errorf("summarize %q: sum, counta, count, countunique, average, max or min", v.Summarize)142+ }143+ }144+ p.Values = append(p.Values, sheet.PivotValue{Col: col, Summarize: sum})145+ }146+ s, err := w.CreatePivot(src, r, spec.Name, p)147+ if err != nil {148+ return "", err149+ }150+ return s.Name(), nil151+}@@ -22,7 +22,13 @@ const maxJEVRounds = 16
2222 // workbook's formulas ask, a few questions at a time, until none is2323 // waiting, each within timeout. It returns how many were asked.2424 func AnswerJEV(ctx context.Context, w *sheet.Workbook, client jev.Client, timeout time.Duration) int {25- cache := jev.NewCache()25+ return AnswerJEVFrom(ctx, w, client, jev.NewCache(), timeout)26+}27+28+// AnswerJEVFrom is AnswerJEV with the answers kept in cache, which a29+// caller opening the workbook again and again (012 mcp) keeps, so each30+// question is asked once.31+func AnswerJEVFrom(ctx context.Context, w *sheet.Workbook, client jev.Client, cache *jev.Cache, timeout time.Duration) int {2632 w.SetRemote(cache)2733 asked := 02834 for range maxJEVRounds {@@ -0,0 +1,182 @@
1+package headless2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Operation is one change of an Apply: what the MCP apply_operations11+// tool takes, each the same engine call a menu command makes. Op says12+// which, and the other fields are its arguments:13+//14+// set Ref (one cell) and Input, as 012 set types it15+// clear Ref: the range's contents, keeping formats and notes16+// insert_rows before Ref's first row, Count rows (Ref's rows when 0)17+// delete_rows Ref's rows18+// insert_columns before Ref's first column, Count columns19+// delete_columns Ref's columns20+// add_sheet Name, after the last sheet21+// rename_sheet Ref (a sheet) to Name22+// delete_sheet Ref (a sheet)23+// define_name Name for the range Ref24+// sort Ref's rows by Keys, the first row left in place with Header25+type Operation struct {26+ Op string `json:"op" jsonschema:"set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name or sort"`27+ Ref string `json:"ref,omitempty" jsonschema:"the cell, range or sheet the operation acts on, as formulas write it: B7, Q3!A1:C9, 'Q3 plan'!A:A, Q3"`28+ Input string `json:"input,omitempty" jsonschema:"for set: what to type, in en-US form (1.5, =SUM(A1:A6), 2026-09-29); empty clears the cell"`29+ Count int `json:"count,omitempty" jsonschema:"for insert_rows and insert_columns: how many; 0 inserts as many as ref spans"`30+ Name string `json:"name,omitempty" jsonschema:"for add_sheet and rename_sheet the sheet's name, for define_name the range's"`31+ Keys []SortKey `json:"keys,omitempty" jsonschema:"for sort: the columns to sort by, first first"`32+ Header bool `json:"header,omitempty" jsonschema:"for sort: the range's first row is a header and stays in place"`33+}34+35+// SortKey is a column to sort by: a letter (B) or, with a header, the36+// header's text.37+type SortKey struct {38+ Column string `json:"column" jsonschema:"a column letter (B) or the header's text"`39+ Descending bool `json:"descending,omitempty"`40+}41+42+// Apply makes ops as one change, in order, stopping at the first that43+// fails with an error naming it; the workbook should then be thrown44+// away rather than saved, as after Set.45+func Apply(w *sheet.Workbook, ops []Operation, o SetOptions) (warnings []string, err error) {46+ err = w.Batch(sheet.Change{Label: "apply operations", Sheet: w.Sheet(w.Active())}, func() error {47+ for i, op := range ops {48+ warn, err := applyOne(w, op, o)49+ if err != nil {50+ return fmt.Errorf("operation %d (%s): %w", i+1, op.Op, err)51+ }52+ if warn != "" {53+ warnings = append(warnings, warn)54+ }55+ }56+ return nil57+ })58+ return warnings, err59+}60+61+func applyOne(w *sheet.Workbook, op Operation, o SetOptions) (string, error) {62+ switch op.Op {63+ case "set":64+ return setOne(w, Entry{Ref: op.Ref, Input: op.Input}, o)65+ case "add_sheet":66+ _, err := w.AddSheet(op.Name, w.Len())67+ return "", err68+ case "define_name":69+ t, err := Resolve(w, op.Ref)70+ if err != nil {71+ return "", err72+ }73+ return "", w.DefineName(op.Name, t.Sheet, targetRect(t))74+ case "rename_sheet", "delete_sheet":75+ s := w.Lookup(strings.TrimSuffix(op.Ref, "!"))76+ if s == nil {77+ return "", noSheet(w, op.Ref)78+ }79+ if op.Op == "rename_sheet" {80+ return "", w.RenameSheet(s, op.Name)81+ }82+ return "", w.DeleteSheet(s)83+ case "sort":84+ return "", Sort(w, op.Ref, op.Keys, op.Header)85+ }86+ return "", rangeOp(w, op, o)87+}88+89+// rangeOp makes the operations on a range: clearing it, and inserting90+// and deleting its rows or columns.91+func rangeOp(w *sheet.Workbook, op Operation, o SetOptions) error {92+ t, err := Resolve(w, op.Ref)93+ if err != nil {94+ return err95+ }96+ s, r := t.Sheet, targetRect(t)97+ rows, cols := r.To.Row-r.From.Row+1, r.To.Col-r.From.Col+198+ if op.Count > 0 {99+ rows, cols = op.Count, op.Count100+ }101+ switch op.Op {102+ case "clear":103+ if p, ok := s.Protecting(r); ok && !o.Force {104+ return fmt.Errorf("%s is protected (%s): force clears it anyway", t, p.Label())105+ }106+ s.EraseRange(r)107+ return nil108+ case "insert_rows":109+ return s.InsertRows(r.From.Row, rows)110+ case "delete_rows":111+ s.DeleteRows(r.From.Row, r.To.Row-r.From.Row+1)112+ return nil113+ case "insert_columns":114+ return s.InsertCols(r.From.Col, cols)115+ case "delete_columns":116+ s.DeleteCols(r.From.Col, r.To.Col-r.From.Col+1)117+ return nil118+ }119+ return fmt.Errorf("no operation %q: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name or sort", op.Op)120+}121+122+// targetRect is the target's range: a whole sheet's is A1 to its last123+// cell with contents.124+func targetRect(t Target) sheet.Rect {125+ if t.Whole {126+ r, _ := t.Sheet.UsedRange()127+ return r128+ }129+ return t.Range130+}131+132+// Sort sorts the rows of the range ref names by keys, as Data > Sort133+// range does, leaving its first row in place when header is set.134+func Sort(w *sheet.Workbook, ref string, keys []SortKey, header bool) error {135+ t, err := Resolve(w, ref)136+ if err != nil {137+ return err138+ }139+ r := targetRect(t)140+ if len(keys) == 0 {141+ return fmt.Errorf("name a column to sort %s by", t)142+ }143+ var sk []sheet.SortKey144+ for _, k := range keys {145+ col, err := columnOf(t.Sheet, r, k.Column, header)146+ if err != nil {147+ return err148+ }149+ sk = append(sk, sheet.SortKey{Col: col, Desc: k.Descending})150+ }151+ if header {152+ r.From.Row++153+ }154+ if len(t.Sheet.MergesIn(r)) > 0 {155+ return fmt.Errorf("%s has merged cells, which can't be sorted", t)156+ }157+ t.Sheet.SortRange(r, sk)158+ return nil159+}160+161+// columnOf finds a column of r by its letter, or with a header row by162+// the header's text, ignoring case.163+func columnOf(s *sheet.Sheet, r sheet.Rect, name string, header bool) (int, error) {164+ if header {165+ for col := r.From.Col; col <= r.To.Col; col++ {166+ if v := s.Value(sheet.Addr{Col: col, Row: r.From.Row}); v.Kind == sheet.Text && strings.EqualFold(v.Str, name) {167+ return col, nil168+ }169+ }170+ }171+ if a, ok := sheet.ParseAddr(strings.ToUpper(name) + "1"); ok && a.Row == 0 && a.Col >= r.From.Col && a.Col <= r.To.Col {172+ return a.Col, nil173+ }174+ return 0, fmt.Errorf("%q isn't a column of %s: name it by its letter%s", name, r, headerHint(header))175+}176+177+func headerHint(header bool) string {178+ if header {179+ return " or its header"180+ }181+ return ""182+}@@ -0,0 +1,87 @@
1+package headless2+3+import (4+ "encoding/json"5+6+ "github.com/FelineStateMachine/012/internal/fileio"7+ "github.com/FelineStateMachine/012/internal/nuon"8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Range is a range's values as a grid, for agents: what ReadRange12+// returns and the MCP read_range tool gives back. Its JSON form is a13+// stable schema (docs/reference/json.md).14+type Range struct {15+ // Range is what was read, with its sheet: Q3!A1:D9; a whole sheet is16+ // read from A1 to its last cell with contents.17+ Range string `json:"range"`18+ Rows int `json:"rows"`19+ Cols int `json:"cols"`20+ // Values are the cells row by row, typed as 012 get --format json21+ // types one cell: numbers, strings, true or false, null for blank,22+ // dates as ISO 8601 strings, errors as their text (#DIV/0!).23+ Values [][]json.RawMessage `json:"values"`24+ // Text is each cell as the sheet shows it, when asked for.25+ Text [][]string `json:"text,omitempty"`26+ // Formulas are the formulas in the range by cell (B7: =SUM(B1:B6)).27+ Formulas map[string]string `json:"formulas"`28+ // Truncated is set when the range held more than the most cells a29+ // read returns; Rows says how many rows came back.30+ Truncated bool `json:"truncated"`31+}32+33+// ReadOptions tune ReadRange.34+type ReadOptions struct {35+ MaxCells int // the most cells to return, whole rows at a time; 0 is 10,00036+ Text bool // also return each cell as shown37+}38+39+// ReadRange reads t's cells as a grid.40+func ReadRange(t Target, o ReadOptions) Range {41+ if o.MaxCells <= 0 {42+ o.MaxCells = 10_00043+ }44+ s, r := t.Sheet, t.Range45+ if t.Whole {46+ r, _ = s.UsedRange()47+ } else if data, ok := s.FilledBounds(r); ok {48+ r.To.Row, r.To.Col = min(r.To.Row, data.To.Row), min(r.To.Col, data.To.Col)49+ } else {50+ r.To = r.From51+ }52+ cols := r.To.Col - r.From.Col + 153+ rows := r.To.Row - r.From.Row + 154+ out := Range{Range: sheet.Qualified(s.Name(), r), Cols: cols, Formulas: map[string]string{}}55+ if rows*cols > o.MaxCells {56+ rows, out.Truncated = max(o.MaxCells/cols, 1), true57+ }58+ out.Rows = rows59+ null := json.RawMessage("null")60+ for row := r.From.Row; row < r.From.Row+rows; row++ {61+ vals := make([]json.RawMessage, cols)62+ var text []string63+ if o.Text {64+ text = make([]string, cols)65+ }66+ for i := range cols {67+ a := sheet.Addr{Col: r.From.Col + i, Row: row}68+ vals[i] = null69+ if c := s.Cell(a); c != nil && c.IsFormula() {70+ out.Formulas[a.String()] = c.Input71+ }72+ v := s.Value(a)73+ if v.Kind == sheet.Empty {74+ continue75+ }76+ vals[i] = nuon.AppendJSON(nil, fileio.CellValue(fileio.SnapCell{Value: v, Format: s.DisplayFormat(a)}))77+ if o.Text {78+ text[i] = s.LocalText(a)79+ }80+ }81+ out.Values = append(out.Values, vals)82+ if o.Text {83+ out.Text = append(out.Text, text)84+ }85+ }86+ return out87+}@@ -0,0 +1,55 @@
1+package headless2+3+import (4+ "context"5+ "fmt"6+7+ "github.com/FelineStateMachine/012/internal/notebook"8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// CellRun is what running a notebook cell gave: its state, its output12+// as NUON (cut at maxShownOutput), or why it failed.13+type CellRun struct {14+ Notebook string `json:"notebook"`15+ Cell int `json:"cell"`16+ Name string `json:"name"`17+ State string `json:"state"` // "ran" or "failed"18+ Error string `json:"error"`19+ Output string `json:"output"` // NUON20+ Cut bool `json:"cut"` // Output was longer and is cut21+}22+23+// maxShownOutput is the most of an output CellRun returns.24+const maxShownOutput = 64 << 1025+26+// RunCell runs cell n (1 for the first) of the notebook tab named, as27+// Run does on the screen: its output replaces the one kept and goes on28+// to the sheet it was sent to. Whether it may run at all (trust, the29+// shell setting) is the caller's to decide first.30+func RunCell(ctx context.Context, w *sheet.Workbook, name string, n int, o NotebookOptions) (CellRun, error) {31+ s := w.Lookup(name)32+ if s == nil || !s.IsNotebook() {33+ return CellRun{}, fmt.Errorf("no notebook named %q", name)34+ }35+ cells := s.NotebookCells()36+ if n < 1 || n > len(cells) {37+ return CellRun{}, fmt.Errorf("%s has %d cells: cell %d isn't one", name, len(cells), n)38+ }39+ c := cells[n-1]40+ if c.Kind != notebook.Code {41+ return CellRun{}, fmt.Errorf("%s cell %d is a note, which doesn't run", name, n)42+ }43+ out := CellRun{Notebook: s.Name(), Cell: n, Name: c.Name(), State: "ran"}44+ if err := runCell(ctx, w, s, cells, n-1, 1, o); err != nil {45+ out.State, out.Error = "failed", err.Error()46+ return out, nil47+ }48+ if res := w.Output(c.ID); res != nil {49+ out.Output = string(res.NUON)50+ if len(out.Output) > maxShownOutput {51+ out.Output, out.Cut = out.Output[:maxShownOutput], true52+ }53+ }54+ return out, nil55+}@@ -11,7 +11,8 @@ import (
1111 // in it, as the file stores entries (numbers and dates in en-US's form,1212 // formulas with commas, whatever the workbook's locale); "" clears it.1313 type Entry struct {14- Ref, Input string14+ Ref string `json:"ref" jsonschema:"one cell, as formulas write it: B7, Q3!B7, 'Q3 plan'!B7"`15+ Input string `json:"input" jsonschema:"what to type, in en-US form: 1.5, =SUM(A1:A6), $1,200, 12%, 2026-09-29; empty clears the cell"`1516 }1617 1718 // SetOptions tune Set.@@ -0,0 +1,177 @@
1+package headless2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// FilterColumn is one column's criteria for Filter: a condition (as12+// Data > Create a filter's "Filter by condition" names them) with its13+// value, and values to hide ("Filter by values").14+type FilterColumn struct {15+ Column string `json:"column" jsonschema:"a column letter (B) or the header's text"`16+ Condition string `json:"condition,omitempty" jsonschema:"empty, not_empty, contains, not_contains, starts_with, ends_with, exactly, gt, ge, lt, le, eq or ne"`17+ Value string `json:"value,omitempty" jsonschema:"what the condition compares with, as typed: 100, 2026-01-31, North"`18+ Hide []string `json:"hide,omitempty" jsonschema:"values to hide, as the cells show them"`19+}20+21+// Filter puts a filter on the range ref names (its first row the22+// header) with the columns' criteria, replacing the sheet's filter, as23+// Data > Create a filter does; remove takes the sheet's filter off.24+func Filter(w *sheet.Workbook, ref string, cols []FilterColumn, remove bool) error {25+ t, err := Resolve(w, ref)26+ if err != nil {27+ return err28+ }29+ s := t.Sheet30+ if remove {31+ s.RemoveFilter()32+ return nil33+ }34+ r := targetRect(t)35+ if f := s.Filter(); f == nil || f.Range != r {36+ s.CreateFilter(r)37+ }38+ for _, c := range cols {39+ col, err := columnOf(s, r, c.Column, true)40+ if err != nil {41+ return err42+ }43+ cr := sheet.Criteria{Hidden: c.Hide}44+ if c.Condition != "" {45+ op, ok := sheet.ParseCondOp(c.Condition)46+ if !ok {47+ return fmt.Errorf("no condition %q: empty, not_empty, contains, not_contains, starts_with, ends_with, exactly, gt, ge, lt, le, eq or ne", c.Condition)48+ }49+ cr.Cond = sheet.Condition{Op: op, Arg: sheet.CondArg(op, c.Value, s.Locale())}50+ }51+ s.FilterColumn(col, cr)52+ }53+ return nil54+}55+56+// FindOptions tune Find.57+type FindOptions struct {58+ Ref string `json:"ref,omitempty" jsonschema:"a sheet or range to search; every sheet when empty"`59+ MatchCase bool `json:"match_case,omitempty"`60+ WholeCell bool `json:"whole_cell,omitempty" jsonschema:"the whole cell must match"`61+ Regex bool `json:"regex,omitempty" jsonschema:"the query is a regular expression"`62+ InFormulas bool `json:"in_formulas,omitempty" jsonschema:"search formulas' text rather than their values"`63+ Limit int `json:"limit,omitempty" jsonschema:"the most matches to return; 100 when 0"`64+}65+66+// Match is a cell Find found: where, what it shows and what was typed.67+type Match struct {68+ Cell string `json:"cell"`69+ Text string `json:"text"`70+ Input string `json:"input"`71+}72+73+// Found is what Find returns: the first matches, and how many there74+// are in all.75+type Found struct {76+ Matches []Match `json:"matches"`77+ Total int `json:"total"`78+}79+80+// Find searches the workbook's sheets, or the sheet or range o.Ref81+// names, as Edit > Find and replace does.82+func Find(w *sheet.Workbook, query string, o FindOptions) (Found, error) {83+ if o.Limit <= 0 {84+ o.Limit = 10085+ }86+ sheets, within := w.Sheets(), (*sheet.Rect)(nil)87+ if o.Ref != "" {88+ t, err := Resolve(w, o.Ref)89+ if err != nil {90+ return Found{}, err91+ }92+ sheets = []*sheet.Sheet{t.Sheet}93+ if !t.Whole {94+ r := t.Range95+ within = &r96+ }97+ }98+ out := Found{Matches: []Match{}}99+ for _, s := range sheets {100+ if s.IsNotebook() {101+ continue102+ }103+ addrs, err := s.Find(query, sheet.FindOptions{MatchCase: o.MatchCase, WholeCell: o.WholeCell, Regex: o.Regex,104+ InFormulas: o.InFormulas, Within: within})105+ if err != nil {106+ return Found{}, err107+ }108+ out.Total += len(addrs)109+ for _, a := range addrs {110+ if len(out.Matches) == o.Limit {111+ break112+ }113+ m := Match{Cell: sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a}), Text: s.LocalText(a)}114+ if c := s.Cell(a); c != nil {115+ m.Input = c.Input116+ }117+ out.Matches = append(out.Matches, m)118+ }119+ }120+ return out, nil121+}122+123+// Evaluated is a formula's result, computed without keeping it.124+type Evaluated struct {125+ Cell string `json:"cell"` // where it was computed126+ Value json.RawMessage `json:"value"` // typed as ReadRange types values127+ Text string `json:"text"` // as the cell would show it128+ Error string `json:"error"` // why, when Value is an error129+ Spill *Range `json:"spill,omitempty"`130+}131+132+// Evaluate computes formula as if typed in the cell at names (a cell133+// below the shown sheet's data when ""), and returns what it shows.134+// It changes w: call it on a workbook to throw away.135+func Evaluate(w *sheet.Workbook, formula, at string) (Evaluated, error) {136+ if !strings.HasPrefix(formula, "=") {137+ formula = "=" + formula138+ }139+ s, a, err := evalCell(w, at)140+ if err != nil {141+ return Evaluated{}, err142+ }143+ out := Evaluated{Cell: sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a})}144+ if err := w.Batch(sheet.Change{Label: "evaluate", Sheet: s}, func() error { return setFormula(s, a, formula) }); err != nil {145+ return out, err146+ }147+ res := ReadRange(Target{Sheet: s, Range: sheet.Rect{From: a, To: a}}, ReadOptions{})148+ out.Value, out.Text = res.Values[0][0], s.LocalText(a)149+ if v := s.Value(a); v.Kind == sheet.Error {150+ out.Error = s.ExplainError(a)151+ }152+ if area, ok := s.SpillArea(a); ok && area != (sheet.Rect{From: a, To: a}) {153+ spill := ReadRange(Target{Sheet: s, Range: area}, ReadOptions{MaxCells: 1000})154+ out.Spill = &spill155+ }156+ return out, nil157+}158+159+// evalCell is where Evaluate computes: the cell at names, or the first160+// cell of the column A two rows below the shown sheet's data.161+func evalCell(w *sheet.Workbook, at string) (*sheet.Sheet, sheet.Addr, error) {162+ if at != "" {163+ return ResolveCell(w, at)164+ }165+ s := w.Sheet(w.Active())166+ row := 0167+ if used, ok := s.UsedRange(); ok {168+ row = min(used.To.Row+2, sheet.MaxRows-1)169+ }170+ return s, sheet.Addr{Row: row}, nil171+}172+173+// setFormula types formula in a, with a parse error said as Set says it.174+func setFormula(s *sheet.Sheet, a sheet.Addr, formula string) error {175+ _, err := setOne(s.Book(), Entry{Ref: sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a}), Input: formula}, SetOptions{Force: true})176+ return err177+}@@ -0,0 +1,100 @@
1+package mcp2+3+import (4+ "context"5+ "path/filepath"6+ "strings"7+ "sync"8+9+ "github.com/FelineStateMachine/012/internal/diff"10+ "github.com/FelineStateMachine/012/internal/headless"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Backend is where the tools' workbook lives. The tools only read it,15+// work on a copy thrown away, or change it through the workbook's one16+// mutation path (Batch), so the file backend here and a live session's17+// (a workbook open on the screen, changed as a participant's operations)18+// can stand behind the same tools.19+type Backend interface {20+ // Name is what the workbook is called, for titles: its file's name.21+ Name() string22+ // View runs fn on the workbook as it is, which fn must not change.23+ View(ctx context.Context, fn func(*sheet.Workbook) error) error24+ // Scratch runs fn on a copy of the workbook that is thrown away.25+ Scratch(ctx context.Context, fn func(*sheet.Workbook) error) error26+ // Change runs fn on the workbook inside one Batch, labelled label,27+ // and returns what it changed. The change is kept unless fn fails or28+ // dryRun is set; a failed change leaves the workbook as it was.29+ Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error)30+}31+32+// FileBackend is a workbook file, opened afresh for every call so edits33+// made meanwhile by others (the screen, git, 012 set) are seen, and34+// saved atomically after each change, as 012 set saves it.35+type FileBackend struct {36+ Path string37+ // Prepare, when set, runs on the workbook once opened, before fn:38+ // what the server's flags allow (answering JEV functions).39+ Prepare func(context.Context, *headless.File) error40+ // Save writes a changed workbook; nil is File.Save.41+ Save func(*headless.File) error42+43+ mu sync.Mutex // one call at a time, so changes don't overlap44+}45+46+// Name is the file's name without its folder.47+func (b *FileBackend) Name() string {48+ return strings.TrimSuffix(filepath.Base(b.Path), filepath.Ext(b.Path))49+}50+51+// open opens the file, a missing one as an empty workbook saved on the52+// first change, as 012 set creates it.53+func (b *FileBackend) open(ctx context.Context) (*headless.File, error) {54+ f, err := headless.Open(b.Path, true)55+ if err != nil {56+ return nil, err57+ }58+ if b.Prepare != nil {59+ if err := b.Prepare(ctx, f); err != nil {60+ return nil, err61+ }62+ }63+ return f, nil64+}65+66+func (b *FileBackend) View(ctx context.Context, fn func(*sheet.Workbook) error) error {67+ b.mu.Lock()68+ defer b.mu.Unlock()69+ f, err := b.open(ctx)70+ if err != nil {71+ return err72+ }73+ return fn(f.Book)74+}75+76+// Scratch is View: every call opens its own copy of the file.77+func (b *FileBackend) Scratch(ctx context.Context, fn func(*sheet.Workbook) error) error {78+ return b.View(ctx, fn)79+}80+81+func (b *FileBackend) Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error) {82+ b.mu.Lock()83+ defer b.mu.Unlock()84+ f, err := b.open(ctx)85+ if err != nil {86+ return nil, err87+ }88+ w := f.Book89+ if err := w.Batch(sheet.Change{Label: label, Sheet: w.Sheet(w.Active())}, func() error { return fn(w) }); err != nil {90+ return nil, err91+ }92+ changes, err := f.Changes()93+ if err != nil || dryRun || len(changes) == 0 {94+ return changes, err95+ }96+ if b.Save != nil {97+ return changes, b.Save(f)98+ }99+ return changes, f.Save()100+}@@ -0,0 +1,60 @@
1+package mcp2+3+import (4+ "context"5+ "strings"6+7+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"8+)9+10+// Prompts are starting points a host offers people (as slash commands11+// in Claude Code): each a message asking the model to do a common job12+// with the tools, the way the tools are best used.13+var prompts = []struct {14+ prompt *sdk.Prompt15+ text string // with {arg} for each argument16+}{{17+ prompt: &sdk.Prompt{Name: "summarize_workbook", Title: "Summarize the workbook",18+ Description: "What each sheet holds and what its numbers say"},19+ text: "Call describe, then read the main ranges it points at with read_range (a few hundred rows at most). " +20+ "Summarize what each sheet holds, how the sheets relate (formulas between them, tables, pivots, charts), and what the numbers say. Don't change anything.",21+}, {22+ prompt: &sdk.Prompt{Name: "fix_errors", Title: "Fix formula errors",23+ Description: "Find the formulas showing errors, explain them and fix them"},24+ text: "Call list_errors. For each formula showing an error, read the cells it reads, and explain the cause. " +25+ "Propose a fix, check it with evaluate at the same cell, and show it with write_cells and dry_run. Make the fixes once they're agreed.",26+}, {27+ prompt: &sdk.Prompt{Name: "add_column", Title: "Add a computed column",28+ Description: "A new column of formulas beside a table of data",29+ Arguments: []*sdk.PromptArgument{30+ {Name: "range", Description: "the data, e.g. Sales!A1:F99, or a table's name", Required: true},31+ {Name: "column", Description: "what the new column computes, e.g. profit as revenue minus cost", Required: true},32+ }},33+ text: "Add a column to {range} that computes {column}. Call describe and read_range to find the header row and the columns involved. " +34+ "Write the header, then the formula for the first data row: check it with evaluate at that cell, then write every row's formula with write_cells (dry_run first), relative references moving with the row. " +35+ "Follow the columns' number formats. Finish with list_errors.",36+}, {37+ prompt: &sdk.Prompt{Name: "chart", Title: "Chart some data",38+ Description: "A chart that answers a question about a range",39+ Arguments: []*sdk.PromptArgument{40+ {Name: "range", Description: "the data, e.g. Sales!A1:C13", Required: true},41+ {Name: "question", Description: "what the chart should show, e.g. how sales grew by month"},42+ }},43+ text: "Read {range} with read_range and pick the chart that best shows {question}: line or area for change over time, column or bar to compare, pie for shares of a whole, scatter for two measures. " +44+ "If the data needs summarizing first, use create_pivot and chart its result. Create it with create_chart, with a title that says what it shows.",45+}}46+47+func (s *Server) addPrompts() {48+ for _, p := range prompts {49+ text := p.text50+ s.AddPrompt(p.prompt, func(_ context.Context, req *sdk.GetPromptRequest) (*sdk.GetPromptResult, error) {51+ msg := text52+ for k, v := range req.Params.Arguments {53+ msg = strings.ReplaceAll(msg, "{"+k+"}", v)54+ }55+ msg = strings.ReplaceAll(msg, "{question}", "the data's main point")56+ return &sdk.GetPromptResult{Description: req.Params.Name,57+ Messages: []*sdk.PromptMessage{{Role: "user", Content: &sdk.TextContent{Text: msg}}}}, nil58+ })59+ }60+}@@ -0,0 +1,103 @@
1+package mcp2+3+import (4+ "context"5+6+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"7+8+ "github.com/FelineStateMachine/012/internal/headless"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// The tools that read the workbook, or compute on a copy thrown away.13+14+type describeIn struct{}15+16+type readIn struct {17+ Ref string `json:"ref" jsonschema:"a cell, range, named range, table or sheet, as formulas write it: Q3!A1:D40, Sales, Sales[Amount], Q3"`18+ Text bool `json:"text,omitempty" jsonschema:"also return each cell as the sheet shows it ($1,200.00, 9/29/2026)"`19+ Max int `json:"max_cells,omitempty" jsonschema:"the most cells to return, whole rows at a time; 10000 when 0"`20+}21+22+type evaluateIn struct {23+ Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`24+ At string `json:"at,omitempty" jsonschema:"the cell it's computed in, which relative references are relative to; below the shown sheet's data when empty"`25+}26+27+type findIn struct {28+ Query string `json:"query" jsonschema:"what to look for"`29+ headless.FindOptions30+}31+32+type errorsIn struct{}33+34+// errorsOut lists the formulas showing errors.35+type errorsOut struct {36+ Errors []headless.ProblemRecord `json:"errors"`37+ Circular bool `json:"circular"`38+}39+40+var readOnly = &sdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: ptr(false)}41+42+func ptr[T any](v T) *T { return &v }43+44+func (s *Server) addReadTools() {45+ tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly,46+ Description: "What the workbook holds: each sheet's used range, guessed header row and column names, tables, notebook outputs and linked files, charts, pivot tables, notebook cells, and the named ranges. Call it first."},47+ func(ctx context.Context, _ *sdk.CallToolRequest, _ describeIn) (*sdk.CallToolResult, headless.Description, error) {48+ var d headless.Description49+ err := s.b.View(ctx, func(w *sheet.Workbook) error { d = headless.Describe(w); return nil })50+ return nil, d, err51+ })52+ tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly,53+ Description: "The values of a range as rows (numbers, strings, booleans, null for blank, dates as ISO 8601, errors as #DIV/0!), with the formulas in it by cell."},54+ s.readRange)55+ tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,56+ Description: "Compute a formula in the workbook without writing it: its value, as the cell would show it, why it's an error, and an array's spilled values."},57+ func(ctx context.Context, _ *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {58+ var out headless.Evaluated59+ err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {60+ var err error61+ out, err = headless.Evaluate(w, in.Formula, in.At)62+ return err63+ })64+ return nil, out, err65+ })66+ tool(s, &sdk.Tool{Name: "find", Annotations: readOnly,67+ Description: "Find cells by what they show (or their formulas' text), as Edit > Find does: the cells, their text and what was typed in them."},68+ func(ctx context.Context, _ *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {69+ var out headless.Found70+ err := s.b.View(ctx, func(w *sheet.Workbook) error {71+ var err error72+ out, err = headless.Find(w, in.Query, in.FindOptions)73+ return err74+ })75+ return nil, out, err76+ })77+ tool(s, &sdk.Tool{Name: "list_errors", Annotations: readOnly,78+ Description: "The formulas showing errors after recalculating everything, with what the screen says about each (as 012 recalc lists them), without saving."},79+ func(ctx context.Context, _ *sdk.CallToolRequest, _ errorsIn) (*sdk.CallToolResult, errorsOut, error) {80+ var out errorsOut81+ err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {82+ problems, circular := headless.Recalc(w)83+ out = errorsOut{Errors: headless.Problems(problems), Circular: circular}84+ return nil85+ })86+ return nil, out, err87+ })88+}89+90+func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, headless.Range, error) {91+ var out headless.Range92+ var res *sdk.CallToolResult93+ err := s.b.View(ctx, func(w *sheet.Workbook) error {94+ t, err := headless.Resolve(w, in.Ref)95+ if err != nil {96+ return err97+ }98+ out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})99+ res = s.rangeView(req, t)100+ return nil101+ })102+ return res, out, err103+}@@ -0,0 +1,225 @@
1+package mcp2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "strconv"8+ "strings"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+12+ "github.com/FelineStateMachine/012/internal/headless"13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// Resources are the workbook's data by address, for hosts that let18+// people attach them to a conversation:19+//20+// o12://book/Sheet1!A1:D40 a range, as read_range returns it (JSON)21+// o12://book/table/Sales a table, header row included (JSON)22+// o12://book/notebook/Notes/2 a notebook cell: its source and output23+//24+// Sheet names are percent-encoded. Each sheet's used range, each table25+// and each code cell are listed; the templates reach any range. The26+// scheme is o12 because a URI's scheme starts with a letter, and the27+// workbook is the authority, book, so a range's colon is in the path.28+const (29+ scheme = "o12://book/"30+ tablePrefix = scheme + "table/"31+ notebookPrefix = scheme + "notebook/"32+ maxListed = 20033+)34+35+func (s *Server) addResources() {36+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{sheet}!{+range}", Name: "range", MIMEType: "application/json",37+ Description: "A range of a sheet as rows of values, as read_range returns it; the sheet's name percent-encoded"}, s.readResource)38+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: tablePrefix + "{name}", Name: "table", MIMEType: "application/json",39+ Description: "A named table, header row included"}, s.readResource)40+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: notebookPrefix + "{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",41+ Description: "A notebook cell's source, and its output as NUON or why it failed"}, s.readResource)42+}43+44+// refreshResources lists the workbook's resources as it stands,45+// telling clients when the list changes.46+func (s *Server) refreshResources(ctx context.Context) {47+ var want []sdk.Resource48+ _ = s.b.View(ctx, func(w *sheet.Workbook) error {49+ want = listResources(w)50+ return nil51+ })52+ s.mu.Lock()53+ defer s.mu.Unlock()54+ keep := map[string]bool{}55+ for _, r := range want {56+ keep[r.URI] = true57+ if !s.resources[r.URI] {58+ res := r59+ s.AddResource(&res, s.readResource)60+ }61+ }62+ var gone []string63+ for uri := range s.resources {64+ if !keep[uri] {65+ gone = append(gone, uri)66+ }67+ }68+ if len(gone) > 0 {69+ s.RemoveResources(gone...)70+ }71+ s.resources = keep72+}73+74+func listResources(w *sheet.Workbook) []sdk.Resource {75+ var out []sdk.Resource76+ add := func(r sdk.Resource) {77+ if len(out) < maxListed {78+ out = append(out, r)79+ }80+ }81+ for _, sh := range w.Sheets() {82+ if sh.IsNotebook() {83+ for i, c := range sh.NotebookCells() {84+ if c.Kind == notebook.Code {85+ add(sdk.Resource{URI: fmt.Sprintf("%s%s/%d", notebookPrefix, escape(sh.Name()), i+1), Name: fmt.Sprintf("%s cell %d", sh.Name(), i+1),86+ MIMEType: "text/plain", Description: firstLine(c.Source)})87+ }88+ }89+ continue90+ }91+ if used, ok := sh.UsedRange(); ok {92+ add(sdk.Resource{URI: rangeURI(sh.Name(), used), Name: sh.Name(), Title: sh.Name() + " " + used.String(),93+ MIMEType: "application/json", Description: "The used range of " + sh.Name()})94+ }95+ for _, t := range sh.Tables() {96+ add(sdk.Resource{URI: tablePrefix + escape(t.Name), Name: "table " + t.Name, MIMEType: "application/json",97+ Description: "Table " + t.Name + " on " + sh.Name() + ", " + t.Range.String() + ": " + strings.Join(t.Cols, ", ")})98+ }99+ }100+ return out101+}102+103+// rangeURI is the resource of a range of a sheet.104+func rangeURI(sheetName string, r sheet.Rect) string {105+ return scheme + escape(sheetName) + "!" + r.String()106+}107+108+func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {109+ uri := req.Params.URI110+ var text, mime string111+ err := s.b.View(ctx, func(w *sheet.Workbook) error {112+ var err error113+ text, mime, err = resourceText(w, uri)114+ return err115+ })116+ if err != nil {117+ return nil, err118+ }119+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: uri, MIMEType: mime, Text: text}}}, nil120+}121+122+// resourceText is what a resource's URI names, and its type.123+func resourceText(w *sheet.Workbook, uri string) (string, string, error) {124+ switch {125+ case strings.HasPrefix(uri, notebookPrefix):126+ return notebookCellText(w, strings.TrimPrefix(uri, notebookPrefix))127+ case strings.HasPrefix(uri, tablePrefix):128+ name, err := unescape(strings.TrimPrefix(uri, tablePrefix))129+ if err != nil {130+ return "", "", err131+ }132+ return rangeJSON(w, name)133+ case strings.HasPrefix(uri, scheme):134+ rest := strings.TrimPrefix(uri, scheme)135+ i := strings.LastIndex(rest, "!")136+ if i < 0 {137+ return "", "", sdk.ResourceNotFoundError(uri)138+ }139+ name, err := unescape(rest[:i])140+ if err != nil {141+ return "", "", err142+ }143+ return rangeJSON(w, sheet.QuoteSheet(name)+"!"+rest[i+1:])144+ }145+ return "", "", sdk.ResourceNotFoundError(uri)146+}147+148+func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {149+ t, err := headless.Resolve(w, ref)150+ if err != nil {151+ return "", "", err152+ }153+ data, err := json.Marshal(headless.ReadRange(t, headless.ReadOptions{}))154+ return string(data), "application/json", err155+}156+157+// notebookCellText is a cell's source, then its output or why it158+// failed; path is sheet/cell.159+func notebookCellText(w *sheet.Workbook, path string) (string, string, error) {160+ i := strings.LastIndex(path, "/")161+ if i < 0 {162+ return "", "", fmt.Errorf("a notebook cell's resource is %snotebook/cell", notebookPrefix)163+ }164+ name, err := unescape(path[:i])165+ if err != nil {166+ return "", "", err167+ }168+ n, err := strconv.Atoi(path[i+1:])169+ s := w.Lookup(name)170+ if s == nil || !s.IsNotebook() || err != nil || n < 1 || n > len(s.NotebookCells()) {171+ return "", "", fmt.Errorf("no cell %s of a notebook %q", path[i+1:], name)172+ }173+ c := s.NotebookCells()[n-1]174+ var b strings.Builder175+ b.WriteString(c.Source + "\n")176+ switch o := w.Output(c.ID); {177+ case c.Kind != notebook.Code:178+ case o == nil || o.Unsaved:179+ b.WriteString("\n(not run)\n")180+ case o.Failed():181+ b.WriteString("\nfailed: " + o.Err + "\n")182+ default:183+ b.WriteString("\n" + string(o.NUON) + "\n")184+ }185+ return b.String(), "text/plain", nil186+}187+188+func firstLine(s string) string {189+ line, _, _ := strings.Cut(s, "\n")190+ return line191+}192+193+// escape percent-encodes all but URI's unreserved characters, so a194+// sheet's name fits a template's simple variable.195+func escape(s string) string {196+ var b strings.Builder197+ for _, c := range []byte(s) {198+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("-._~", c) >= 0 {199+ b.WriteByte(c)200+ } else {201+ fmt.Fprintf(&b, "%%%02X", c)202+ }203+ }204+ return b.String()205+}206+207+func unescape(s string) (string, error) {208+ var b strings.Builder209+ for i := 0; i < len(s); i++ {210+ if s[i] != '%' {211+ b.WriteByte(s[i])212+ continue213+ }214+ if i+2 >= len(s) {215+ return "", fmt.Errorf("%q: a %% needs two hex digits", s)216+ }217+ v, err := strconv.ParseUint(s[i+1:i+3], 16, 8)218+ if err != nil {219+ return "", fmt.Errorf("%q: a %% needs two hex digits", s)220+ }221+ b.WriteByte(byte(v))222+ i += 2223+ }224+ return b.String(), nil225+}@@ -0,0 +1,96 @@
1+// Package mcp is 012 mcp: a Model Context Protocol server on one2+// workbook (see docs/agents/mcp.md). Its tools, resources and prompts3+// are a thin layer over internal/headless, the same code 012 get, set4+// and describe run, so agents' changes go through the workbook's one5+// mutation path (Batch) and the same checks as a person's, and are6+// shown by 012 diff. The workbook stands behind a Backend: a file here,7+// and for live mode a session.8+package mcp9+10+import (11+ "context"12+ "encoding/json"13+ "reflect"14+ "sync"15+16+ "github.com/google/jsonschema-go/jsonschema"17+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"18+19+ "github.com/FelineStateMachine/012/internal/headless"20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// Options say what the server may do beyond reading and writing cells.24+type Options struct {25+ // Version is 012's, which the server reports.26+ Version string27+ // ReadOnly leaves out every tool that changes the workbook.28+ ReadOnly bool29+ // Force lets writes change protected ranges, as 012 set --force.30+ Force bool31+ // Notebooks, when set, lets run_notebook_cell run cells with nu, as32+ // --notebooks does; nil leaves the tool out. MayRun says why a33+ // workbook's cells may not run here (trust, the shell setting), and34+ // may mark it trusted, as the command line's --trust does.35+ Notebooks *headless.NotebookOptions36+ MayRun func(*sheet.Workbook) error37+}38+39+// Server is the MCP server on a backend.40+type Server struct {41+ *sdk.Server42+ b Backend43+ opts Options44+45+ mu sync.Mutex46+ resources map[string]bool // the URIs listed now47+}48+49+// New makes the server with its tools, resources and prompts.50+func New(b Backend, o Options) *Server {51+ s := &Server{b: b, opts: o, resources: map[string]bool{}}52+ s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)},53+ &sdk.ServerOptions{Instructions: instructions, Capabilities: &sdk.ServerCapabilities{}})54+ s.addReadTools()55+ if !o.ReadOnly {56+ s.addWriteTools()57+ }58+ s.addResources()59+ s.addPrompts()60+ s.addViews()61+ s.refreshResources(context.Background())62+ return s63+}64+65+func orDevel(v string) string {66+ if v == "" {67+ return "(devel)"68+ }69+ return v70+}71+72+// instructions tell the model how to use the server.73+const instructions = `This server works on one 012 spreadsheet workbook (.012 file).74+Call describe first: it lists the sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks.75+References are written as in formulas: B7, A1:C9, Q3!B7, 'Q3 plan'!A1:C9, a named range, a table (Sales, Sales[Amount]) or a sheet name for the whole sheet; without a sheet name, the sheet shown when the file was saved.76+Inputs are what a person types, in en-US form: 1.5, =SUM(A1:A6), $1,200, 12%, 2026-09-29. Formulas are Google Sheets'.77+Writes are checked as typing is (formulas must parse, validation rules, protected ranges) and each call is one undoable change, saved at once; pass dry_run to see the change without making it. Call evaluate to try a formula without writing it.`78+79+// rawSchemas infer json.RawMessage, a value already in JSON, as any80+// value.81+var rawSchemas = &jsonschema.ForOptions{TypeSchemas: map[reflect.Type]*jsonschema.Schema{82+ reflect.TypeFor[json.RawMessage](): {},83+}}84+85+// tool adds a tool whose handler takes In and returns Out, with the86+// output schema inferred as rawSchemas says.87+func tool[In, Out any](s *Server, t *sdk.Tool, h func(context.Context, *sdk.CallToolRequest, In) (*sdk.CallToolResult, Out, error)) {88+ if t.OutputSchema == nil {89+ schema, err := jsonschema.For[Out](rawSchemas)90+ if err != nil {91+ panic(err)92+ }93+ t.OutputSchema = schema94+ }95+ sdk.AddTool(s.Server, t, h)96+}@@ -0,0 +1,340 @@
1+package mcp2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "errors"8+ "io"9+ "os"10+ "path/filepath"11+ "slices"12+ "strings"13+ "testing"14+15+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"16+17+ "github.com/FelineStateMachine/012/internal/headless"18+ "github.com/FelineStateMachine/012/internal/notebook"19+ "github.com/FelineStateMachine/012/internal/nushell"20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// bookFile writes a workbook: a sheet of sales with a header, and a24+// second sheet whose name has a space.25+func bookFile(t *testing.T) string {26+ t.Helper()27+ w := sheet.NewBook()28+ s := w.Sheet(0)29+ rows := [][]string{{"Region", "Units", "Price"}, {"North", "3", "12.5"}, {"South", "5", "9.75"}, {"North", "7", "12.5"}}30+ for r, row := range rows {31+ for c, in := range row {32+ s.Set(sheet.Addr{Col: c, Row: r}, in)33+ }34+ }35+ other, _ := w.AddSheet("Q3 plan", 1)36+ other.Set(sheet.Addr{}, "=Sheet1!B2*2")37+ path := filepath.Join(t.TempDir(), "book.012")38+ writeBook(t, w, path)39+ return path40+}41+42+func writeBook(t *testing.T, w *sheet.Workbook, path string) {43+ t.Helper()44+ var b bytes.Buffer45+ if err := w.Write(&b); err != nil {46+ t.Fatal(err)47+ }48+ if err := os.WriteFile(path, b.Bytes(), 0o644); err != nil {49+ t.Fatal(err)50+ }51+}52+53+// connect serves backend b with options o to a client over the SDK's54+// in-memory transport.55+func connect(t *testing.T, b Backend, o Options) *sdk.ClientSession {56+ t.Helper()57+ ctx := context.Background()58+ srv := New(b, o)59+ st, ct := sdk.NewInMemoryTransports()60+ ss, err := srv.Connect(ctx, st, nil)61+ if err != nil {62+ t.Fatal(err)63+ }64+ t.Cleanup(func() { ss.Close() })65+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, ct, nil)66+ if err != nil {67+ t.Fatal(err)68+ }69+ t.Cleanup(func() { cs.Close() })70+ return cs71+}72+73+// call calls a tool, failing on a protocol error, and decodes its74+// structured result into out.75+func call(t *testing.T, cs *sdk.ClientSession, name string, args any, out any) *sdk.CallToolResult {76+ t.Helper()77+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: name, Arguments: args})78+ if err != nil {79+ t.Fatalf("%s: %v", name, err)80+ }81+ if out != nil && !res.IsError {82+ data, _ := json.Marshal(res.StructuredContent)83+ if err := json.Unmarshal(data, out); err != nil {84+ t.Fatalf("%s: %v in %s", name, err, data)85+ }86+ }87+ return res88+}89+90+func errorText(res *sdk.CallToolResult) string {91+ if !res.IsError || len(res.Content) == 0 {92+ return ""93+ }94+ return res.Content[0].(*sdk.TextContent).Text95+}96+97+func TestTools(t *testing.T) {98+ cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})99+ var names []string100+ for tl, err := range cs.Tools(context.Background(), nil) {101+ if err != nil {102+ t.Fatal(err)103+ }104+ names = append(names, tl.Name)105+ }106+ for _, want := range []string{"describe", "read_range", "evaluate", "find", "list_errors", "write_cells", "apply_operations",107+ "sort", "filter", "create_chart", "create_pivot"} {108+ if !slices.Contains(names, want) {109+ t.Errorf("no tool %s in %v", want, names)110+ }111+ }112+ if slices.Contains(names, "run_notebook_cell") {113+ t.Error("run_notebook_cell offered without Notebooks")114+ }115+ ro := connect(t, &FileBackend{Path: bookFile(t)}, Options{ReadOnly: true})116+ for tl := range ro.Tools(context.Background(), nil) {117+ if !tl.Annotations.ReadOnlyHint {118+ t.Errorf("read-only server offers %s", tl.Name)119+ }120+ }121+}122+123+func TestDescribeAndRead(t *testing.T) {124+ cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})125+ var d headless.Description126+ call(t, cs, "describe", map[string]any{}, &d)127+ if len(d.Sheets) != 2 || d.Sheets[0].Header != 1 || !slices.Equal(d.Sheets[0].Columns, []string{"Region", "Units", "Price"}) {128+ t.Errorf("describe: %+v", d.Sheets)129+ }130+ var r headless.Range131+ call(t, cs, "read_range", map[string]any{"ref": "A1:C4", "text": true}, &r)132+ if r.Range != "Sheet1!A1:C4" || r.Rows != 4 || string(r.Values[1][2]) != "12.5" || r.Text[0][0] != "Region" {133+ t.Errorf("read_range: %+v", r)134+ }135+ call(t, cs, "read_range", map[string]any{"ref": "'Q3 plan'!A1"}, &r)136+ if string(r.Values[0][0]) != "6" || r.Formulas["A1"] != "=Sheet1!B2*2" {137+ t.Errorf("read_range of a formula: %+v", r)138+ }139+ res := call(t, cs, "read_range", map[string]any{"ref": "Q4!A1"}, nil)140+ if !strings.Contains(errorText(res), `no sheet named "Q4" (sheets: Sheet1, Q3 plan)`) {141+ t.Errorf("bad sheet: %q", errorText(res))142+ }143+ var found headless.Found144+ call(t, cs, "find", map[string]any{"query": "north"}, &found)145+ if found.Total != 2 || found.Matches[1].Cell != "Sheet1!A4" {146+ t.Errorf("find: %+v", found)147+ }148+}149+150+func TestEvaluateLeavesTheFile(t *testing.T) {151+ path := bookFile(t)152+ before, _ := os.ReadFile(path)153+ cs := connect(t, &FileBackend{Path: path}, Options{})154+ var ev headless.Evaluated155+ call(t, cs, "evaluate", map[string]any{"formula": `SUMIF(A2:A4, "North", B2:B4)`}, &ev)156+ if string(ev.Value) != "10" || ev.Cell != "Sheet1!A6" {157+ t.Errorf("evaluate: %+v", ev)158+ }159+ call(t, cs, "evaluate", map[string]any{"formula": "=SEQUENCE(2)", "at": "E1"}, &ev)160+ if ev.Spill == nil || ev.Spill.Range != "Sheet1!E1:E2" {161+ t.Errorf("spill: %+v", ev.Spill)162+ }163+ call(t, cs, "evaluate", map[string]any{"formula": "=1/0"}, &ev)164+ if string(ev.Value) != `"#DIV/0!"` || ev.Error == "" {165+ t.Errorf("error: %+v", ev)166+ }167+ if res := call(t, cs, "evaluate", map[string]any{"formula": "=SUM(A1"}, nil); !strings.Contains(errorText(res), "Expected") {168+ t.Errorf("parse error: %q", errorText(res))169+ }170+ var errs errorsOut171+ call(t, cs, "list_errors", map[string]any{}, &errs)172+ if len(errs.Errors) != 0 {173+ t.Errorf("errors: %+v", errs)174+ }175+ if after, _ := os.ReadFile(path); !bytes.Equal(before, after) {176+ t.Error("reading tools changed the file")177+ }178+}179+180+func TestWrites(t *testing.T) {181+ path := bookFile(t)182+ cs := connect(t, &FileBackend{Path: path}, Options{})183+ before, _ := os.ReadFile(path)184+ var ch Changed185+ call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D1", "input": "Total"}, {"ref": "D2", "input": "=B2*C2"}},186+ "dry_run": true}, &ch)187+ if ch.Saved || len(ch.Changes) != 3 || ch.Changes[1].Item != "D2" || string(ch.Changes[2].New) != "37.5" {188+ t.Errorf("dry run: %+v", ch)189+ }190+ if after, _ := os.ReadFile(path); !bytes.Equal(before, after) {191+ t.Error("dry run saved")192+ }193+ call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D2", "input": "=B2*C2"}}}, &ch)194+ if !ch.Saved || len(ch.Changes) != 2 {195+ t.Errorf("write: %+v", ch)196+ }197+ f, _ := headless.Open(path, false)198+ if v := f.Book.Sheet(0).Value(sheet.Addr{Col: 3, Row: 1}); v.Num != 37.5 {199+ t.Errorf("D2 = %v", v)200+ }201+ res := call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D3", "input": "=SUM(A1"}}}, nil)202+ if !strings.Contains(errorText(res), "Sheet1!D3: Expected") {203+ t.Errorf("bad formula: %q", errorText(res))204+ }205+ call(t, cs, "apply_operations", map[string]any{"operations": []map[string]any{206+ {"op": "insert_rows", "ref": "A2"}, {"op": "set", "ref": "A2", "input": "East"}, {"op": "define_name", "ref": "B2:B5", "name": "Units"},207+ }}, &ch)208+ if !ch.Saved {209+ t.Errorf("operations: %+v", ch)210+ }211+ call(t, cs, "sort", map[string]any{"ref": "A1:C5", "header": true, "keys": []map[string]any{{"column": "Units", "descending": true}}}, &ch)212+ var r headless.Range213+ call(t, cs, "read_range", map[string]any{"ref": "Units"}, &r)214+ if got := string(r.Values[0][0]) + string(r.Values[1][0]); got != "75" {215+ t.Errorf("sorted units start %s: %+v", got, r.Values)216+ }217+ call(t, cs, "filter", map[string]any{"ref": "A1:C5", "columns": []map[string]any{{"column": "Region", "condition": "eq", "value": "North"}}}, &ch)218+ var d headless.Description219+ call(t, cs, "describe", map[string]any{}, &d)220+ if d.Sheets[0].Filter != "A1:C5" || len(d.Names) != 1 {221+ t.Errorf("filter or name missing: %+v", d)222+ }223+}224+225+func TestChartAndPivot(t *testing.T) {226+ path := bookFile(t)227+ cs := connect(t, &FileBackend{Path: path}, Options{})228+ var c chartOut229+ call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "type": "bar", "title": "Units"}, &c)230+ if c.Chart.Number != 1 || c.Chart.Sheet != "Sheet1" || !c.Saved {231+ t.Errorf("chart: %+v", c)232+ }233+ var p pivotOut234+ call(t, cs, "create_pivot", map[string]any{"source": "A1:C4", "rows": []string{"Region"}, "values": []map[string]any{{"column": "Units"}}}, &p)235+ if p.Sheet != "Pivot Table 1" || !p.Saved {236+ t.Errorf("pivot: %+v", p)237+ }238+ var r headless.Range239+ call(t, cs, "read_range", map[string]any{"ref": "'Pivot Table 1'!A1:B3"}, &r)240+ if string(r.Values[1][0]) != `"North"` || string(r.Values[1][1]) != "10" {241+ t.Errorf("pivot result: %+v", r.Values)242+ }243+ res := call(t, cs, "create_pivot", map[string]any{"source": "A1:C4", "values": []map[string]any{{"column": "Weight"}}}, nil)244+ if !strings.Contains(errorText(res), `"Weight" isn't a column of A1:C4`) {245+ t.Errorf("unknown column: %q", errorText(res))246+ }247+}248+249+func TestResourcesAndPrompts(t *testing.T) {250+ cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})251+ ctx := context.Background()252+ var uris []string253+ for r, err := range cs.Resources(ctx, nil) {254+ if err != nil {255+ t.Fatal(err)256+ }257+ uris = append(uris, r.URI)258+ }259+ if !slices.Contains(uris, "o12://book/Sheet1!A1:C4") || !slices.Contains(uris, "o12://book/Q3%20plan!A1") {260+ t.Errorf("resources: %v", uris)261+ }262+ for uri, want := range map[string]string{263+ "o12://book/Sheet1!A1:C4": `"range":"Sheet1!A1:C4"`,264+ "o12://book/Q3%20plan!A1": `"formulas":{"A1":"=Sheet1!B2*2"}`,265+ "o12://book/Sheet1!B2:B3": `"values":[[3],[5]]`,266+ "o12://book/table/Missing": "isn't a cell",267+ } {268+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})269+ got := ""270+ if err != nil {271+ got = err.Error()272+ } else {273+ got = res.Contents[0].Text274+ }275+ if !strings.Contains(got, want) {276+ t.Errorf("%s: %s, want %s", uri, got, want)277+ }278+ }279+ var names []string280+ for p, err := range cs.Prompts(ctx, nil) {281+ if err != nil {282+ t.Fatal(err)283+ }284+ names = append(names, p.Name)285+ }286+ if len(names) != len(prompts) {287+ t.Errorf("prompts: %v", names)288+ }289+ res, err := cs.GetPrompt(ctx, &sdk.GetPromptParams{Name: "add_column", Arguments: map[string]string{"range": "Sheet1!A1:C4", "column": "revenue"}})290+ if err != nil {291+ t.Fatal(err)292+ }293+ if text := res.Messages[0].Content.(*sdk.TextContent).Text; !strings.Contains(text, "Add a column to Sheet1!A1:C4 that computes revenue") {294+ t.Errorf("prompt: %s", text)295+ }296+}297+298+var errNotTrusted = errors.New("book.012 was saved on another computer")299+300+// fakeNu answers every cell with one table.301+type fakeNu struct{ ran int }302+303+func (f *fakeNu) Run(_ context.Context, _ nushell.Job, _ string, stdout io.Writer) error {304+ f.ran++305+ _, err := stdout.Write([]byte("[[name]; [a.txt]]"))306+ return err307+}308+309+func TestRunNotebookCell(t *testing.T) {310+ w := sheet.NewBook()311+ nb, _ := w.AddNotebook("Notes", w.Sheet(0))312+ nb.SetNotebookCells("add cell", []notebook.Cell{{Kind: notebook.Note, Source: "# Files"}, {Kind: notebook.Code, Source: "files = ls"}})313+ path := filepath.Join(t.TempDir(), "nb.012")314+ writeBook(t, w, path)315+ nu := &fakeNu{}316+ refuse := true317+ cs := connect(t, &FileBackend{Path: path}, Options{Notebooks: &headless.NotebookOptions{Runner: nu},318+ MayRun: func(*sheet.Workbook) error {319+ if refuse {320+ return errNotTrusted321+ }322+ return nil323+ }})324+ if res := call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 2}, nil); errorText(res) != errNotTrusted.Error() || nu.ran != 0 {325+ t.Errorf("untrusted: %q, ran %d", errorText(res), nu.ran)326+ }327+ refuse = false328+ var run headless.CellRun329+ call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 2}, &run)330+ if run.State != "ran" || run.Name != "files" || !strings.Contains(run.Output, "a.txt") {331+ t.Errorf("run: %+v", run)332+ }333+ res, err := cs.ReadResource(context.Background(), &sdk.ReadResourceParams{URI: "o12://book/notebook/Notes/2"})334+ if err != nil || !strings.Contains(res.Contents[0].Text, "a.txt") {335+ t.Errorf("cell resource after the run: %v %+v", err, res)336+ }337+ if res := call(t, cs, "run_notebook_cell", map[string]any{"notebook": "Notes", "cell": 1}, nil); !strings.Contains(errorText(res), "is a note") {338+ t.Errorf("note cell: %q", errorText(res))339+ }340+}@@ -0,0 +1,18 @@
1+package mcp2+3+import (4+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"5+6+ "github.com/FelineStateMachine/012/internal/headless"7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+func (s *Server) addViews() {}11+12+func (s *Server) viewMeta() sdk.Meta { return nil }13+14+func (s *Server) rangeView(*sdk.CallToolRequest, headless.Target) *sdk.CallToolResult { return nil }15+16+func (s *Server) chartView(*sdk.CallToolRequest, *sheet.Workbook, headless.ChartMade) *sdk.CallToolResult {17+ return nil18+}@@ -0,0 +1,169 @@
1+package mcp2+3+import (4+ "context"5+6+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"7+8+ "github.com/FelineStateMachine/012/internal/headless"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// The tools that change the workbook. Each is one call to the backend's13+// Change, so one undo step and one save, and returns what changed as14+// 012 diff lists it; dry_run returns it without keeping it.15+16+type writeIn struct {17+ Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input, typed as a person types it in en-US form; an empty input clears the cell"`18+ DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`19+}20+21+type opsIn struct {22+ Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`23+ DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`24+}25+26+type sortIn struct {27+ Ref string `json:"ref" jsonschema:"the range to sort"`28+ Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`29+ Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`30+ DryRun bool `json:"dry_run,omitempty"`31+}32+33+type filterIn struct {34+ Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`35+ Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`36+ Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`37+ DryRun bool `json:"dry_run,omitempty"`38+}39+40+type chartIn struct {41+ headless.ChartSpec42+ DryRun bool `json:"dry_run,omitempty"`43+}44+45+type pivotIn struct {46+ headless.PivotSpec47+ DryRun bool `json:"dry_run,omitempty"`48+}49+50+type runCellIn struct {51+ Notebook string `json:"notebook" jsonschema:"the notebook tab's name"`52+ Cell int `json:"cell" jsonschema:"the cell's number, 1 for the first, as describe lists them"`53+}54+55+// Changed is what a write returns: whether it was kept, what changed56+// as 012 diff lists it, and warnings of entries a rule marks invalid.57+type Changed struct {58+ Saved bool `json:"saved"`59+ Changes []headless.Change `json:"changes"`60+ Warnings []string `json:"warnings"`61+}62+63+type chartOut struct {64+ Chart headless.ChartMade `json:"chart"`65+ Changed66+}67+68+type pivotOut struct {69+ Sheet string `json:"sheet"`70+ Changed71+}72+73+var writes = &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(false)}74+75+func (s *Server) addWriteTools() {76+ set := headless.SetOptions{Force: s.opts.Force}77+ tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,78+ Description: "Type entries into cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take."},79+ func(ctx context.Context, _ *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {80+ var warnings []string81+ return s.change(ctx, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {82+ warnings, err = headless.Set(w, in.Entries, set)83+ return err84+ })85+ })86+ tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},87+ Description: "Make several operations as one change: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},88+ func(ctx context.Context, _ *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {89+ var warnings []string90+ return s.change(ctx, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {91+ warnings, err = headless.Apply(w, in.Operations, set)92+ return err93+ })94+ })95+ tool(s, &sdk.Tool{Name: "sort", Annotations: writes, Description: "Sort a range's rows by columns, as Data > Sort range does."},96+ func(ctx context.Context, _ *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {97+ return s.change(ctx, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })98+ })99+ tool(s, &sdk.Tool{Name: "filter", Annotations: writes,100+ Description: "Put a filter on a range, hiding the rows its columns' conditions or values leave out, as Data > Create a filter does; or remove the sheet's filter."},101+ func(ctx context.Context, _ *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {102+ return s.change(ctx, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })103+ })104+ tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: s.viewMeta(),105+ Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},106+ s.createChart)107+ tool(s, &sdk.Tool{Name: "create_pivot", Annotations: writes,108+ Description: "Add a pivot table on a new sheet, summarizing a range whose first row names its columns."},109+ func(ctx context.Context, _ *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {110+ var out pivotOut111+ _, changed, err := s.change(ctx, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {112+ out.Sheet, err = headless.AddPivot(w, in.PivotSpec)113+ return err114+ })115+ out.Changed = changed116+ return nil, out, err117+ })118+ if s.opts.Notebooks != nil {119+ tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},120+ Description: "Run a notebook tab's code cell with nushell, as Run does on the screen: its output replaces the one kept and goes on to the sheet it was sent to."},121+ s.runCell)122+ }123+}124+125+func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {126+ var out chartOut127+ var res *sdk.CallToolResult128+ _, changed, err := s.change(ctx, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {129+ if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {130+ return err131+ }132+ res = s.chartView(req, w, out.Chart)133+ return nil134+ })135+ out.Changed = changed136+ return res, out, err137+}138+139+func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {140+ var out headless.CellRun141+ _, _, err := s.change(ctx, "run notebook cell", false, nil, func(w *sheet.Workbook) error {142+ if s.opts.MayRun != nil {143+ if err := s.opts.MayRun(w); err != nil {144+ return err145+ }146+ }147+ var err error148+ out, err = headless.RunCell(ctx, w, in.Notebook, in.Cell, *s.opts.Notebooks)149+ return err150+ })151+ return nil, out, err152+}153+154+// change makes fn a change of the backend's and says what it changed;155+// warnings, when set, are fn's.156+func (s *Server) change(ctx context.Context, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {157+ changes, err := s.b.Change(ctx, label, dryRun, fn)158+ if err != nil {159+ return nil, Changed{}, err160+ }161+ out := Changed{Saved: !dryRun && len(changes) > 0, Changes: headless.Changes(changes), Warnings: []string{}}162+ if warnings != nil && *warnings != nil {163+ out.Warnings = *warnings164+ }165+ if out.Saved {166+ s.refreshResources(ctx)167+ }168+ return nil, out, nil169+}