cat / 012
clone

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

MCP: one server for every workbook, and views in Apps SDK hosts

Dami committed ca67f3e
Every tool takes the workbook's path, confined to the client's roots or
the folders --root names (the working directory by default); describe
without one lists the workbooks, and create_workbook makes one, empty or
from a file 012 imports. Files 012 imports are read only. A file on the
command line stays as the default. Resources are o12://<path>/....

The view's data is the result's structuredContent.view for every client,
left out of the text models read. Tools name the view both ways
(ui.resourceUri, and openai/outputTemplate with a text/html+skybridge
copy), and the page draws from window.openai when the host sets it and
speaks MCP Apps otherwise, fitting its height to the grid.
commit
ca67f3ed171c76a829e303f6a150aa5442c4bda8
parent
c342b1ac0a63fdd60f8912b1962970256f48dce2
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.7 of 3, Jev's estimate
20 files changed, +1675 -305
Mcmd/012/args.go7+-
Mcmd/012/mcp.go51++++++--
Mcmd/012/mcp_test.go64++++++++--
Minternal/mcp/backend.go47+++++++
Ainternal/mcp/books.go190+++++++++++++++++++++++++++++
Ainternal/mcp/books_test.go177+++++++++++++++++++++++++++
Ainternal/mcp/create.go83+++++++++++++
Ainternal/mcp/list.go99+++++++++++++++
Minternal/mcp/read.go136++++++++++++++++----
Minternal/mcp/resources.go199++++++++++++++++++++----------
Ainternal/mcp/roots.go77++++++++++++
Ainternal/mcp/roots_test.go37++++++
Minternal/mcp/server.go81+++++++++---
Minternal/mcp/server_test.go54+++++---
Ainternal/mcp/testdata/view.mjs158++++++++++++++++++++++++
Minternal/mcp/views.go168++++++++++++++------------
Ainternal/mcp/views_browser_test.go102+++++++++++++++
Minternal/mcp/views_test.go97+++++++++------
Ainternal/mcp/viewscript.go82++++++++++++
Minternal/mcp/write.go71+++++++---
M cmd/012/args.go +5 -2
M cmd/012/mcp.go +40 -11
@@ -6,6 +6,7 @@ import (
66 "io"77 "os"88 "os/signal"9+ "path/filepath"910 "sync"1011 1112 sdk "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -18,49 +19,77 @@ import (
1819 )1920 2021 // 012 mcp: a Model Context Protocol server over standard input and21-// output on one workbook file (see docs/agents/mcp.md).22+// output on the workbooks in the folders open to it (see23+// docs/agents/mcp.md): the client's roots, else --root's, else the24+// working directory. A file named is the default of tools called25+// without a path.2226 23-const mcpUsage = "usage: 012 mcp file.012 [--read-only] [--force] [--notebooks [--trust]] [--jev]"27+const mcpUsage = "usage: 012 mcp [file.012] [--root dir]... [--read-only] [--force] [--notebooks [--trust]] [--jev]"2428 2529 // runMCP is 012 mcp: it serves until the client closes standard input.2630 // Nothing runs a program or reaches the network unless its flag says:2731 // --notebooks offers run_notebook_cell, --jev answers JEV functions.2832 func runMCP(args []string, e env) error {29- a, err := parseArgs(args, nil, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})33+ a, err := parseArgs(args, []string{"root"}, []string{"read-only", "force", "notebooks", "trust", "jev", "help"})3034 switch {3135 case err != nil:3236 return usageError(err.Error(), mcpUsage)3337 case a.has("help"):3438 fmt.Fprintln(e.stdout, mcpUsage)3539 return nil36- case len(a.pos) != 1:40+ case len(a.pos) > 1:3741 return usageError("", mcpUsage)3842 case a.has("trust") && !a.has("notebooks"):3943 return usageError("--trust goes with --notebooks", mcpUsage)4044 }41- path := a.pos[0]42- if _, err := headless.Open(path, true); err != nil {45+ roots, err := mcp.NewRoots(rootDirs(a.all["root"]))46+ if err != nil {4347 return err4448 }49+ o := mcp.Options{Version: buildVersion(), ReadOnly: a.has("read-only"), Force: a.has("force"), Roots: roots}50+ if len(a.pos) == 1 {51+ if o.Default, err = filepath.Abs(a.pos[0]); err != nil {52+ return err53+ }54+ if _, err := headless.Open(o.Default, true); err != nil {55+ return err56+ }57+ }4558 cfg, err := loadConfig(e, nil)4659 if err != nil {4760 return err4861 }4962 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)5063 defer stop()51- b := &mcp.FileBackend{Path: path, Save: func(f *headless.File) error { return save(f, e) }}64+ o.Save = func(f *headless.File) error { return save(f, e) }5265 if a.has("jev") {53- b.Prepare = jevPreparer(cfg, e)66+ o.Prepare = jevPreparer(cfg, e)5467 }55- o := mcp.Options{Version: buildVersion(), ReadOnly: a.has("read-only"), Force: a.has("force")}5668 if a.has("notebooks") && !a.has("read-only") {5769 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 {70+ o.MayRun = func(path string, w *sheet.Workbook) error {5971 return mayRun(&headless.File{Path: path, Book: w}, cfg, a.has("trust"))6072 }6173 }6274 t := &sdk.IOTransport{Reader: io.NopCloser(e.stdin), Writer: nopWriteCloser{e.stdout}}63- return mcp.New(b, o).Run(ctx, t)75+ return mcp.New(o).Run(ctx, t)76+}77+78+// rootDirs are the folders --root gave, else the working directory,79+// else, when that's the file system's root (where some hosts start80+// servers), the home folder.81+func rootDirs(given []string) []string {82+ if len(given) > 0 {83+ return given84+ }85+ wd, err := os.Getwd()86+ if err != nil || filepath.Dir(wd) != wd {87+ return []string{"."}88+ }89+ if home, err := os.UserHomeDir(); err == nil {90+ return []string{home}91+ }92+ return []string{"."}6493 }6594 6695 // jevPreparer answers a workbook's JEV functions each time it's opened,
M cmd/012/mcp_test.go +51 -13
@@ -2,35 +2,47 @@ package main
22 33 import (44 "context"5+ "encoding/json"56 "io"67 "path/filepath"78 "slices"9+ "strings"810 "testing"911 1012 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1113 )1214 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")15+// serveMCP runs 012 mcp with args on pipes standing for standard input16+// and output, as a host runs it, and connects a client; stop ends it17+// and returns what 012 mcp did.18+func serveMCP(t *testing.T, e env, args ...string) (cs *sdk.ClientSession, stop func() error) {19+ t.Helper()1920 inR, inW := io.Pipe()2021 outR, outW := io.Pipe()2122 e.stdin, e.stdout = inR, outW2223 done := make(chan error, 1)2324 go func() {24- done <- run([]string{"mcp", path, "--read-only"}, e)25+ done <- run(append([]string{"mcp"}, args...), e)2526 outW.Close()2627 }()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)28+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(context.Background(), &sdk.IOTransport{Reader: outR, Writer: inW}, nil)2929 if err != nil {3030 t.Fatal(err)3131 }32+ return cs, func() error {33+ cs.Close()34+ inW.Close()35+ return <-done36+ }37+}38+39+func TestMCPOverStdio(t *testing.T) {40+ e, _, _ := scriptEnv(t)41+ path := filepath.Join(t.TempDir(), "book.012")42+ status(e, "set", path, "A1", "1")43+ cs, stop := serveMCP(t, e, path, "--read-only")3244 var names []string33- for tl, err := range cs.Tools(ctx, nil) {45+ for tl, err := range cs.Tools(context.Background(), nil) {3446 if err != nil {3547 t.Fatal(err)3648 }
@@ -39,12 +51,38 @@ func TestMCPOverStdio(t *testing.T) {
3951 if !slices.Contains(names, "read_range") || slices.Contains(names, "write_cells") {4052 t.Errorf("tools of a read-only server: %v", names)4153 }42- cs.Close()43- inW.Close()44- if err := <-done; err != nil {54+ if err := stop(); err != nil {4555 t.Errorf("012 mcp: %v", err)4656 }4757 if code, _ := status(e, "mcp", path, "--trust"); code != 2 {4858 t.Errorf("--trust without --notebooks: status %d", code)4959 }5060 }61+62+// TestMCPRoots serves the workbooks of the folders --root gives, with63+// no file named.64+func TestMCPRoots(t *testing.T) {65+ e, _, _ := scriptEnv(t)66+ dir, other := t.TempDir(), t.TempDir()67+ status(e, "set", filepath.Join(dir, "a.012"), "A1", "1")68+ status(e, "set", filepath.Join(other, "b.012"), "A1", "2")69+ cs, stop := serveMCP(t, e, "--root", dir, "--root", other)70+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: "describe", Arguments: map[string]any{}})71+ if err != nil {72+ t.Fatal(err)73+ }74+ var d struct {75+ Workbooks []struct{ Path string }76+ }77+ data, _ := json.Marshal(res.StructuredContent)78+ json.Unmarshal(data, &d)79+ if len(d.Workbooks) != 2 || d.Workbooks[0].Path != "a.012" || !strings.HasSuffix(d.Workbooks[1].Path, "b.012") {80+ t.Errorf("describe: %s", data)81+ }82+ if err := stop(); err != nil {83+ t.Errorf("012 mcp: %v", err)84+ }85+ if _, err := status(e, "mcp", "--root", filepath.Join(dir, "missing")); err == nil || !strings.Contains(err.Error(), "--root") {86+ t.Errorf("a missing root: %v", err)87+ }88+}
M internal/mcp/backend.go +47 -0
@@ -2,11 +2,13 @@ package mcp
22 33 import (44 "context"5+ "fmt"56 "path/filepath"67 "strings"78 "sync"89 910 "github.com/FelineStateMachine/012/internal/diff"11+ "github.com/FelineStateMachine/012/internal/fileio"1012 "github.com/FelineStateMachine/012/internal/headless"1113 "github.com/FelineStateMachine/012/internal/sheet"1214 )
@@ -98,3 +100,48 @@ func (b *FileBackend) Change(ctx context.Context, label string, dryRun bool, fn
98100 }99101 return changes, f.Save()100102 }103+104+// ImportBackend is a file in a format 012 imports (CSV, XLSX, SQLite105+// and the rest), read afresh for every call as the app opens it. It is106+// read only: 012 writes a workbook as a .012 file, which create_workbook107+// makes from it.108+type ImportBackend struct {109+ Path string110+ Prepare func(context.Context, *headless.File) error111+}112+113+// Name is the file's name without its folder or extension.114+func (b *ImportBackend) Name() string {115+ return strings.TrimSuffix(filepath.Base(b.Path), filepath.Ext(b.Path))116+}117+118+func (b *ImportBackend) View(ctx context.Context, fn func(*sheet.Workbook) error) error {119+ w, err := importBook(ctx, b.Path)120+ if err != nil {121+ return err122+ }123+ if b.Prepare != nil {124+ if err := b.Prepare(ctx, &headless.File{Path: b.Path, Book: w}); err != nil {125+ return err126+ }127+ }128+ return fn(w)129+}130+131+// Scratch is View: every call imports its own copy.132+func (b *ImportBackend) Scratch(ctx context.Context, fn func(*sheet.Workbook) error) error {133+ return b.View(ctx, fn)134+}135+136+func (b *ImportBackend) Change(context.Context, string, bool, func(*sheet.Workbook) error) ([]diff.Change, error) {137+ return nil, fmt.Errorf("%s is read only: 012 saves workbooks as %s files; create_workbook with from set to it makes one to change", filepath.Base(b.Path), sheet.FileExt)138+}139+140+// importBook reads a file 012 imports as a workbook.141+func importBook(ctx context.Context, path string) (*sheet.Workbook, error) {142+ res, err := fileio.Import(ctx, path, fileio.Options{})143+ if err != nil {144+ return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)145+ }146+ return res.Sheet.Book(), nil147+}
A internal/mcp/books.go +190 -0
@@ -0,0 +1,190 @@
1+package mcp2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "io/fs"8+ "net/url"9+ "os"10+ "path/filepath"11+ "strings"12+13+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"14+15+ "github.com/FelineStateMachine/012/internal/confine"16+ "github.com/FelineStateMachine/012/internal/fileio"17+ "github.com/FelineStateMachine/012/internal/sheet"18+)19+20+// Workbooks are named by path in every tool. A path is confined to the21+// folders the client shares as its roots (the MCP roots capability) or,22+// when it shares none, to Options.Roots, each a confine.Root: a23+// relative path is looked for in each folder in turn, an absolute one24+// must lie inside one, and hidden files and links leading out are25+// refused as confine refuses them. Options.Default, the file named on26+// the command line, is open to tools without a path wherever it is.27+28+// bookIn is the argument naming a tool's workbook.29+type bookIn struct {30+ Path string `json:"path,omitempty" jsonschema:"the workbook: a .012 file, or a file 012 imports (CSV, XLSX, JSON, SQLite...) to read it; relative to the folder open to the server, or absolute inside it"`31+}32+33+// book is a workbook a tool works on: its backend and the path that34+// names it to the client.35+type book struct {36+ Backend37+ path string // absolute, as resolved38+ name string // as tools and resources name it: relative to its root, or absolute39+}40+41+// NewRoots confines paths to dirs, the working directory when there42+// are none.43+func NewRoots(dirs []string) ([]confine.Root, error) {44+ if len(dirs) == 0 {45+ dirs = []string{"."}46+ }47+ out := make([]confine.Root, 0, len(dirs))48+ for _, d := range dirs {49+ r, err := confine.New(d)50+ if err != nil {51+ return nil, fmt.Errorf("--root %s: %w", d, err)52+ }53+ out = append(out, r)54+ }55+ return out, nil56+}57+58+// resolve is the file on disk a tool's path names, within roots.59+func resolve(roots []confine.Root, name string) (string, error) {60+ if u, err := url.Parse(name); err == nil && u.Scheme == "file" {61+ name = filepath.FromSlash(u.Path)62+ }63+ if len(roots) == 0 {64+ return "", errors.New("no folder is open to this server")65+ }66+ if !filepath.IsAbs(name) {67+ found := ""68+ for _, r := range roots {69+ p, err := r.Resolve(name)70+ if err != nil {71+ return "", outside(roots, name, err)72+ }73+ if _, err := os.Stat(p); err == nil {74+ return p, nil75+ }76+ if found == "" {77+ found = p78+ }79+ }80+ return found, nil81+ }82+ real := realPath(name)83+ for _, r := range roots {84+ if rel, err := filepath.Rel(r.Dir(), real); err == nil && filepath.IsLocal(rel) {85+ p, err := r.Resolve(rel)86+ if err != nil {87+ return "", outside(roots, name, err)88+ }89+ return p, nil90+ }91+ }92+ return "", outside(roots, name, confine.ErrOutside)93+}94+95+// outside is why name was refused, err, naming the folders open.96+func outside(roots []confine.Root, name string, err error) error {97+ return fmt.Errorf("%s: %w; the folders open to this server are %s, without their hidden files", name, err, strings.Join(rootDirs(roots), ", "))98+}99+100+// realPath is p with the symbolic links along the part of it that101+// exists followed, so it compares with a root's resolved folder.102+func realPath(p string) string {103+ p = filepath.Clean(p)104+ var rest []string105+ for cur := p; ; cur = filepath.Dir(cur) {106+ if real, err := filepath.EvalSymlinks(cur); err == nil {107+ return filepath.Join(append([]string{real}, rest...)...)108+ }109+ if filepath.Dir(cur) == cur {110+ return p111+ }112+ rest = append([]string{filepath.Base(cur)}, rest...)113+ }114+}115+116+// displayName is how tools and resources name the file at path: relative117+// to the first root when it's in it, else absolute.118+func displayName(roots []confine.Root, path string) string {119+ if len(roots) > 0 {120+ if rel, err := filepath.Rel(roots[0].Dir(), path); err == nil && filepath.IsLocal(rel) {121+ return filepath.ToSlash(rel)122+ }123+ }124+ return path125+}126+127+// isWorkbook reports whether a file's name is a .012 workbook's, and128+// readable whether it's one 012 at least imports.129+func isWorkbook(name string) (own, readable bool) {130+ if strings.EqualFold(filepath.Ext(name), sheet.FileExt) {131+ return true, true132+ }133+ k, ok := fileio.KindOf(name)134+ return false, ok && k.CanImport()135+}136+137+// book is the workbook a tool's path names for the client of req, or138+// the default one when the path is empty.139+func (s *Server) book(ctx context.Context, ss *sdk.ServerSession, name string) (*book, error) {140+ roots := s.roots(ctx, ss)141+ var path string142+ switch {143+ case name == "" && s.opts.Default == "":144+ return nil, errors.New("name the workbook with path; describe without one lists the workbooks open to this server")145+ case name == "" || s.opts.Default != "" && filepath.IsAbs(name) && realPath(name) == s.opts.Default:146+ path = s.opts.Default147+ default:148+ p, err := resolve(roots, name)149+ if err != nil {150+ return nil, err151+ }152+ path = p153+ }154+ if path != s.opts.Default {155+ if _, ok := isWorkbook(path); !ok {156+ return nil, fmt.Errorf("%s isn't a workbook: a %s file, or one 012 imports", name, sheet.FileExt)157+ }158+ if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {159+ return nil, fmt.Errorf("%s doesn't exist: create_workbook makes a workbook, and describe without a path lists them", name)160+ }161+ }162+ return &book{Backend: s.backend(path), path: path, name: displayName(roots, path)}, nil163+}164+165+// backend is the one backend of the file at path, so calls on the same166+// file take turns.167+func (s *Server) backend(path string) Backend {168+ s.mu.Lock()169+ defer s.mu.Unlock()170+ if b, ok := s.backends[path]; ok {171+ return b172+ }173+ var b Backend174+ if own, _ := isWorkbook(path); own || path == s.opts.Default {175+ b = &FileBackend{Path: path, Prepare: s.opts.Prepare, Save: s.opts.Save}176+ } else {177+ b = &ImportBackend{Path: path, Prepare: s.opts.Prepare}178+ }179+ s.backends[path] = b180+ return b181+}182+183+// rootDirs are the roots' folders, for describe.184+func rootDirs(roots []confine.Root) []string {185+ dirs := make([]string, len(roots))186+ for i, r := range roots {187+ dirs[i] = r.Dir()188+ }189+ return dirs190+}
A internal/mcp/books_test.go +177 -0
@@ -0,0 +1,177 @@
1+package mcp2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "slices"9+ "strings"10+ "testing"11+12+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"13+14+ "github.com/FelineStateMachine/012/internal/confine"15+ "github.com/FelineStateMachine/012/internal/headless"16+)17+18+// folder makes a folder of workbooks: book.012 and sub/data.csv, a19+// hidden .secret.012, a note that isn't a workbook, and a link leading20+// out to another folder's workbook.21+func folder(t *testing.T) (dir, outsideBook string) {22+ t.Helper()23+ dir = t.TempDir()24+ out := t.TempDir()25+ outsideBook = filepath.Join(out, "other.012")26+ for _, p := range []string{"book.012", ".secret.012", outsideBook} {27+ if !filepath.IsAbs(p) {28+ p = filepath.Join(dir, p)29+ }30+ data, _ := os.ReadFile(bookFile(t))31+ if err := os.WriteFile(p, data, 0o644); err != nil {32+ t.Fatal(err)33+ }34+ }35+ if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {36+ t.Fatal(err)37+ }38+ files := map[string]string{"sub/data.csv": "Name,Score\nAda,3\nBo,5\n", "notes.txt": "hello"}39+ for name, text := range files {40+ if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o644); err != nil {41+ t.Fatal(err)42+ }43+ }44+ if err := os.Symlink(outsideBook, filepath.Join(dir, "link.012")); err != nil {45+ t.Fatal(err)46+ }47+ return dir, outsideBook48+}49+50+func rootsFor(t *testing.T, dirs ...string) []confine.Root {51+ t.Helper()52+ roots, err := NewRoots(dirs)53+ if err != nil {54+ t.Fatal(err)55+ }56+ return roots57+}58+59+func TestResolve(t *testing.T) {60+ dir, outsideBook := folder(t)61+ roots := rootsFor(t, dir)62+ real := roots[0].Dir()63+ for name, want := range map[string]string{64+ "book.012": real + "/book.012",65+ "sub/data.csv": real + "/sub/data.csv",66+ filepath.Join(dir, "book.012"): real + "/book.012",67+ "file://" + filepath.Join(dir, "x.012"): real + "/x.012",68+ "new.012": real + "/new.012",69+ } {70+ if got, err := resolve(roots, name); err != nil || got != filepath.FromSlash(want) {71+ t.Errorf("%s: %s, %v; want %s", name, got, err, want)72+ }73+ }74+ for _, name := range []string{"../x.012", outsideBook, ".secret.012", "link.012", "/etc/passwd", filepath.Join(dir, "..", "x.012")} {75+ if got, err := resolve(roots, name); !errors.Is(err, confine.ErrOutside) {76+ t.Errorf("%s: %s, %v; want it refused", name, got, err)77+ }78+ }79+ // A relative path is looked for in each root in turn.80+ other := filepath.Dir(outsideBook)81+ if got, err := resolve(rootsFor(t, dir, other), "other.012"); err != nil || filepath.Base(got) != "other.012" {82+ t.Errorf("second root: %s, %v", got, err)83+ }84+}85+86+func TestDescribeListsWorkbooks(t *testing.T) {87+ dir, _ := folder(t)88+ cs := connect(t, Options{Roots: rootsFor(t, dir)})89+ var d describeOut90+ call(t, cs, "describe", map[string]any{}, &d)91+ var paths []string92+ for _, f := range d.Workbooks {93+ paths = append(paths, f.Path+" "+f.Format)94+ }95+ slices.Sort(paths)96+ if !slices.Equal(paths, []string{"book.012 012", "sub/data.csv CSV"}) || len(d.Roots) != 1 {97+ t.Errorf("describe without a path: %v in %v", paths, d.Roots)98+ }99+ call(t, cs, "describe", map[string]any{"path": "sub/data.csv"}, &d)100+ if d.Path != "sub/data.csv" || d.Description == nil || d.Sheets[0].Name != "data" || d.Sheets[0].Columns[1] != "Score" {101+ t.Errorf("describe a CSV: %+v", d)102+ }103+ for tl, err := range cs.Tools(context.Background(), nil) {104+ if err != nil {105+ t.Fatal(err)106+ }107+ list, _ := tl.InputSchema.(map[string]any)["required"].([]any)108+ required := slices.Contains(list, any("path"))109+ if tl.Name != "describe" && !required {110+ t.Errorf("%s doesn't require path", tl.Name)111+ }112+ }113+ if res := call(t, cs, "read_range", map[string]any{"path": "link.012", "ref": "A1"}, nil); !strings.Contains(errorText(res), "outside") {114+ t.Errorf("a link leading out: %q", errorText(res))115+ }116+ if res := call(t, cs, "read_range", map[string]any{"path": "notes.txt", "ref": "A1"}, nil); !strings.Contains(errorText(res), "isn't a workbook") {117+ t.Errorf("a text file: %q", errorText(res))118+ }119+ var r RangeRead120+ call(t, cs, "read_range", map[string]any{"path": filepath.Join(dir, "book.012"), "ref": "B2"}, &r)121+ if string(r.Values[0][0]) != "3" {122+ t.Errorf("absolute path: %+v", r)123+ }124+}125+126+func TestCreateWorkbook(t *testing.T) {127+ dir, _ := folder(t)128+ cs := connect(t, Options{Roots: rootsFor(t, dir)})129+ var d describeOut130+ call(t, cs, "create_workbook", map[string]any{"path": "empty.012"}, &d)131+ if d.Path != "empty.012" || len(d.Sheets) != 1 {132+ t.Errorf("empty: %+v", d)133+ }134+ if _, err := headless.Open(filepath.Join(dir, "empty.012"), false); err != nil {135+ t.Errorf("not saved: %v", err)136+ }137+ call(t, cs, "create_workbook", map[string]any{"path": "sub/scores.012", "from": "sub/data.csv"}, &d)138+ if d.Sheets[0].Used != "A1:B3" {139+ t.Errorf("from a CSV: %+v", d.Sheets)140+ }141+ for args, want := range map[[2]string]string{142+ {"book.012", ""}: "exists",143+ {"x.csv", ""}: "ends in .012",144+ {"../x.012", ""}: "outside",145+ {"y.012", "notes.txt"}: "isn't a file 012 imports",146+ } {147+ res := call(t, cs, "create_workbook", map[string]any{"path": args[0], "from": args[1]}, nil)148+ if !strings.Contains(errorText(res), want) {149+ t.Errorf("%v: %q, want %s", args, errorText(res), want)150+ }151+ }152+ res := call(t, cs, "write_cells", map[string]any{"path": "sub/data.csv", "entries": []map[string]string{{"ref": "A1", "input": "x"}}}, nil)153+ if !strings.Contains(errorText(res), "read only") {154+ t.Errorf("writing a CSV: %q", errorText(res))155+ }156+ var ch Changed157+ call(t, cs, "write_cells", map[string]any{"path": "sub/scores.012", "entries": []map[string]string{{"ref": "C1", "input": "Rank"}}}, &ch)158+ if !ch.Saved {159+ t.Errorf("writing the new workbook: %+v", ch)160+ }161+}162+163+func TestPathRequiredWithoutDefault(t *testing.T) {164+ dir, _ := folder(t)165+ cs := connect(t, Options{Roots: rootsFor(t, dir)})166+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: "read_range", Arguments: map[string]any{"ref": "A1"}})167+ if err == nil && !res.IsError {168+ t.Error("read_range without a path")169+ }170+ // With a default, it's optional and names the default.171+ cs = connect(t, Options{Default: filepath.Join(dir, "book.012")})172+ var r RangeRead173+ call(t, cs, "read_range", map[string]any{"ref": "B2"}, &r)174+ if string(r.Values[0][0]) != "3" {175+ t.Errorf("the default: %+v", r)176+ }177+}
A internal/mcp/create.go +83 -0
@@ -0,0 +1,83 @@
1+package mcp2+3+import (4+ "context"5+ "fmt"6+ "os"7+8+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"9+10+ "github.com/FelineStateMachine/012/internal/confine"11+ "github.com/FelineStateMachine/012/internal/headless"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// create_workbook makes a new .012 file in a folder open to the server:16+// empty, or from a file 012 imports (which the other tools only read),17+// or a copy of another workbook. It never replaces a file.18+19+type createIn struct {20+ Path string `json:"path" jsonschema:"the new workbook's file, ending in .012; relative to the folder open to the server, or absolute inside it"`21+ From string `json:"from,omitempty" jsonschema:"a file to make it from: one 012 imports (CSV, XLSX, JSON, SQLite...) or another workbook; empty for an empty workbook"`22+}23+24+func (s *Server) addCreateTool() {25+ tool(s, &sdk.Tool{Name: "create_workbook", Annotations: writes,26+ Description: "Make a new .012 workbook, empty or from a file 012 imports (CSV, XLSX, JSON, SQLite...), which can then be changed; it describes the workbook made. It won't replace a file that exists."},27+ s.createWorkbook)28+}29+30+func (s *Server) createWorkbook(ctx context.Context, req *sdk.CallToolRequest, in createIn) (*sdk.CallToolResult, describeOut, error) {31+ roots := s.roots(ctx, req.Session)32+ path, err := resolve(roots, in.Path)33+ if err != nil {34+ return nil, describeOut{}, err35+ }36+ if own, _ := isWorkbook(path); !own {37+ return nil, describeOut{}, fmt.Errorf("%s: a workbook's file name ends in %s", in.Path, sheet.FileExt)38+ }39+ if _, err := os.Lstat(path); err == nil {40+ return nil, describeOut{}, fmt.Errorf("%s exists: describe it, or name a new file", in.Path)41+ }42+ f, err := headless.Open(path, true)43+ if err != nil {44+ return nil, describeOut{}, err45+ }46+ if in.From != "" {47+ if f.Book, err = s.source(ctx, roots, in.From); err != nil {48+ return nil, describeOut{}, err49+ }50+ }51+ save := f.Save52+ if s.opts.Save != nil {53+ save = func() error { return s.opts.Save(f) }54+ }55+ if err := save(); err != nil {56+ return nil, describeOut{}, err57+ }58+ b := &book{Backend: s.backend(path), path: path, name: displayName(roots, path)}59+ s.used(ctx, b)60+ d := headless.Describe(f.Book)61+ return nil, describeOut{Path: b.name, Description: &d}, nil62+}63+64+// source is the workbook a new one is made from: a file imported, or65+// another workbook's contents.66+func (s *Server) source(ctx context.Context, roots []confine.Root, name string) (*sheet.Workbook, error) {67+ path, err := resolve(roots, name)68+ if err != nil {69+ return nil, err70+ }71+ own, readable := isWorkbook(path)72+ switch {73+ case own:74+ f, err := headless.Open(path, false)75+ if err != nil {76+ return nil, err77+ }78+ return f.Book, nil79+ case readable:80+ return importBook(ctx, path)81+ }82+ return nil, fmt.Errorf("%s isn't a file 012 imports", name)83+}
A internal/mcp/list.go +99 -0
@@ -0,0 +1,99 @@
1+package mcp2+3+import (4+ "io/fs"5+ "os"6+ "path/filepath"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/confine"10+ "github.com/FelineStateMachine/012/internal/fileio"11+)12+13+// describe without a path lists the workbooks open to the server: the14+// default one, then the .012 files and files 012 imports in each root,15+// not looking in hidden folders or node_modules, nor deeper than16+// listDepth, and stopping at maxListedFiles files or maxListedDirs17+// folders looked in, so a root as wide as a home folder answers18+// quickly.19+const (20+ maxListedFiles = 50021+ maxListedDirs = 200022+ listDepth = 423+)24+25+// listedFile is a workbook describe lists.26+type listedFile struct {27+ Path string `json:"path" jsonschema:"what to pass as path"`28+ Format string `json:"format" jsonschema:"012, or the format it's imported from (read only)"`29+ Size int64 `json:"size"`30+ Modified string `json:"modified" jsonschema:"when it last changed, RFC 3339"`31+}32+33+// lister gathers the files of a listing.34+type lister struct {35+ roots []confine.Root36+ skip string // the default workbook, listed first37+ files []listedFile38+ dirs int39+ stopped bool40+}41+42+// listFiles lists the workbooks in roots, and the default one; more is43+// set when there were more than it lists.44+func (s *Server) listFiles(roots []confine.Root) (files []listedFile, more bool) {45+ l := &lister{roots: roots, skip: s.opts.Default}46+ if d := s.opts.Default; d != "" {47+ if info, err := os.Stat(d); err == nil {48+ l.add(d, info)49+ }50+ }51+ for _, r := range roots {52+ if l.stopped {53+ break54+ }55+ _ = filepath.WalkDir(r.Dir(), func(p string, e fs.DirEntry, err error) error { return l.visit(r, p, e, err) })56+ }57+ return l.files, l.stopped58+}59+60+func (l *lister) add(path string, info fs.FileInfo) {61+ format := "012"62+ if own, _ := isWorkbook(path); !own {63+ k, _ := fileio.KindOf(path)64+ format = k.String()65+ }66+ l.files = append(l.files, listedFile{Path: displayName(l.roots, path), Format: format, Size: info.Size(),67+ Modified: info.ModTime().UTC().Format("2006-01-02T15:04:05Z")})68+}69+70+// visit is the walk's step: a folder to look in or not, or a file to71+// list or not.72+func (l *lister) visit(r confine.Root, p string, e fs.DirEntry, err error) error {73+ if err != nil || p == r.Dir() {74+ return nil75+ }76+ if e.IsDir() {77+ rel, _ := filepath.Rel(r.Dir(), p)78+ l.dirs++79+ switch {80+ case l.dirs > maxListedDirs:81+ l.stopped = true82+ return filepath.SkipAll83+ case strings.HasPrefix(e.Name(), ".") || e.Name() == "node_modules" || strings.Count(rel, string(filepath.Separator)) >= listDepth-1:84+ return filepath.SkipDir85+ }86+ return nil87+ }88+ if _, ok := isWorkbook(p); !ok || !e.Type().IsRegular() || strings.HasPrefix(e.Name(), ".") || p == l.skip {89+ return nil90+ }91+ if len(l.files) == maxListedFiles {92+ l.stopped = true93+ return filepath.SkipAll94+ }95+ if info, err := e.Info(); err == nil {96+ l.add(p, info)97+ }98+ return nil99+}
M internal/mcp/read.go +109 -27
@@ -2,6 +2,9 @@ package mcp
22 33 import (44 "context"5+ "slices"6+7+ "github.com/google/jsonschema-go/jsonschema"58 69 sdk "github.com/modelcontextprotocol/go-sdk/mcp"710
@@ -9,27 +12,51 @@ import (
912 "github.com/FelineStateMachine/012/internal/sheet"1013 )1114 12-// The tools that read the workbook, or compute on a copy thrown away.15+// The tools that read a workbook, or compute on a copy thrown away.16+17+type describeIn struct{ bookIn }1318 14-type describeIn struct{}19+// describeOut is a workbook described, or with no path the workbooks20+// open to the server.21+type 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+}1528 1629 type readIn struct {30+ bookIn1731 Ref string `json:"ref" jsonschema:"a cell, range, named range, table or sheet, as formulas write it: Q3!A1:D40, Sales, Sales[Amount], Q3"`1832 Text bool `json:"text,omitempty" jsonschema:"also return each cell as the sheet shows it ($1,200.00, 9/29/2026)"`1933 Max int `json:"max_cells,omitempty" jsonschema:"the most cells to return, whole rows at a time; 10000 when 0"`2034 }2135 36+// RangeRead is headless.Range under another name, which a struct37+// embedding it can have beside its own field Range.38+type RangeRead = headless.Range39+40+// rangeOut is what read_range returns: the range, and the view hosts41+// draw of it.42+type rangeOut struct {43+ RangeRead44+ View *View `json:"view,omitempty"`45+}46+2247 type evaluateIn struct {48+ bookIn2349 Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`2450 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"`2551 }2652 2753 type findIn struct {54+ bookIn2855 Query string `json:"query" jsonschema:"what to look for"`2956 headless.FindOptions3057 }3158 32-type errorsIn struct{}59+type errorsIn struct{ bookIn }3360 3461 // errorsOut lists the formulas showing errors.3562 type errorsOut struct {
@@ -42,22 +69,17 @@ var readOnly = &sdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, Op
4269 func ptr[T any](v T) *T { return &v }4370 4471 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, Meta: s.viewMeta(),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."},72+ tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly, OutputSchema: describeSchema(),73+ 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."},74+ s.describe)75+ tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: viewMeta("Reading the range", "Read the range"),76+ 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. Hosts that draw views show it as 012's grid."},5477 s.readRange)5578 tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,5679 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) {80+ func(ctx context.Context, req *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {5881 var out headless.Evaluated59- err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {60- var err error82+ err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) (err error) {6183 out, err = headless.Evaluate(w, in.Formula, in.At)6284 return err6385 })
@@ -65,10 +87,9 @@ func (s *Server) addReadTools() {
6587 })6688 tool(s, &sdk.Tool{Name: "find", Annotations: readOnly,6789 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) {90+ func(ctx context.Context, req *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {6991 var out headless.Found70- err := s.b.View(ctx, func(w *sheet.Workbook) error {71- var err error92+ err := s.view(ctx, req, in.Path, func(_ *book, w *sheet.Workbook) (err error) {7293 out, err = headless.Find(w, in.Query, in.FindOptions)7394 return err7495 })
@@ -76,9 +97,9 @@ func (s *Server) addReadTools() {
7697 })7798 tool(s, &sdk.Tool{Name: "list_errors", Annotations: readOnly,7899 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) {100+ func(ctx context.Context, req *sdk.CallToolRequest, in errorsIn) (*sdk.CallToolResult, errorsOut, error) {80101 var out errorsOut81- err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {102+ err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) error {82103 problems, circular := headless.Recalc(w)83104 out = errorsOut{Errors: headless.Problems(problems), Circular: circular}84105 return nil
@@ -87,17 +108,78 @@ func (s *Server) addReadTools() {
87108 })88109 }89110 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 {111+// describeSchema is describeOut's, whose workbook's sheets and names112+// are there only with a path.113+func describeSchema() *jsonschema.Schema {114+ schema, err := jsonschema.For[describeOut](rawSchemas)115+ if err != nil {116+ panic(err)117+ }118+ schema.Required = slices.DeleteFunc(schema.Required, func(p string) bool { return p == "sheets" || p == "names" })119+ return schema120+}121+122+func (s *Server) describe(ctx context.Context, req *sdk.CallToolRequest, in describeIn) (*sdk.CallToolResult, describeOut, error) {123+ if in.Path == "" && s.opts.Default == "" {124+ roots := s.roots(ctx, req.Session)125+ files, more := s.listFiles(roots)126+ return nil, describeOut{Roots: rootDirs(roots), Workbooks: files, More: more}, nil127+ }128+ var out describeOut129+ err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {130+ d := headless.Describe(w)131+ out = describeOut{Path: b.name, Description: &d}132+ return nil133+ })134+ return nil, out, err135+}136+137+func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, rangeOut, error) {138+ var out rangeOut139+ err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {94140 t, err := headless.Resolve(w, in.Ref)95141 if err != nil {96142 return err97143 }98- out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})99- res = s.rangeView(req, t)144+ out.RangeRead = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})145+ out.View = rangeView(b, t, out.Rows)100146 return nil101147 })102- return res, out, err148+ if err != nil {149+ return nil, rangeOut{}, err150+ }151+ return forModel(out.RangeRead), out, nil152+}153+154+// view runs fn on the workbook path names, as it is.155+func (s *Server) view(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*book, *sheet.Workbook) error) error {156+ b, err := s.open(ctx, req, path)157+ if err != nil {158+ return err159+ }160+ return b.View(ctx, func(w *sheet.Workbook) error { return fn(b, w) })161+}162+163+// scratch runs fn on a copy of the workbook path names, thrown away.164+func (s *Server) scratch(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*sheet.Workbook) error) error {165+ b, err := s.open(ctx, req, path)166+ if err != nil {167+ return err168+ }169+ return b.Scratch(ctx, fn)170+}171+172+// open is the workbook path names for req's client, its resources173+// listed from now on.174+func (s *Server) open(ctx context.Context, req *sdk.CallToolRequest, path string) (*book, error) {175+ var ss *sdk.ServerSession176+ if req != nil {177+ ss = req.Session178+ }179+ b, err := s.book(ctx, ss, path)180+ if err != nil {181+ return nil, err182+ }183+ s.used(ctx, b)184+ return b, nil103185 }
M internal/mcp/resources.go +132 -67
@@ -14,41 +14,72 @@ import (
1414 "github.com/FelineStateMachine/012/internal/sheet"1515 )1616 17-// Resources are the workbook's data by address, for hosts that let18-// people attach them to a conversation:17+// Resources are workbooks' data by address, for hosts that let people18+// attach them to a conversation:1919 //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 output20+// o12://budget.012/Sheet1!A1:D40 a range, as read_range returns it (JSON)21+// o12://budget.012/table/Sales a table, header row included (JSON)22+// o12://budget.012/notebook/Notes/2 a notebook cell: its source and output2323 //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.24+// The workbook is named as tools name it, relative to the folder open25+// to the server (or absolute, o12:///Users/me/budget.012/...), each26+// part of it and the sheet's name percent-encoded. The scheme is o1227+// because a URI's scheme starts with a letter. Each sheet's used range,28+// table and code cell of the workbooks used lately is listed; the29+// templates reach any range of any workbook open to the server.2830 const (29- scheme = "o12://book/"30- tablePrefix = scheme + "table/"31- notebookPrefix = scheme + "notebook/"32- maxListed = 20031+ scheme = "o12://"32+ maxListed = 20033+ maxRecent = 5 // workbooks whose resources are listed34+ tableSeg = "table"35+ notebookSeg = "notebook"3336 )3437 3538 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+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/{sheet}!{+range}", Name: "range", MIMEType: "application/json",40+ Description: "A range of a sheet as rows of values, as read_range returns it; the workbook's path and the sheet's name percent-encoded"}, s.readResource)41+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/table/{name}", Name: "table", MIMEType: "application/json",3942 Description: "A named table, header row included"}, s.readResource)40- s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: notebookPrefix + "{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",43+ s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/notebook/{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",4144 Description: "A notebook cell's source, and its output as NUON or why it failed"}, s.readResource)4245 }4346 44-// refreshResources lists the workbook's resources as it stands,45-// telling clients when the list changes.47+// used notes that a tool used b, listing its resources from now on.48+func (s *Server) used(ctx context.Context, b *book) {49+ s.mu.Lock()50+ known := false51+ for i, r := range s.recent {52+ if r.path == b.path {53+ s.recent = append(s.recent[:i], s.recent[i+1:]...)54+ known = true55+ break56+ }57+ }58+ s.recent = append(s.recent, b)59+ if len(s.recent) > maxRecent {60+ s.recent = s.recent[len(s.recent)-maxRecent:]61+ }62+ s.mu.Unlock()63+ if !known {64+ s.refreshResources(ctx)65+ }66+}67+68+// refreshResources lists the resources of the workbooks used lately as69+// they stand, telling clients when the list changes.4670 func (s *Server) refreshResources(ctx context.Context) {71+ s.mu.Lock()72+ recent := append([]*book(nil), s.recent...)73+ s.mu.Unlock()4774 var want []sdk.Resource48- _ = s.b.View(ctx, func(w *sheet.Workbook) error {49- want = listResources(w)50- return nil51- })75+ for i := len(recent) - 1; i >= 0; i-- {76+ b := recent[i]77+ _ = b.View(ctx, func(w *sheet.Workbook) error {78+ want = append(want, listResources(w, b.name)...)79+ return nil80+ })81+ }82+ want = want[:min(len(want), maxListed)]5283 s.mu.Lock()5384 defer s.mu.Unlock()5485 keep := map[string]bool{}
@@ -71,46 +102,62 @@ func (s *Server) refreshResources(ctx context.Context) {
71102 s.resources = keep72103 }73104 74-func listResources(w *sheet.Workbook) []sdk.Resource {105+// listResources are a workbook's resources: each sheet's used range,106+// table and notebook code cell.107+func listResources(w *sheet.Workbook, name string) []sdk.Resource {75108 var out []sdk.Resource76- add := func(r sdk.Resource) {77- if len(out) < maxListed {78- out = append(out, r)79- }80- }109+ base := bookURI(name)81110 for _, sh := range w.Sheets() {82111 if sh.IsNotebook() {83112 for i, c := range sh.NotebookCells() {84113 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)})114+ out = append(out, sdk.Resource{URI: fmt.Sprintf("%s/%s/%s/%d", base, notebookSeg, escape(sh.Name()), i+1),115+ Name: fmt.Sprintf("%s %s cell %d", name, sh.Name(), i+1), MIMEType: "text/plain", Description: firstLine(c.Source)})87116 }88117 }89118 continue90119 }91120 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()})121+ out = append(out, sdk.Resource{URI: rangeURI(name, sh.Name(), used), Name: name + " " + sh.Name(), Title: sh.Name() + " " + used.String(),122+ MIMEType: "application/json", Description: "The used range of " + sh.Name() + " in " + name})94123 }95124 for _, t := range sh.Tables() {96- add(sdk.Resource{URI: tablePrefix + escape(t.Name), Name: "table " + t.Name, MIMEType: "application/json",125+ out = append(out, sdk.Resource{URI: base + "/" + tableSeg + "/" + escape(t.Name), Name: name + " table " + t.Name, MIMEType: "application/json",97126 Description: "Table " + t.Name + " on " + sh.Name() + ", " + t.Range.String() + ": " + strings.Join(t.Cols, ", ")})98127 }99128 }100129 return out101130 }102131 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()132+// bookURI is where a workbook's resources start: the scheme and its133+// path, each part percent-encoded.134+func bookURI(name string) string {135+ parts := strings.Split(name, "/")136+ for i, p := range parts {137+ parts[i] = escape(p)138+ }139+ return scheme + strings.Join(parts, "/")140+}141+142+// rangeURI is the resource of a range of a sheet of a workbook.143+func rangeURI(book, sheetName string, r sheet.Rect) string {144+ return bookURI(book) + "/" + escape(sheetName) + "!" + r.String()106145 }107146 108147 func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {109148 uri := req.Params.URI149+ name, rest, err := splitURI(uri)150+ if err != nil {151+ return nil, err152+ }153+ b, err := s.book(ctx, req.Session, name)154+ if err != nil {155+ return nil, err156+ }110157 var text, mime string111- err := s.b.View(ctx, func(w *sheet.Workbook) error {158+ err = b.View(ctx, func(w *sheet.Workbook) error {112159 var err error113- text, mime, err = resourceText(w, uri)160+ text, mime, err = resourceText(w, rest)114161 return err115162 })116163 if err != nil {
@@ -119,30 +166,52 @@ func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest)
119166 return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: uri, MIMEType: mime, Text: text}}}, nil120167 }121168 122-// resourceText is what a resource's URI names, and its type.123-func resourceText(w *sheet.Workbook, uri string) (string, string, error) {169+// splitURI is the workbook a resource's URI names, and the rest of it170+// (what's in the workbook) as its last parts: Sheet1!A1:D40,171+// table/Sales or notebook/Notes/2.172+func splitURI(uri string) (book string, rest []string, err error) {173+ parts := strings.Split(strings.TrimPrefix(uri, scheme), "/")174+ n := len(parts)124175 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)176+ case !strings.HasPrefix(uri, scheme) || n < 2:177+ return "", nil, sdk.ResourceNotFoundError(uri)178+ case strings.Contains(parts[n-1], "!"):179+ rest = parts[n-1:]180+ case n >= 4 && parts[n-3] == notebookSeg:181+ rest = parts[n-3:]182+ case n >= 3 && parts[n-2] == tableSeg:183+ rest = parts[n-2:]184+ default:185+ return "", nil, sdk.ResourceNotFoundError(uri)186+ }187+ path := parts[:n-len(rest)]188+ for i, p := range path {189+ if path[i], err = unescape(p); err != nil {190+ return "", nil, err138191 }139- name, err := unescape(rest[:i])192+ }193+ return strings.Join(path, "/"), rest, nil194+}195+196+// resourceText is what the rest of a resource's URI names in a197+// workbook, and its type.198+func resourceText(w *sheet.Workbook, rest []string) (string, string, error) {199+ switch {200+ case rest[0] == notebookSeg:201+ return notebookCellText(w, rest[1], rest[2])202+ case rest[0] == tableSeg:203+ name, err := unescape(rest[1])140204 if err != nil {141205 return "", "", err142206 }143- return rangeJSON(w, sheet.QuoteSheet(name)+"!"+rest[i+1:])207+ return rangeJSON(w, name)144208 }145- return "", "", sdk.ResourceNotFoundError(uri)209+ sh, rng, _ := strings.Cut(rest[0], "!")210+ name, err := unescape(sh)211+ if err != nil {212+ return "", "", err213+ }214+ return rangeJSON(w, sheet.QuoteSheet(name)+"!"+rng)146215 }147216 148217 func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {
@@ -155,20 +224,16 @@ func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {
155224 }156225 157226 // 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])227+// failed.228+func notebookCellText(w *sheet.Workbook, sheetPart, cell string) (string, string, error) {229+ name, err := unescape(sheetPart)165230 if err != nil {166231 return "", "", err167232 }168- n, err := strconv.Atoi(path[i+1:])233+ n, err := strconv.Atoi(cell)169234 s := w.Lookup(name)170235 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)236+ return "", "", fmt.Errorf("no cell %s of a notebook %q", cell, name)172237 }173238 c := s.NotebookCells()[n-1]174239 var b strings.Builder
@@ -191,7 +256,7 @@ func firstLine(s string) string {
191256 }192257 193258 // escape percent-encodes all but URI's unreserved characters, so a194-// sheet's name fits a template's simple variable.259+// sheet's name or a part of a path fits a template's simple variable.195260 func escape(s string) string {196261 var b strings.Builder197262 for _, c := range []byte(s) {
A internal/mcp/roots.go +77 -0
@@ -0,0 +1,77 @@
1+// The roots capability is deprecated (SEP-2577), but every protocol2+// version keeps it for a year after 2026-07-28 and hosts in use share3+// their project folders through it (Claude Code among them), so the4+// server follows a client's roots where the client offers them, and5+// Options.Roots otherwise. Clients of 2026-07-28 and later can't be6+// asked in the middle of a call, so they get Options.Roots.7+//8+//lint:file-ignore SA1019 roots are deprecated, and followed for the hosts that still share them9+10+package mcp11+12+import (13+ "context"14+ "net/url"15+ "path/filepath"16+17+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"18+19+ "github.com/FelineStateMachine/012/internal/confine"20+)21+22+// roots are the folders workbooks may be in for the client of ss: its23+// roots when it shares any, else the server's.24+func (s *Server) roots(ctx context.Context, ss *sdk.ServerSession) []confine.Root {25+ if ss == nil {26+ return s.fallback27+ }28+ s.mu.Lock()29+ cached, ok := s.clientRoots[ss]30+ s.mu.Unlock()31+ if ok {32+ return cached33+ }34+ var roots []confine.Root35+ if p := ss.InitializeParams(); p != nil && p.Capabilities != nil && p.Capabilities.RootsV2 != nil {36+ if res, err := ss.ListRoots(ctx, nil); err == nil {37+ roots = rootsOf(res.Roots)38+ }39+ }40+ if len(roots) == 0 {41+ roots = s.fallback42+ }43+ s.mu.Lock()44+ s.clientRoots[ss] = roots45+ s.mu.Unlock()46+ return roots47+}48+49+// forgetRoots drops the roots kept for a client, which said they50+// changed.51+func (s *Server) forgetRoots(_ context.Context, req *sdk.RootsListChangedRequest) {52+ s.mu.Lock()53+ delete(s.clientRoots, req.Session)54+ s.mu.Unlock()55+}56+57+// serverOptions are the SDK's options for the server: its instructions,58+// its MCP Apps extension, and forgetRoots.59+func (s *Server) serverOptions() *sdk.ServerOptions {60+ return &sdk.ServerOptions{Instructions: instructions, RootsListChangedHandler: s.forgetRoots,61+ Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}}62+}63+64+// rootsOf are the folders of a client's file:// roots that exist.65+func rootsOf(list []*sdk.Root) []confine.Root {66+ var out []confine.Root67+ for _, r := range list {68+ u, err := url.Parse(r.URI)69+ if err != nil || u.Scheme != "file" {70+ continue71+ }72+ if root, err := confine.New(filepath.FromSlash(u.Path)); err == nil {73+ out = append(out, root)74+ }75+ }76+ return out77+}
A internal/mcp/roots_test.go +37 -0
@@ -0,0 +1,37 @@
1+//lint:file-ignore SA1019 roots are deprecated, and followed for the hosts that still share them2+3+package mcp4+5+import (6+ "path/filepath"7+ "strings"8+ "testing"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+)12+13+// TestClientRoots checks the client's roots win over the server's, and14+// follow the client's changes.15+func TestClientRoots(t *testing.T) {16+ dir, outsideBook := folder(t)17+ c := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil)18+ c.AddRoots(&sdk.Root{URI: "file://" + filepath.ToSlash(filepath.Dir(outsideBook))})19+ // Servers ask for roots only of clients of protocols up to 2025-11-25.20+ cs := connectClient(t, Options{Roots: rootsFor(t, dir)}, c, &sdk.ClientSessionOptions{ProtocolVersion: "2025-11-25"})21+ if res := call(t, cs, "describe", map[string]any{"path": filepath.Join(dir, "book.012")}, nil); !strings.Contains(errorText(res), "outside") {22+ t.Errorf("the server's root with the client's given: %q", errorText(res))23+ }24+ var d describeOut25+ call(t, cs, "describe", map[string]any{"path": "other.012"}, &d)26+ if d.Path != "other.012" {27+ t.Errorf("the client's root: %+v", d)28+ }29+ c.RemoveRoots("file://" + filepath.ToSlash(filepath.Dir(outsideBook)))30+ c.AddRoots(&sdk.Root{URI: "file://" + filepath.ToSlash(dir)})31+ for range 100 {32+ if res := call(t, cs, "describe", map[string]any{"path": "book.012"}, &d); !res.IsError {33+ return34+ }35+ }36+ t.Error("the client's new roots weren't followed")37+}
M internal/mcp/server.go +58 -23
@@ -1,30 +1,33 @@
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.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.88 package mcp99 1010 import (1111 "context"1212 "encoding/json"1313 "reflect"14+ "slices"1415 "sync"1516 1617 "github.com/google/jsonschema-go/jsonschema"1718 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1819 20+ "github.com/FelineStateMachine/012/internal/confine"1921 "github.com/FelineStateMachine/012/internal/headless"2022 "github.com/FelineStateMachine/012/internal/sheet"2123 )2224 23-// Options say what the server may do beyond reading and writing cells.25+// Options say where the server's workbooks are and what it may do26+// beyond reading and writing cells.2427 type Options struct {2528 // Version is 012's, which the server reports.2629 Version string27- // ReadOnly leaves out every tool that changes the workbook.30+ // ReadOnly leaves out every tool that changes a workbook.2831 ReadOnly bool2932 // Force lets writes change protected ranges, as 012 set --force.3033 Force bool
@@ -33,24 +36,40 @@ type Options struct {
3336 // workbook's cells may not run here (trust, the shell setting), and3437 // may mark it trusted, as the command line's --trust does.3538 Notebooks *headless.NotebookOptions36- MayRun func(*sheet.Workbook) error39+ MayRun func(path string, w *sheet.Workbook) error40+ // Roots are the folders workbooks may be in when the client shares41+ // none of its own (NewRoots makes them).42+ Roots []confine.Root43+ // Default, an absolute path, is the workbook of tools called without44+ // a path, open wherever it is; "" makes the path required.45+ Default string46+ // Prepare, when set, runs on every workbook once opened (answering47+ // JEV functions); Save writes a changed one, nil being File.Save.48+ Prepare func(context.Context, *headless.File) error49+ Save func(*headless.File) error3750 }3851 39-// Server is the MCP server on a backend.52+// Server is the MCP server on the workbooks open to it.4053 type Server struct {4154 *sdk.Server42- b Backend43- opts Options55+ opts Options56+ fallback []confine.Root // Options.Roots4457 45- mu sync.Mutex46- resources map[string]bool // the URIs listed now58+ mu sync.Mutex59+ backends map[string]Backend // by absolute path60+ clientRoots map[*sdk.ServerSession][]confine.Root61+ resources map[string]bool // the URIs listed now62+ recent []*book // the workbooks whose resources are listed, most recent last4763 }4864 4965 // 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{Extensions: map[string]any{uiExtension: map[string]any{}}}})66+func New(o Options) *Server {67+ if o.Default != "" {68+ o.Default = realPath(o.Default)69+ }70+ s := &Server{opts: o, fallback: o.Roots, backends: map[string]Backend{},71+ clientRoots: map[*sdk.ServerSession][]confine.Root{}, resources: map[string]bool{}}72+ s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)}, s.serverOptions())5473 s.addReadTools()5574 if !o.ReadOnly {5675 s.addWriteTools()
@@ -58,7 +77,11 @@ func New(b Backend, o Options) *Server {
5877 s.addResources()5978 s.addPrompts()6079 s.addViews()61- s.refreshResources(context.Background())80+ if o.Default != "" {81+ if b, err := s.book(context.Background(), nil, ""); err == nil {82+ s.used(context.Background(), b)83+ }84+ }6285 return s6386 }6487
@@ -70,8 +93,8 @@ func orDevel(v string) string {
7093 }7194 7295 // 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.96+const 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).97+Every 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 or from a file 012 imports.7598 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.7699 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'.77100 Writes 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.`
@@ -83,8 +106,20 @@ var rawSchemas = &jsonschema.ForOptions{TypeSchemas: map[reflect.Type]*jsonschem
83106 }}84107 85108 // tool adds a tool whose handler takes In and returns Out, with the86-// output schema inferred as rawSchemas says.109+// output schema 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.87112 func 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](nil)115+ if err != nil {116+ panic(err)117+ }118+ if _, ok := schema.Properties["path"]; ok && s.opts.Default == "" && t.Name != "describe" && !slices.Contains(schema.Required, "path") {119+ schema.Required = append(schema.Required, "path")120+ }121+ t.InputSchema = schema122+ }88123 if t.OutputSchema == nil {89124 schema, err := jsonschema.For[Out](rawSchemas)90125 if err != nil {
M internal/mcp/server_test.go +33 -21
@@ -50,19 +50,31 @@ func writeBook(t *testing.T, w *sheet.Workbook, path string) {
5050 }5151 }5252 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 {53+// connect serves o to a client over the SDK's in-memory transport. A54+// server with a default workbook and no roots is open to its folder.55+func connect(t *testing.T, o Options) *sdk.ClientSession {5656 t.Helper()57+ return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil), nil)58+}59+60+// connectClient serves o to client c, connecting with co.61+func connectClient(t *testing.T, o Options, c *sdk.Client, co *sdk.ClientSessionOptions) *sdk.ClientSession {62+ t.Helper()63+ if o.Roots == nil && o.Default != "" {64+ roots, err := NewRoots([]string{filepath.Dir(o.Default)})65+ if err != nil {66+ t.Fatal(err)67+ }68+ o.Roots = roots69+ }5770 ctx := context.Background()58- srv := New(b, o)5971 st, ct := sdk.NewInMemoryTransports()60- ss, err := srv.Connect(ctx, st, nil)72+ ss, err := New(o).Connect(ctx, st, nil)6173 if err != nil {6274 t.Fatal(err)6375 }6476 t.Cleanup(func() { ss.Close() })65- cs, err := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "1"}, nil).Connect(ctx, ct, nil)77+ cs, err := c.Connect(ctx, ct, co)6678 if err != nil {6779 t.Fatal(err)6880 }
@@ -95,7 +107,7 @@ func errorText(res *sdk.CallToolResult) string {
95107 }96108 97109 func TestTools(t *testing.T) {98- cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})110+ cs := connect(t, Options{Default: bookFile(t)})99111 var names []string100112 for tl, err := range cs.Tools(context.Background(), nil) {101113 if err != nil {
@@ -112,7 +124,7 @@ func TestTools(t *testing.T) {
112124 if slices.Contains(names, "run_notebook_cell") {113125 t.Error("run_notebook_cell offered without Notebooks")114126 }115- ro := connect(t, &FileBackend{Path: bookFile(t)}, Options{ReadOnly: true})127+ ro := connect(t, Options{Default: bookFile(t), ReadOnly: true})116128 for tl := range ro.Tools(context.Background(), nil) {117129 if !tl.Annotations.ReadOnlyHint {118130 t.Errorf("read-only server offers %s", tl.Name)
@@ -121,7 +133,7 @@ func TestTools(t *testing.T) {
121133 }122134 123135 func TestDescribeAndRead(t *testing.T) {124- cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})136+ cs := connect(t, Options{Default: bookFile(t)})125137 var d headless.Description126138 call(t, cs, "describe", map[string]any{}, &d)127139 if len(d.Sheets) != 2 || d.Sheets[0].Header != 1 || !slices.Equal(d.Sheets[0].Columns, []string{"Region", "Units", "Price"}) {
@@ -150,7 +162,7 @@ func TestDescribeAndRead(t *testing.T) {
150162 func TestEvaluateLeavesTheFile(t *testing.T) {151163 path := bookFile(t)152164 before, _ := os.ReadFile(path)153- cs := connect(t, &FileBackend{Path: path}, Options{})165+ cs := connect(t, Options{Default: path})154166 var ev headless.Evaluated155167 call(t, cs, "evaluate", map[string]any{"formula": `SUMIF(A2:A4, "North", B2:B4)`}, &ev)156168 if string(ev.Value) != "10" || ev.Cell != "Sheet1!A6" {
@@ -179,7 +191,7 @@ func TestEvaluateLeavesTheFile(t *testing.T) {
179191 180192 func TestWrites(t *testing.T) {181193 path := bookFile(t)182- cs := connect(t, &FileBackend{Path: path}, Options{})194+ cs := connect(t, Options{Default: path})183195 before, _ := os.ReadFile(path)184196 var ch Changed185197 call(t, cs, "write_cells", map[string]any{"entries": []map[string]string{{"ref": "D1", "input": "Total"}, {"ref": "D2", "input": "=B2*C2"}},
@@ -224,7 +236,7 @@ func TestWrites(t *testing.T) {
224236 225237 func TestChartAndPivot(t *testing.T) {226238 path := bookFile(t)227- cs := connect(t, &FileBackend{Path: path}, Options{})239+ cs := connect(t, Options{Default: path})228240 var c chartOut229241 call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "type": "bar", "title": "Units"}, &c)230242 if c.Chart.Number != 1 || c.Chart.Sheet != "Sheet1" || !c.Saved {
@@ -247,7 +259,7 @@ func TestChartAndPivot(t *testing.T) {
247259 }248260 249261 func TestResourcesAndPrompts(t *testing.T) {250- cs := connect(t, &FileBackend{Path: bookFile(t)}, Options{})262+ cs := connect(t, Options{Default: bookFile(t)})251263 ctx := context.Background()252264 var uris []string253265 for r, err := range cs.Resources(ctx, nil) {
@@ -256,14 +268,14 @@ func TestResourcesAndPrompts(t *testing.T) {
256268 }257269 uris = append(uris, r.URI)258270 }259- if !slices.Contains(uris, "o12://book/Sheet1!A1:C4") || !slices.Contains(uris, "o12://book/Q3%20plan!A1") {271+ if !slices.Contains(uris, "o12://book.012/Sheet1!A1:C4") || !slices.Contains(uris, "o12://book.012/Q3%20plan!A1") {260272 t.Errorf("resources: %v", uris)261273 }262274 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",275+ "o12://book.012/Sheet1!A1:C4": `"range":"Sheet1!A1:C4"`,276+ "o12://book.012/Q3%20plan!A1": `"formulas":{"A1":"=Sheet1!B2*2"}`,277+ "o12://book.012/Sheet1!B2:B3": `"values":[[3],[5]]`,278+ "o12://book.012/table/Missing": "isn't a cell",267279 } {268280 res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})269281 got := ""
@@ -314,8 +326,8 @@ func TestRunNotebookCell(t *testing.T) {
314326 writeBook(t, w, path)315327 nu := &fakeNu{}316328 refuse := true317- cs := connect(t, &FileBackend{Path: path}, Options{Notebooks: &headless.NotebookOptions{Runner: nu},318- MayRun: func(*sheet.Workbook) error {329+ cs := connect(t, Options{Default: path, Notebooks: &headless.NotebookOptions{Runner: nu},330+ MayRun: func(string, *sheet.Workbook) error {319331 if refuse {320332 return errNotTrusted321333 }
@@ -330,7 +342,7 @@ func TestRunNotebookCell(t *testing.T) {
330342 if run.State != "ran" || run.Name != "files" || !strings.Contains(run.Output, "a.txt") {331343 t.Errorf("run: %+v", run)332344 }333- res, err := cs.ReadResource(context.Background(), &sdk.ReadResourceParams{URI: "o12://book/notebook/Notes/2"})345+ res, err := cs.ReadResource(context.Background(), &sdk.ReadResourceParams{URI: "o12://nb.012/notebook/Notes/2"})334346 if err != nil || !strings.Contains(res.Contents[0].Text, "a.txt") {335347 t.Errorf("cell resource after the run: %v %+v", err, res)336348 }
A internal/mcp/testdata/view.mjs +158 -0
@@ -0,0 +1,158 @@
1+// Draws the MCP view in Chromium the two ways hosts show it, and checks2+// what a person would see (see views_browser_test.go):3+//4+// - openai: the OpenAI Apps SDK's way. window.openai is set before the5+// page's scripts run, its toolOutput null at first and then set by an6+// openai:set_globals event, as ChatGPT does when the result arrives;7+// notifyIntrinsicHeight resizes the frame.8+// - mcp-apps: the MCP Apps way. The host answers ui/initialize with its9+// context, sends ui/notifications/tool-input and tool-result once the10+// view says it's initialized, and sizes the frame as11+// ui/notifications/size-changed says.12+//13+// Usage: node view.mjs cases.json [screenshot folder]. It exits 0 when14+// every check passes, 3 when there's no browser to run, 1 otherwise.15+import { readFileSync } from "node:fs";16+import { createRequire } from "node:module";17+import { join } from "node:path";18+19+const require = createRequire(import.meta.url);20+const { chromium } = require("playwright");21+const [, , casesFile, shots] = process.argv;22+const cases = JSON.parse(readFileSync(casesFile, "utf8"));23+const failures = [];24+const check = (ok, what) => { if (!ok) failures.push(what); };25+26+let browser;27+try {28+ browser = await chromium.launch();29+} catch (e) {30+ console.error(String(e).split("\n")[0]);31+ process.exit(3);32+}33+34+// js is v as a script's literal, safe inside a script element.35+const js = (v) => JSON.stringify(v).replace(/</g, "\\u003c");36+37+// openaiDoc is the view's page with window.openai set first.38+function openaiDoc(page, args, theme) {39+ const boot = `<script>40+window.openai={theme:${js(theme)},displayMode:"inline",maxHeight:520,locale:"en-US",41+ toolInput:${js(args)},toolOutput:null,toolResponseMetadata:null,widgetState:null,42+ setWidgetState:function(){return Promise.resolve()},callTool:function(){return Promise.reject(new Error("no tools here"))},43+ notifyIntrinsicHeight:function(h){parent.postMessage({harness:"height",height:h},"*")}};44+</script>`;45+ return page.replace("<head>", "<head>" + boot);46+}47+48+// hostPage is the page around the frame: it relays the frame's size and,49+// as an MCP Apps host, speaks the extension's messages.50+function hostPage(mode, doc, c, theme) {51+ const hc = { theme, displayMode: "inline", containerDimensions: { maxHeight: 520 }, locale: "en-US", platform: "web" };52+ return `<!doctype html><html><body style="margin:24px;background:${theme === "dark" ? "#111" : "#eee"}">53+<iframe id="f" sandbox="allow-scripts" style="width:720px;height:150px;border:1px solid #888;display:block"></iframe>54+<script>55+var f=document.getElementById("f"),seen=[];window.seen=seen;56+window.addEventListener("message",function(e){57+ if(e.source!==f.contentWindow)return;var m=e.data;seen.push(m);58+ if(m.harness==="height"){f.style.height=m.height+"px";return}59+ if(${js(mode)}!=="mcp-apps"||!m||m.jsonrpc!=="2.0")return;60+ function post(x){f.contentWindow.postMessage(Object.assign({jsonrpc:"2.0"},x),"*")}61+ if(m.method==="ui/initialize")post({id:m.id,result:{protocolVersion:"2026-01-26",hostInfo:{name:"harness",version:"1"},hostCapabilities:{},hostContext:${js(hc)}}});62+ if(m.method==="ui/notifications/initialized"){post({method:"ui/notifications/tool-input",params:{arguments:${js(c.args)}}});63+ post({method:"ui/notifications/tool-result",params:${js(c.result)}})}64+ if(m.method==="ui/notifications/size-changed")f.style.height=m.params.height+"px";65+});66+f.srcdoc=${js(doc)};67+</script></body></html>`;68+}69+70+async function run(mode, name, c, theme) {71+ const page = await browser.newPage({ viewport: { width: 800, height: 700 } });72+ const errors = [];73+ page.on("pageerror", (e) => errors.push(String(e)));74+ const doc = mode === "openai" ? openaiDoc(cases.page, c.args, theme) : cases.page;75+ await page.setContent(hostPage(mode, doc, c, theme));76+ const frame = page.frameLocator("#f");77+ if (mode === "openai") {78+ await frame.locator("#o12-view").waitFor({ state: "attached" });79+ await page.waitForTimeout(100);80+ await frame.locator("body").evaluate((_, out) => {81+ window.openai.toolOutput = out;82+ window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals: { toolOutput: out } } }));83+ }, c.result.structuredContent);84+ }85+ const what = `${mode} ${name}`;86+ try {87+ await frame.locator("#o12-view > *").first().waitFor({ timeout: 5000 });88+ } catch {89+ failures.push(`${what}: nothing drawn`);90+ await page.close();91+ return;92+ }93+ await page.waitForTimeout(300);94+ const v = c.result.structuredContent.view;95+ const state = () => frame.locator("body").evaluate(() => {96+ const sh = document.querySelector("#o12-view .sheet");97+ return {98+ book: document.querySelector(".panel .book").textContent,99+ name: document.getElementById("o12-name").textContent,100+ input: document.getElementById("o12-input").textContent,101+ where: document.getElementById("o12-context").textContent,102+ theme: document.documentElement.className,103+ height: Math.ceil(document.documentElement.getBoundingClientRect().height),104+ scroll: sh ? { client: sh.clientHeight, full: sh.scrollHeight } : null,105+ };106+ });107+ let s = await state();108+ const frameHeight = await page.locator("#f").evaluate((f) => f.getBoundingClientRect().height);109+ check(s.book === v.title && s.where === v.where, `${what}: panel says ${s.book} / ${s.where}`);110+ check(s.theme === theme, `${what}: theme ${s.theme}, want ${theme}`);111+ check(Math.abs(frameHeight - s.height - 2) <= 2, `${what}: frame ${frameHeight}px for ${s.height}px of view`);112+ check(s.height <= 520, `${what}: ${s.height}px, more than the host's room`);113+ check(errors.length === 0, `${what}: script errors ${errors}`);114+ if (c.grid) {115+ check(s.name === "A1" && s.input === c.grid.a1, `${what}: pointer on ${s.name} showing ${s.input}`);116+ await frame.locator(`td[data-a="${c.grid.cell}"]`).click();117+ s = await state();118+ check(s.name === c.grid.cell && s.input === c.grid.input, `${what}: clicking ${c.grid.cell} shows ${s.name} ${s.input}`);119+ await page.keyboard.press("ArrowDown");120+ s = await state();121+ check(s.name === c.grid.below, `${what}: the arrow key moved to ${s.name}`);122+ check(s.scroll && s.scroll.full > s.scroll.client, `${what}: the grid doesn't scroll (${JSON.stringify(s.scroll)})`);123+ const header = await frame.locator("#o12-view .sheet").evaluate((sh) => {124+ sh.scrollTop = 400;125+ const th = sh.querySelector("thead th"), top = sh.getBoundingClientRect().top;126+ return { scrolled: sh.scrollTop, headerAt: Math.round(th.getBoundingClientRect().top - top) };127+ });128+ check(header.scrolled > 0 && header.headerAt === 0, `${what}: scrolled ${header.scrolled}, column header ${header.headerAt}px down`);129+ await frame.locator("#o12-view .sheet").evaluate((sh) => { sh.scrollTop = 0; });130+ const style = await frame.locator(`td[data-a="${c.grid.cell}"]`).evaluate((td) => getComputedStyle(td).textAlign);131+ check(style === "right", `${what}: a number is aligned ${style}`);132+ } else {133+ check(await frame.locator("#o12-view svg").count() === 1, `${what}: no chart drawn`);134+ check(s.name === v.name, `${what}: name box ${s.name}`);135+ }136+ if (mode === "mcp-apps") {137+ const seen = await page.evaluate(() => window.seen.map((m) => m.method || (m.id ? "response" : m.harness)));138+ check(seen[0] === "ui/initialize" && seen.includes("ui/notifications/initialized") && seen.includes("ui/notifications/size-changed"),139+ `${what}: messages ${seen}`);140+ }141+ if (shots) {142+ await page.screenshot({ path: join(shots, `${mode}-${name}-${theme}.png`) });143+ }144+ await page.close();145+}146+147+for (const mode of ["openai", "mcp-apps"]) {148+ for (const [name, c] of Object.entries(cases.cases)) {149+ for (const theme of ["light", "dark"]) {150+ await run(mode, name, c, theme);151+ }152+ }153+}154+await browser.close();155+if (failures.length) {156+ console.error(failures.join("\n"));157+ process.exit(1);158+}
M internal/mcp/views.go +90 -78
@@ -2,6 +2,7 @@ package mcp
22 33 import (44 "context"5+ "encoding/json"56 67 sdk "github.com/modelcontextprotocol/go-sdk/mcp"78
@@ -10,110 +11,121 @@ import (
1011 "github.com/FelineStateMachine/012/internal/sheet"1112 )1213 13-// Views are the MCP Apps extension (io.modelcontextprotocol/ui, spec14-// 2026-01-26): read_range and create_chart name one UI resource,15-// ui://012/view, which hosts that support the extension draw in a16-// sandboxed frame beside the result. The resource is the HTML export's17-// page with nothing in it yet (its fonts, palettes and pointer script);18-// each result carries the fragment to show in its _meta, under viewKey,19-// only for clients that declared the extension. The page asks the host20-// for its theme, shows the fragment when the result arrives, and says21-// how tall it is.14+// Views are the range read_range read and the chart create_chart made,15+// drawn as 012 draws them in a sandboxed frame beside the result, by16+// hosts that implement either of the two ways of doing that:17+//18+// - MCP Apps (the io.modelcontextprotocol/ui extension, spec19+// 2026-01-26): the tool's _meta.ui.resourceUri names ui://012/view,20+// text/html;profile=mcp-app, and the page speaks the extension's21+// messages over postMessage (ui/initialize, the tool's result, the22+// host's theme and room, the page's size).23+// - The OpenAI Apps SDK (ChatGPT, Codex): the tool's24+// _meta["openai/outputTemplate"] names ui://012/view.skybridge, the25+// same page as text/html+skybridge, which reads window.openai26+// (toolOutput, theme, maxHeight, displayMode) instead.27+//28+// The page is the HTML export's with nothing in it yet (fonts,29+// palettes, the pointer's script). What it draws is the result's30+// structuredContent.view, for every client: its title, where it is and31+// the grid's or chart's HTML. The result's text, which models read in32+// hosts that keep structuredContent for the view, leaves the view out.2233 const (23- uiExtension = "io.modelcontextprotocol/ui"24- uiMIME = "text/html;profile=mcp-app"25- viewURI = "ui://012/view"26- viewKey = "o12/view"34+ uiExtension = "io.modelcontextprotocol/ui"35+ uiMIME = "text/html;profile=mcp-app"36+ skybridgeMIME = "text/html+skybridge"37+ viewURI = "ui://012/view"38+ skybridgeURI = "ui://012/view.skybridge"2739 // maxViewRows bounds the rows a range's view draws.28- maxViewRows = 50040+ maxViewRows = 2002941 )3042 31-func (s *Server) addViews() {32- meta := sdk.Meta{"ui": map[string]any{"prefersBorder": true, "csp": map[string]any{}}}33- s.AddResource(&sdk.Resource{URI: viewURI, Name: "012 view", Title: s.b.Name() + " in 012", MIMEType: uiMIME, Meta: meta,34- Description: "The range or chart a tool returned, drawn as 012 draws it"},35- func(context.Context, *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {36- page := fileio.HTMLDocument(s.b.Name(), fileio.HTMLPanel(s.b.Name(), "", "")+`<div id="o12-view"></div>`, viewScript)37- return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: viewURI, MIMEType: uiMIME, Text: page, Meta: meta}}}, nil38- })43+// View is what a view draws of a result.44+type View struct {45+ Title string `json:"title" jsonschema:"the workbook's name"`46+ Name string `json:"name,omitempty" jsonschema:"what the name box shows before a cell is pointed at: a chart's data"`47+ Where string `json:"where" jsonschema:"the range, or the chart's kind, sheet and data"`48+ HTML string `json:"html" jsonschema:"the grid or chart as 012's HTML export draws it, for the view"`3949 }4050 41-// viewMeta is what a tool drawn in the view says of it: the resource,42-// under the key the spec names and the one its first drafts named.43-func (s *Server) viewMeta() sdk.Meta {44- return sdk.Meta{"ui": map[string]any{"resourceUri": viewURI}, "ui/resourceUri": viewURI}51+// viewResourceMeta says how hosts should frame the view, in both52+// dialects: a border, and no network (an empty content security53+// policy's lists).54+var viewResourceMeta = sdk.Meta{55+ "ui": map[string]any{"prefersBorder": true, "csp": map[string]any{}},56+ "openai/widgetPrefersBorder": true,57+ "openai/widgetCSP": map[string]any{"connect_domains": []string{}, "resource_domains": []string{}},58+ "openai/widgetDescription": "The range or chart the tool returned, drawn as 012's grid: the model need not repeat it.",4559 }4660 47-// wantsViews reports whether the client declared the extension.48-func wantsViews(req *sdk.CallToolRequest) bool {49- if req == nil || req.Session == nil {50- return false51- }52- p := req.Session.InitializeParams()53- if p == nil || p.Capabilities == nil {54- return false61+func (s *Server) addViews() {62+ for _, r := range []struct{ uri, mime string }{{viewURI, uiMIME}, {skybridgeURI, skybridgeMIME}} {63+ s.AddResource(&sdk.Resource{URI: r.uri, Name: "012 view", Title: "012", MIMEType: r.mime, Meta: viewResourceMeta,64+ Description: "The range or chart a tool returned, drawn as 012 draws it"},65+ func(context.Context, *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {66+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: r.uri, MIMEType: r.mime, Text: viewPage(), Meta: viewResourceMeta}}}, nil67+ })5568 }56- _, ok := p.Capabilities.Extensions[uiExtension]57- return ok5869 }5970 60-// viewResult is a result carrying a fragment of the view.61-func viewResult(title, where, html string) *sdk.CallToolResult {62- return &sdk.CallToolResult{Meta: sdk.Meta{viewKey: map[string]any{"title": title, "where": where, "html": html}}}71+// viewPage is the view's page: the panel, an empty place for the view72+// and the scripts.73+func viewPage() string {74+ return fileio.HTMLDocument("012", "<style>"+viewCSS+"</style>"+fileio.HTMLPanel("012", "", "")+`<div id="o12-view"></div>`, viewScript)75+}76+77+// viewMeta is what a tool drawn in the view says of it: the resource78+// under MCP Apps' key and its first drafts' flat one, and the Apps79+// SDK's template and the status lines it shows while the tool runs.80+// The view calls no tools, so openai/widgetAccessible stays false.81+func viewMeta(invoking, invoked string) sdk.Meta {82+ return sdk.Meta{"ui": map[string]any{"resourceUri": viewURI}, "ui/resourceUri": viewURI,83+ "openai/outputTemplate": skybridgeURI, "openai/toolInvocation/invoking": invoking, "openai/toolInvocation/invoked": invoked}6384 }6485 65-// rangeView draws what read_range read, at most maxViewRows rows.66-func (s *Server) rangeView(req *sdk.CallToolRequest, t headless.Target) *sdk.CallToolResult {67- if !wantsViews(req) {86+// forModel is a result whose text is v as JSON: the structured result87+// without its view.88+func forModel(v any) *sdk.CallToolResult {89+ data, err := json.Marshal(v)90+ if err != nil {6891 return nil6992 }93+ return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: string(data)}}}94+}95+96+// rangeView draws the rows read_range returned of t, at most97+// maxViewRows.98+func rangeView(b *book, t headless.Target, rows int) *View {7099 r := t.Range71100 if t.Whole {72- r, _ = t.Sheet.UsedRange()101+ var ok bool102+ if r, ok = t.Sheet.UsedRange(); !ok {103+ r = sheet.Rect{}104+ }73105 }74- r.To.Row = min(r.To.Row, r.From.Row+maxViewRows-1)106+ r.To.Row = min(r.To.Row, r.From.Row+max(rows, 1)-1, r.From.Row+maxViewRows-1)75107 snap := fileio.Snap(t.Sheet, r, t.Sheet.Name())76108 grid, _ := fileio.HTMLGrid(snap)77- return viewResult(s.b.Name(), sheet.Qualified(t.Sheet.Name(), snap.Range), grid)109+ return &View{Title: b.Name(), Where: sheet.Qualified(t.Sheet.Name(), snap.Range), HTML: grid}78110 }79111 80-// chartView draws the chart create_chart made.81-func (s *Server) chartView(req *sdk.CallToolRequest, w *sheet.Workbook, made headless.ChartMade) *sdk.CallToolResult {82- if !wantsViews(req) {83- return nil84- }112+// chartView draws the chart create_chart made in b.113+func chartView(b *book, w *sheet.Workbook, made headless.ChartMade) *View {85114 c, ok := fileio.ChartSnap(w.Lookup(made.Sheet), made.Number-1)86115 if !ok {87116 return nil88117 }89- return viewResult(made.Title, made.Sheet+" "+c.Data.String(), fileio.HTMLChart(c))118+ data := sheet.Qualified(made.Sheet, c.Data)119+ return &View{Title: b.Name(), Name: c.Data.String(), Where: c.Type.Title() + " chart of " + data, HTML: fileio.HTMLChart(c)}90120 }91121 92-// viewScript is the view's side of the extension's messages, over93-// postMessage with the host: ui/initialize, then the host's theme, the94-// tool's result and teardown, and the page's height as it changes.95-const viewScript = `(function(){96-var next=1,host=window.parent;97-function send(m){host.postMessage(Object.assign({jsonrpc:"2.0"},m),"*")}98-function theme(t){var r=document.documentElement;r.classList.remove("dark","light");if(t==="dark"||t==="light")r.classList.add(t)}99-function size(){send({method:"ui/notifications/size-changed",params:{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight}})}100-function show(res){101- var v=res&&res._meta&&res._meta["o12/view"];if(!v)return;102- document.getElementById("o12-view").innerHTML=v.html;103- var c=document.getElementById("o12-context");if(c)c.textContent=v.where;104- var first=document.querySelector("#o12-view td[data-a]");if(first&&window.o12point)window.o12point(first);105- size();106-}107-window.addEventListener("message",function(e){108- var m=e.data;if(!m||m.jsonrpc!=="2.0")return;109- if(m.id===1&&m.result){var hc=m.result.hostContext||{};theme(hc.theme);send({method:"ui/notifications/initialized",params:{}});return}110- switch(m.method){111- case "ui/notifications/tool-result":show(m.params);break;112- case "ui/notifications/host-context-changed":if(m.params)theme(m.params.theme);break;113- case "ui/resource-teardown":send({id:m.id,result:{}});break;114- }115-});116-send({id:next++,method:"ui/initialize",params:{protocolVersion:"2026-01-26",capabilities:{},clientInfo:{name:"012",version:"1"},appCapabilities:{availableDisplayModes:["inline","fullscreen"]}}});117-new ResizeObserver(size).observe(document.body);118-})();122+// viewCSS fits the export's page to a frame: the panel on top, the123+// grid scrolling in what room the host gives, its headers sticking to124+// the grid's edges.125+const viewCSS = `html,body{overflow:hidden}126+.o12{width:auto;min-width:0}127+#o12-view{overflow-x:auto}128+#o12-view .sheet{overflow:auto;max-height:480px;overscroll-behavior:contain}129+#o12-view .grid thead th{top:0}130+#o12-view:empty::before{content:"Waiting for the tool's result";display:block;color:var(--muted);padding:0 9px;height:21px}119131 `
A internal/mcp/views_browser_test.go +102 -0
@@ -0,0 +1,102 @@
1+package mcp2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "os"8+ "os/exec"9+ "path/filepath"10+ "strings"11+ "testing"12+13+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"14+15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// TestViewInBrowser draws the view as the OpenAI Apps SDK's hosts and19+// MCP Apps hosts do, in Chromium through Playwright (testdata/view.mjs),20+// and checks the grid, the pointer and formula bar, scrolling, the21+// theme and the frame's height. It needs Node and the playwright22+// package, found through O12_PLAYWRIGHT (a folder whose node_modules23+// holds it) or NODE_PATH, and is skipped without them.24+// O12_VIEW_SHOTS names a folder for screenshots of each case.25+func TestViewInBrowser(t *testing.T) {26+ if testing.Short() {27+ t.Skip("-short")28+ }29+ nodePath := playwrightPath(t)30+ w := sheet.NewBook()31+ s := w.Sheet(0)32+ s.Set(sheet.Addr{}, "Month")33+ s.Set(sheet.Addr{Col: 1}, "Revenue")34+ s.Set(sheet.Addr{Col: 2}, "Cost")35+ s.Set(sheet.Addr{Col: 3}, "Profit")36+ for r := 1; r <= 60; r++ {37+ s.Set(sheet.Addr{Row: r}, fmt.Sprintf("Week %d", r))38+ s.Set(sheet.Addr{Col: 1, Row: r}, fmt.Sprintf("$%d,%03d", 10+r%7, r*37%1000))39+ s.Set(sheet.Addr{Col: 2, Row: r}, fmt.Sprintf("$%d,%03d", 6+r%3, r*53%1000))40+ s.Set(sheet.Addr{Col: 3, Row: r}, fmt.Sprintf("=B%d-C%d", r+1, r+1))41+ }42+ path := filepath.Join(t.TempDir(), "weeks.012")43+ writeBook(t, w, path)44+ cs := connect(t, Options{Default: path})45+ result := func(name string, args map[string]any) *sdk.CallToolResult {46+ return call(t, cs, name, args, nil)47+ }48+ gridArgs := map[string]any{"ref": "A1:D61"}49+ chartArgs := map[string]any{"data": "A1:C9", "type": "line", "title": "Revenue and cost"}50+ cases := map[string]any{51+ "page": viewPage(),52+ "cases": map[string]any{53+ "grid": map[string]any{"args": gridArgs, "result": result("read_range", gridArgs), "grid": map[string]string{"a1": "Month", "cell": "D2", "input": "=B2-C2", "below": "D3"}},54+ "chart": map[string]any{"args": chartArgs, "result": result("create_chart", chartArgs)},55+ },56+ }57+ data, err := json.Marshal(cases)58+ if err != nil {59+ t.Fatal(err)60+ }61+ file := filepath.Join(t.TempDir(), "cases.json")62+ if err := os.WriteFile(file, data, 0o644); err != nil {63+ t.Fatal(err)64+ }65+ args := []string{filepath.Join("testdata", "view.mjs"), file}66+ if shots := os.Getenv("O12_VIEW_SHOTS"); shots != "" {67+ args = append(args, shots)68+ }69+ cmd := exec.Command("node", args...)70+ cmd.Env = append(os.Environ(), "NODE_PATH="+nodePath)71+ out, err := cmd.CombinedOutput()72+ var exit *exec.ExitError73+ switch {74+ case errors.As(err, &exit) && exit.ExitCode() == 3:75+ t.Skipf("no browser for Playwright: %s", out)76+ case err != nil:77+ t.Fatalf("%v\n%s", err, out)78+ }79+}80+81+// playwrightPath is the NODE_PATH that finds the playwright package,82+// skipping the test when there's none.83+func playwrightPath(t *testing.T) string {84+ t.Helper()85+ if _, err := exec.LookPath("node"); err != nil {86+ t.Skip("no node")87+ }88+ var dirs []string89+ if d := os.Getenv("O12_PLAYWRIGHT"); d != "" {90+ dirs = append(dirs, filepath.Join(d, "node_modules"), d)91+ }92+ if d := os.Getenv("NODE_PATH"); d != "" {93+ dirs = append(dirs, d)94+ }95+ nodePath := strings.Join(dirs, string(os.PathListSeparator))96+ cmd := exec.Command("node", "-e", "require('playwright')")97+ cmd.Env = append(os.Environ(), "NODE_PATH="+nodePath)98+ if err := cmd.Run(); err != nil {99+ t.Skip("no playwright package: set O12_PLAYWRIGHT to a folder whose node_modules holds it")100+ }101+ return nodePath102+}
M internal/mcp/views_test.go +57 -40
@@ -2,6 +2,7 @@ package mcp
22 33 import (44 "context"5+ "encoding/json"56 "strings"67 "testing"78
@@ -10,34 +11,26 @@ import (
1011 1112 // connectApps is connect with a client that declares the MCP Apps1213 // extension.13-func connectApps(t *testing.T, b Backend) *sdk.ClientSession {14+func connectApps(t *testing.T, o Options) *sdk.ClientSession {1415 t.Helper()15- ctx := context.Background()16- st, ct := sdk.NewInMemoryTransports()17- ss, err := New(b, Options{}).Connect(ctx, st, nil)18- if err != nil {19- t.Fatal(err)20- }21- t.Cleanup(func() { ss.Close() })2216 caps := &sdk.ClientCapabilities{}2317 caps.AddExtension(uiExtension, map[string]any{"mimeTypes": []string{uiMIME}})24- cs, err := sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}).Connect(ctx, ct, nil)25- if err != nil {26- t.Fatal(err)27- }28- t.Cleanup(func() { cs.Close() })29- return cs18+ return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}), nil)3019 }3120 32-func viewHTML(res *sdk.CallToolResult) string {33- v, _ := res.Meta[viewKey].(map[string]any)34- html, _ := v["html"].(string)35- return html21+// resultView is the view in a result's structured content.22+func resultView(t *testing.T, res *sdk.CallToolResult) View {23+ t.Helper()24+ var out struct{ View View }25+ data, _ := json.Marshal(res.StructuredContent)26+ if err := json.Unmarshal(data, &out); err != nil {27+ t.Fatal(err)28+ }29+ return out.View3630 }3731 38-func TestViews(t *testing.T) {39- path := bookFile(t)40- cs := connectApps(t, &FileBackend{Path: path})32+func TestViewResources(t *testing.T) {33+ cs := connectApps(t, Options{Default: bookFile(t)})4134 ctx := context.Background()4235 for tl, err := range cs.Tools(ctx, nil) {4336 if err != nil {
@@ -45,30 +38,54 @@ func TestViews(t *testing.T) {
4538 }4639 ui, _ := tl.Meta["ui"].(map[string]any)4740 drawn := tl.Name == "read_range" || tl.Name == "create_chart"48- if (ui["resourceUri"] == viewURI) != drawn {41+ if (ui["resourceUri"] == viewURI) != drawn || (tl.Meta["openai/outputTemplate"] == skybridgeURI) != drawn {4942 t.Errorf("%s: _meta %v", tl.Name, tl.Meta)5043 }5144 }52- res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: viewURI})53- if err != nil {54- t.Fatal(err)55- }56- page := res.Contents[0]57- if page.MIMEType != uiMIME || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "@font-face") ||58- !strings.Contains(page.Text, `<div id="o12-view"></div>`) {59- t.Errorf("view resource: %s %.200s", page.MIMEType, page.Text)45+ for uri, mime := range map[string]string{viewURI: uiMIME, skybridgeURI: skybridgeMIME} {46+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})47+ if err != nil {48+ t.Fatal(err)49+ }50+ page := res.Contents[0]51+ if page.MIMEType != mime || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "openai:set_globals") ||52+ !strings.Contains(page.Text, "@font-face") || !strings.Contains(page.Text, `<div id="o12-view"></div>`) {53+ t.Errorf("%s: %s %.200s", uri, page.MIMEType, page.Text)54+ }55+ if ui, _ := page.Meta["ui"].(map[string]any); ui["prefersBorder"] != true || page.Meta["openai/widgetPrefersBorder"] != true {56+ t.Errorf("%s: _meta %v", uri, page.Meta)57+ }6058 }61- got := call(t, cs, "read_range", map[string]any{"ref": "A1:C4"}, nil)62- if html := viewHTML(got); !strings.Contains(html, `<table class="grid">`) || !strings.Contains(html, `data-a="A2"`) {63- t.Errorf("read_range view: %.300s", html)59+}60+61+// TestViewsForEveryClient checks the view is in the structured content62+// whether or not the client declared the extension (the Apps SDK's63+// hosts don't), and left out of the text models read.64+func TestViewsForEveryClient(t *testing.T) {65+ path := bookFile(t)66+ for name, cs := range map[string]*sdk.ClientSession{67+ "apps": connectApps(t, Options{Default: path}),68+ "plain": connect(t, Options{Default: path}),69+ } {70+ got := call(t, cs, "read_range", map[string]any{"ref": "A1:C4"}, nil)71+ v := resultView(t, got)72+ if !strings.Contains(v.HTML, `<table class="grid">`) || !strings.Contains(v.HTML, `data-a="A2"`) || v.Where != "Sheet1!A1:C4" || v.Title != "book" {73+ t.Errorf("%s: read_range view: %+v", name, v)74+ }75+ if text := got.Content[0].(*sdk.TextContent).Text; strings.Contains(text, "html") || !strings.Contains(text, `"range":"Sheet1!A1:C4"`) {76+ t.Errorf("%s: text for the model: %s", name, text)77+ }78+ got = call(t, cs, "read_range", map[string]any{"ref": "A1:C4", "max_cells": 6}, nil)79+ if v := resultView(t, got); v.Where != "Sheet1!A1:C2" {80+ t.Errorf("%s: the view draws %s, not the rows read", name, v.Where)81+ }6482 }65- got = call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "title": "Units"}, nil)66- if html := viewHTML(got); !strings.Contains(html, `<figure class="chart solo"`) || !strings.Contains(html, "<svg") {67- t.Errorf("create_chart view: %.300s", html)83+ cs := connect(t, Options{Default: path})84+ got := call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "title": "Units"}, nil)85+ if v := resultView(t, got); !strings.Contains(v.HTML, `<figure class="chart solo"`) || !strings.Contains(v.HTML, "<svg") || v.Title != "book" || v.Name != "A1:B4" || v.Where != "Column chart of Sheet1!A1:B4" {86+ t.Errorf("create_chart view: %.300s", v.HTML)6887 }69- // A client without the extension gets no view.70- plain := connect(t, &FileBackend{Path: path}, Options{})71- if got := call(t, plain, "read_range", map[string]any{"ref": "A1"}, nil); got.Meta[viewKey] != nil {72- t.Error("a view for a client without the extension")88+ if text := got.Content[0].(*sdk.TextContent).Text; strings.Contains(text, "<svg") || !strings.Contains(text, `"saved":true`) {89+ t.Errorf("create_chart text: %s", text)7390 }7491 }
A internal/mcp/viewscript.go +82 -0
@@ -0,0 +1,82 @@
1+package mcp2+3+// viewScript is the view's side of both hosts' protocols. With4+// window.openai (the Apps SDK) it draws toolOutput.view, takes the5+// theme, maxHeight and displayMode from it and from openai:set_globals6+// events, and reports its height with notifyIntrinsicHeight. Otherwise7+// it speaks MCP Apps over postMessage: ui/initialize with its info and8+// display modes, then ui/notifications/initialized; it draws the9+// structuredContent.view of ui/notifications/tool-result, follows the10+// hostContext's theme, containerDimensions and displayMode as they11+// change, answers ping and ui/resource-teardown, and sends12+// ui/notifications/size-changed as its content's size changes,13+// measured as the extension's SDK measures it.14+//15+// The grid scrolls inside the room it's given: inline, at most 48016+// pixels, less when the host's maximum height less the panel's is17+// smaller, so a view in a conversation stays a glance; the whole frame18+// when the host fixes its height or shows it full screen. A smaller19+// range takes only its own height.20+const viewScript = `(function(){21+var doc=document.documentElement,view=document.getElementById("o12-view"),oai=window.openai;22+var room=0,fill=false,shown=null,next=1,lastW=0,lastH=0,sizing=false;23+function theme(t){doc.classList.remove("dark","light");if(t==="dark"||t==="light")doc.classList.add(t)}24+function send(m){window.parent.postMessage(Object.assign({jsonrpc:"2.0"},m),"*")}25+function fit(){26+ var sh=view.querySelector(".sheet"),p=document.querySelector(".panel");if(!sh)return;27+ var top=p?p.offsetHeight:0,h=fill?window.innerHeight-top:Math.min(room>0?room-top:480,480);28+ sh.style.maxHeight=Math.max(h,84)+"px";29+}30+function measure(){31+ var old=doc.style.height;doc.style.height="max-content";32+ var h=Math.ceil(doc.getBoundingClientRect().height);doc.style.height=old;return h;33+}34+function size(){35+ if(sizing)return;sizing=true;36+ requestAnimationFrame(function(){37+ sizing=false;var w=Math.ceil(window.innerWidth),h=measure();38+ if(w===lastW&&h===lastH)return;lastW=w;lastH=h;39+ if(oai){if(typeof oai.notifyIntrinsicHeight==="function")oai.notifyIntrinsicHeight(h)}40+ else send({method:"ui/notifications/size-changed",params:{width:w,height:h}});41+ });42+}43+function show(v){44+ if(!v||typeof v.html!=="string"||v===shown)return;45+ shown=v;view.innerHTML=v.html;46+ var b=document.querySelector(".panel .book"),c=document.getElementById("o12-context"),n=document.getElementById("o12-name");47+ if(b)b.textContent=v.title||"";if(c)c.textContent=v.where||"";if(n)n.textContent=v.name||"";48+ var first=view.querySelector("td[data-a]");if(first&&window.o12point)window.o12point(first);49+ fit();size();50+}51+function viewOf(r){return r&&r.structuredContent&&r.structuredContent.view}52+function context(hc){53+ if(!hc)return;if(hc.theme)theme(hc.theme);54+ var d=hc.containerDimensions;55+ if(d){fill=typeof d.height==="number";room=typeof d.maxHeight==="number"?d.maxHeight:0}56+ if(hc.displayMode)fill=fill||hc.displayMode==="fullscreen";57+ fit();size();58+}59+function globals(g){60+ if(!g)return;if(g.theme)theme(g.theme);61+ if(typeof g.maxHeight==="number")room=g.maxHeight;62+ if(g.displayMode)fill=g.displayMode==="fullscreen";63+ if(g.toolOutput)show(g.toolOutput.view);64+ fit();size();65+}66+window.addEventListener("message",function(e){67+ var m=e.data;if(!m||m.jsonrpc!=="2.0"||oai)return;68+ if(m.id===1&&(m.result||m.error)){if(m.result)context(m.result.hostContext);send({method:"ui/notifications/initialized",params:{}});return}69+ switch(m.method){70+ case "ui/notifications/tool-result":show(viewOf(m.params));break;71+ case "ui/notifications/host-context-changed":context(m.params);break;72+ case "ui/resource-teardown":case "ping":send({id:m.id,result:{}});break;73+ }74+});75+window.addEventListener("openai:set_globals",function(e){if(e.detail)globals(e.detail.globals)});76+window.addEventListener("resize",function(){fit();size()});77+new ResizeObserver(size).observe(document.body);78+if(oai)globals(oai);79+else send({id:next++,method:"ui/initialize",params:{protocolVersion:"2026-01-26",appInfo:{name:"012",version:"1"},appCapabilities:{availableDisplayModes:["inline","fullscreen"]}}});80+size();81+})();82+`
M internal/mcp/write.go +48 -23
@@ -9,21 +9,24 @@ import (
99 "github.com/FelineStateMachine/012/internal/sheet"1010 )1111 12-// The tools that change the workbook. Each is one call to the backend's12+// The tools that change a workbook. Each is one call to the backend's1313 // Change, so one undo step and one save, and returns what changed as1414 // 012 diff lists it; dry_run returns it without keeping it.1515 1616 type writeIn struct {17+ bookIn1718 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"`1819 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`1920 }2021 2122 type opsIn struct {23+ bookIn2224 Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`2325 DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`2426 }2527 2628 type sortIn struct {29+ bookIn2730 Ref string `json:"ref" jsonschema:"the range to sort"`2831 Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`2932 Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`
@@ -31,6 +34,7 @@ type sortIn struct {
3134 }3235 3336 type filterIn struct {37+ bookIn3438 Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`3539 Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`3640 Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`
@@ -38,16 +42,19 @@ type filterIn struct {
3842 }3943 4044 type chartIn struct {45+ bookIn4146 headless.ChartSpec4247 DryRun bool `json:"dry_run,omitempty"`4348 }4449 4550 type pivotIn struct {51+ bookIn4652 headless.PivotSpec4753 DryRun bool `json:"dry_run,omitempty"`4854 }4955 5056 type runCellIn struct {57+ bookIn5158 Notebook string `json:"notebook" jsonschema:"the notebook tab's name"`5259 Cell int `json:"cell" jsonschema:"the cell's number, 1 for the first, as describe lists them"`5360 }
@@ -63,6 +70,7 @@ type Changed struct {
6370 type chartOut struct {6471 Chart headless.ChartMade `json:"chart"`6572 Changed73+ View *View `json:"view,omitempty"`6674 }6775 6876 type pivotOut struct {
@@ -76,45 +84,46 @@ func (s *Server) addWriteTools() {
7684 set := headless.SetOptions{Force: s.opts.Force}7785 tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,7886 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) {87+ func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {8088 var warnings []string81- return s.change(ctx, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {89+ return s.change(ctx, req, in.Path, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {8290 warnings, err = headless.Set(w, in.Entries, set)8391 return err8492 })8593 })8694 tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},8795 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) {96+ func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {8997 var warnings []string90- return s.change(ctx, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {98+ return s.change(ctx, req, in.Path, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {9199 warnings, err = headless.Apply(w, in.Operations, set)92100 return err93101 })94102 })95103 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) })104+ func(ctx context.Context, req *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {105+ return s.change(ctx, req, in.Path, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })98106 })99107 tool(s, &sdk.Tool{Name: "filter", Annotations: writes,100108 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) })109+ func(ctx context.Context, req *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {110+ return s.change(ctx, req, in.Path, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })103111 })104- tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: s.viewMeta(),112+ tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: viewMeta("Adding the chart", "Added the chart"),105113 Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},106114 s.createChart)107115 tool(s, &sdk.Tool{Name: "create_pivot", Annotations: writes,108116 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) {117+ func(ctx context.Context, req *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {110118 var out pivotOut111- _, changed, err := s.change(ctx, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {119+ _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {112120 out.Sheet, err = headless.AddPivot(w, in.PivotSpec)113121 return err114122 })115123 out.Changed = changed116124 return nil, out, err117125 })126+ s.addCreateTool()118127 if s.opts.Notebooks != nil {119128 tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},120129 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."},
@@ -124,23 +133,35 @@ func (s *Server) addWriteTools() {
124133 125134 func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {126135 var out chartOut127- var res *sdk.CallToolResult128- _, changed, err := s.change(ctx, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {136+ b, err := s.open(ctx, req, in.Path)137+ if err != nil {138+ return nil, out, err139+ }140+ _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {129141 if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {130142 return err131143 }132- res = s.chartView(req, w, out.Chart)144+ out.View = chartView(b, w, out.Chart)133145 return nil134146 })147+ if err != nil {148+ return nil, chartOut{}, err149+ }135150 out.Changed = changed136- return res, out, err151+ model := out152+ model.View = nil153+ return forModel(model), out, nil137154 }138155 139-func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {156+func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {140157 var out headless.CellRun141- _, _, err := s.change(ctx, "run notebook cell", false, nil, func(w *sheet.Workbook) error {158+ b, err := s.open(ctx, req, in.Path)159+ if err != nil {160+ return nil, out, err161+ }162+ _, _, err = s.change(ctx, req, in.Path, "run notebook cell", false, nil, func(w *sheet.Workbook) error {142163 if s.opts.MayRun != nil {143- if err := s.opts.MayRun(w); err != nil {164+ if err := s.opts.MayRun(b.path, w); err != nil {144165 return err145166 }146167 }
@@ -151,10 +172,14 @@ func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCell
151172 return nil, out, err152173 }153174 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)175+// change makes fn a change of the workbook path names and says what it176+// changed; warnings, when set, are fn's.177+func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {178+ b, err := s.open(ctx, req, path)179+ if err != nil {180+ return nil, Changed{}, err181+ }182+ changes, err := b.Change(ctx, label, dryRun, fn)158183 if err != nil {159184 return nil, Changed{}, err160185 }