cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / mcp / read.go
read.go 7.2K
1package mcp23import (4 "context"5 "slices"67 "github.com/google/jsonschema-go/jsonschema"89 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1011 "src.rest/cat/012/internal/headless"12 "src.rest/cat/012/internal/sheet"13)1415// The tools that read a workbook, or compute on a copy thrown away.1617type describeIn struct{ bookIn }1819// describeOut is a workbook described, or with no path the workbooks20// open to the server.21type describeOut struct {22 Path string `json:"path,omitempty" jsonschema:"the workbook described"`23 *headless.Description24 Roots []string `json:"roots,omitempty" jsonschema:"without a path: the folders open to the server"`25 Workbooks []listedFile `json:"workbooks,omitempty" jsonschema:"without a path: the workbooks in them"`26 More bool `json:"more,omitempty" jsonschema:"there were more workbooks than listed"`27}2829type readIn struct {30 bookIn31 Ref string `json:"ref" jsonschema:"a cell, range, named range, table or sheet, as formulas write it: Q3!A1:D40, Sales, Sales[Amount], Q3"`32 Text *bool `json:"text,omitempty" jsonschema:"false leaves out each cell as the sheet shows it ($1,200.00, 9/29/2026), which comes back beside the values otherwise"`33 Max int `json:"max_cells,omitempty" jsonschema:"the most cells to return, whole rows at a time; 10000 when 0"`34}3536// RangeRead is headless.Range under another name, which a struct37// embedding it can have beside its own field Range.38type RangeRead = headless.Range3940type evaluateIn struct {41 bookIn42 Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`43 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"`44}4546type findIn struct {47 bookIn48 Query string `json:"query" jsonschema:"what to look for"`49 headless.FindOptions50}5152type errorsIn struct{ bookIn }5354// errorsOut lists the formulas showing errors.55type errorsOut struct {56 Errors []headless.ProblemRecord `json:"errors"`57 Circular bool `json:"circular"`58}5960var readOnly = &sdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: ptr(false)}6162func ptr[T any](v T) *T { return &v }6364func (s *Server) addReadTools() {65 tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly, OutputSchema: describeSchema(),66 Description: "Without a path: the workbooks (.012 files, and files 012 imports) in the folders open to the server. With one: 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."},67 s.describe)68 tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: viewMeta("Reading the range", "Read the range"),69 Description: "The values of a range as rows, typed by their formats (numbers, strings, booleans, null for blank, {\"currency\": 3.5}, {\"percent\": 0.12}, {\"date\": \"2026-09-29\"}, {\"duration\": \"90min\"}, {\"size\": 1500}, errors as #DIV/0!), each cell as the sheet shows it ($3.50, 12%, 9/29/2026), and the formulas in it by cell. Hosts that draw views show it as 012's grid."},70 s.readRange)71 tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,72 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."},73 func(ctx context.Context, req *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {74 var out headless.Evaluated75 err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) (err error) {76 out, err = headless.Evaluate(w, in.Formula, in.At)77 return err78 })79 return nil, out, err80 })81 tool(s, &sdk.Tool{Name: "find", Annotations: readOnly,82 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."},83 func(ctx context.Context, req *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {84 var out headless.Found85 err := s.view(ctx, req, in.Path, func(_ *book, w *sheet.Workbook) (err error) {86 out, err = headless.Find(w, in.Query, in.FindOptions)87 return err88 })89 return nil, out, err90 })91 tool(s, &sdk.Tool{Name: "list_errors", Annotations: readOnly,92 Description: "The formulas showing errors after recalculating everything, with what the screen says about each (as 012 recalc lists them), without saving."},93 func(ctx context.Context, req *sdk.CallToolRequest, in errorsIn) (*sdk.CallToolResult, errorsOut, error) {94 var out errorsOut95 err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) error {96 problems, circular := headless.Recalc(w)97 out = errorsOut{Errors: headless.Problems(problems), Circular: circular}98 return nil99 })100 return nil, out, err101 })102}103104// describeSchema is describeOut's, whose workbook's sheets and names105// are there only with a path.106func describeSchema() *jsonschema.Schema {107 schema, err := jsonschema.For[describeOut](rawSchemas)108 if err != nil {109 panic(err)110 }111 schema.Required = slices.DeleteFunc(schema.Required, func(p string) bool { return p == "sheets" || p == "names" })112 return schema113}114115func (s *Server) describe(ctx context.Context, req *sdk.CallToolRequest, in describeIn) (*sdk.CallToolResult, describeOut, error) {116 if in.Path == "" && s.opts.Default == "" && s.opts.Live == nil {117 roots := s.roots(ctx, req.Session)118 files, more := s.listFiles(roots)119 return nil, describeOut{Roots: rootDirs(roots), Workbooks: files, More: more}, nil120 }121 var out describeOut122 err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {123 d := headless.Describe(w)124 out = describeOut{Path: b.name, Description: &d}125 return nil126 })127 return nil, out, err128}129130func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, RangeRead, error) {131 var out RangeRead132 var view *View133 err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {134 t, err := headless.Resolve(w, in.Ref)135 if err != nil {136 return err137 }138 out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, NoText: in.Text != nil && !*in.Text})139 view = rangeView(b, t, out.Rows)140 return nil141 })142 if err != nil {143 return nil, RangeRead{}, err144 }145 return viewResult(view), out, nil146}147148// view runs fn on the workbook path names, as it is.149func (s *Server) view(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*book, *sheet.Workbook) error) error {150 b, err := s.open(ctx, req, path)151 if err != nil {152 return err153 }154 return b.View(ctx, func(w *sheet.Workbook) error { return fn(b, w) })155}156157// scratch runs fn on a copy of the workbook path names, thrown away.158func (s *Server) scratch(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*sheet.Workbook) error) error {159 b, err := s.open(ctx, req, path)160 if err != nil {161 return err162 }163 return b.Scratch(ctx, fn)164}165166// open is the workbook path names for req's client, its resources167// listed from now on.168func (s *Server) open(ctx context.Context, req *sdk.CallToolRequest, path string) (*book, error) {169 var ss *sdk.ServerSession170 if req != nil {171 ss = req.Session172 }173 b, err := s.book(ctx, ss, path)174 if err != nil {175 return nil, err176 }177 s.used(ctx, b)178 return b, nil179}