1// Package mcp is 012 mcp: a Model Context Protocol server on the2// workbooks in the folders open to it (see docs/agents/mcp.md). Its3// tools, resources and prompts are a thin layer over internal/headless,4// the same code 012 get, set and describe run, so agents' changes go5// through the workbook's one mutation path (Batch) and the same checks6// as a person's, and are shown by 012 diff. Each workbook stands behind7// a Backend: a file here, and for live mode a session.8package mcp910import (11 "context"12 "slices"13 "sync"1415 "github.com/google/jsonschema-go/jsonschema"16 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1718 "src.rest/cat/012/internal/confine"19 "src.rest/cat/012/internal/headless"20 "src.rest/cat/012/internal/sheet"21)2223// Options say where the server's workbooks are and what it may do24// beyond reading and writing cells.25type Options struct {26 // Version is 012's, which the server reports.27 Version string28 // ReadOnly leaves out every tool that changes a workbook.29 ReadOnly bool30 // Force lets writes change protected ranges, as 012 set --force.31 Force bool32 // Notebooks, when set, lets run_notebook_cell run cells with nu, as33 // --notebooks does; nil leaves the tool out. MayRun says why a34 // workbook's cells may not run here (trust, the shell setting), and35 // may mark it trusted, as the command line's --trust does.36 Notebooks *headless.NotebookOptions37 MayRun func(path string, w *sheet.Workbook) error38 // Roots are the folders workbooks may be in when the client shares39 // none of its own (NewRoots makes them).40 Roots []confine.Root41 // Default, an absolute path, is the workbook of tools called without42 // a path, open wherever it is; "" makes the path required.43 Default string44 // Prepare, when set, runs on every workbook once opened (answering45 // JEV functions); Save writes a changed one, nil being File.Save.46 Prepare func(context.Context, *headless.File) error47 Save func(*headless.File) error48 // Live, when set, is the one workbook of a server attached to a49 // session (live mode, live.go): every tool works on it, and the50 // live tools are added. Roots, Default and Notebooks go unused.51 Live Live52}5354// Server is the MCP server on the workbooks open to it.55type Server struct {56 *sdk.Server57 opts Options58 fallback []confine.Root // Options.Roots5960 mu sync.Mutex61 backends map[string]Backend // by absolute path62 clientRoots map[*sdk.ServerSession][]confine.Root63 resources map[string]bool // the URIs listed now64 recent []*book // the workbooks whose resources are listed, most recent last65}6667// New makes the server with its tools, resources and prompts.68func New(o Options) *Server {69 if o.Default != "" {70 o.Default = realPath(o.Default)71 }72 s := &Server{opts: o, fallback: o.Roots, backends: map[string]Backend{},73 clientRoots: map[*sdk.ServerSession][]confine.Root{}, resources: map[string]bool{}}74 s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)}, s.serverOptions())75 s.addReadTools()76 if !o.ReadOnly {77 s.addWriteTools()78 }79 if o.Live != nil {80 s.addLiveTools(o.Live)81 }82 s.addResources()83 s.addPrompts()84 s.addViews()85 if o.Default != "" || o.Live != nil {86 if b, err := s.book(context.Background(), nil, ""); err == nil {87 s.used(context.Background(), b)88 }89 }90 return s91}9293func orDevel(v string) string {94 if v == "" {95 return "(devel)"96 }97 return v98}99100// instructions tell the model how to use the server.101const instructions = `This server works on 012 spreadsheet workbooks (.012 files) in the folders open to it, and reads the files 012 imports (CSV, XLSX, JSON, SQLite and more).102Every tool takes the workbook's path. Call describe without one to list the workbooks; describe with a path lists its sheets, their used ranges, guessed header rows and column names, tables, named ranges, charts and notebooks. create_workbook makes a new workbook, empty, from a file 012 imports, or holding a table of data.103References 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.104Inputs are what a person types, in en-US form: 1.5, =SUM(A1:A6), $1,200, 12%, 2026-09-29. Formulas are Google Sheets'.105Keep values' meaning: write money, percentages, dates, times, durations and sizes with their types, never as bare numbers, either typed as a person types them ($3.50, 12%) or as values with their types ({"currency": 3.5}, {"percent": 0.12}, {"date": "2026-09-29"}, {"duration": "90min"}, {"size": "1.5kb"}); write rows of records with write_table, which formats each column from its values. Reads return values the same way, with each cell as shown.106Writes are checked as typing is (formulas must parse, validation rules, protected ranges) and each call is one change, saved at once; pass dry_run to see the change without making it. Call evaluate to try a formula without writing it.`107108// tool adds a tool whose handler takes In and returns Out, with the109// schemas inferred as rawSchemas says. Its path is required110// unless the server has a default workbook, or the tool is describe,111// which lists the workbooks without one.112func tool[In, Out any](s *Server, t *sdk.Tool, h func(context.Context, *sdk.CallToolRequest, In) (*sdk.CallToolResult, Out, error)) {113 if t.InputSchema == nil {114 schema, err := jsonschema.For[In](rawSchemas)115 if err != nil {116 panic(err)117 }118 if _, ok := schema.Properties["path"]; ok && s.opts.Default == "" && s.opts.Live == nil && t.Name != "describe" && !slices.Contains(schema.Required, "path") {119 schema.Required = append(schema.Required, "path")120 }121 t.InputSchema = schema122 }123 if t.OutputSchema == nil {124 schema, err := jsonschema.For[Out](rawSchemas)125 if err != nil {126 panic(err)127 }128 t.OutputSchema = schema129 }130 sdk.AddTool(s.Server, t, h)131}