cat / 012
clone

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

Merge branch 'main' into worktree-agent-a4e6ec5e6afec1cd9

Dami committed b077eab
commit
b077eabff5ae249352914a9f01d33fdb939da216
parent
bfe621b123de325334fdd81dd39364c87806bb36
parent
e34a85f0dda96ae15f0397b53f13abee49669c16
author
Dami <dami.etoile@gmail.com>
31 files changed, +2354 -393
MROADMAP.md4+-
Mcmd/012/agentskill.go18+-
Mcmd/012/args.go7+-
Acmd/012/installmcp.go311++++++++++++++++++++++++++++++
Acmd/012/installmcp_test.go134+++++++++++++
Mcmd/012/mcp.go51++++-
Mcmd/012/mcp_test.go64+++++-
Mcmd/012/skill/SKILL.md24++-
Mdocs/agents/README.md2+-
Mdocs/agents/mcp.md164++++++++++------
Mdocs/contributing/testing.md16++
Mdocs/reference/command-line.md5+-
Mdocs/reference/json.md29++-
Minternal/mcp/backend.go47+++++
Ainternal/mcp/books.go190++++++++++++++++++
Ainternal/mcp/books_test.go177+++++++++++++++++
Ainternal/mcp/create.go83++++++++
Ainternal/mcp/list.go99++++++++++
Minternal/mcp/prompts.go33++-
Minternal/mcp/read.go128++++++++++---
Minternal/mcp/resources.go199+++++++++++++------
Ainternal/mcp/roots.go77+++++++
Ainternal/mcp/roots_test.go37++++
Minternal/mcp/server.go81++++++--
Minternal/mcp/server_test.go56+++--
Ainternal/mcp/testdata/view.mjs161++++++++++++++++
Minternal/mcp/views.go170+++++++++--------
Ainternal/mcp/views_browser_test.go102++++++++++
Minternal/mcp/views_test.go127+++++++++----
Ainternal/mcp/viewscript.go82++++++++
Minternal/mcp/write.go69++++--
M ROADMAP.md +2 -2
M cmd/012/agentskill.go +13 -5
@@ -14,12 +14,13 @@ import (
1414 // 012 agent --skill and 012 agent --install-skill: the Claude Code1515 // skill that teaches agents with a shell to use 012 (see1616 // docs/agents/README.md), installed as 012 nu --install-module installs17-// the nushell module.17+// the nushell module. 012 agent --install-mcp adds 012 mcp to a host18+// (installmcp.go).1819 1920 //go:embed skill/SKILL.md2021 var skillSource string2122 22-const agentUsage = "usage: 012 agent --skill | --install-skill [--force] [dir]"23+const agentUsage = "usage: 012 agent --skill | --install-skill [--force] [dir] | --install-mcp claude-code|claude-desktop|codex [--root dir]... [--print]"2324 2425 // skillHome is where Claude Code looks for a person's own skills; tests2526 // replace it.
@@ -31,16 +32,23 @@ var skillHome = func() (string, error) {
3132 return filepath.Join(home, ".claude", "skills", "012"), nil3233 }3334 34-// runAgent is 012 agent --skill (print the skill) or --install-skill35-// [--force] [dir].35+// runAgent is 012 agent --skill (print the skill), --install-skill36+// [--force] [dir], or --install-mcp host [--root dir]... [--print].3637 func runAgent(args []string, e env) error {37- a, err := parseArgs(args, nil, []string{"skill", "install-skill", "force", "help"})38+ a, err := parseArgs(args, []string{"install-mcp", "root"}, []string{"skill", "install-skill", "force", "print", "help"})3839 switch {3940 case err != nil:4041 return usageError(err.Error(), agentUsage)4142 case a.has("help"):4243 fmt.Fprintln(e.stdout, agentUsage)4344 return nil45+ case a.has("install-mcp"):46+ if a.has("skill") || a.has("install-skill") || a.has("force") || len(a.pos) > 0 {47+ return usageError("", agentUsage)48+ }49+ return installMCP(e, a.flags["install-mcp"], a.all["root"], a.has("print"))50+ case a.has("root") || a.has("print"):51+ return usageError("--root and --print go with --install-mcp", agentUsage)4452 case a.has("skill") && !a.has("install-skill") && len(a.pos) == 0:4553 _, err := fmt.Fprint(e.stdout, skillSource)4654 return err
M cmd/012/args.go +5 -2
A cmd/012/installmcp.go +311 -0
@@ -0,0 +1,311 @@
1+package main2+3+import (4+ "bytes"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "reflect"12+ "regexp"13+ "strings"14+ "time"15+)16+17+// 012 agent --install-mcp claude-code|claude-desktop|codex: the entry18+// that starts 012 mcp, written into the host's configuration (see19+// docs/agents/mcp.md#adding-it-to-a-host). The entry runs this binary by20+// its absolute path with no workbook, so one server works on every21+// workbook the host's roots or --root open to it. A file edited is22+// copied beside itself first; an entry already the same is left alone.23+24+// mcpHost is a host whose configuration holds MCP servers.25+type mcpHost struct {26+ name string // as --install-mcp takes it27+ app string // as people call it28+ path func(e env) (string, error)29+ // entry is the configuration's text with 012's entry set, old being30+ // what's there ("" for no file); same is set when it was already.31+ entry func(old []byte, command string, args []string) (text []byte, same bool, err error)32+ // show is the entry alone, for --print.33+ show func(command string, args []string) string34+}35+36+var mcpHosts = []mcpHost{37+ {name: "claude-code", app: "Claude Code", path: claudeConfig, entry: jsonEntry(true), show: jsonShow(true)},38+ {name: "codex", app: "Codex", path: codexConfig, entry: tomlEntry, show: tomlShow},39+ {name: "claude-desktop", app: "Claude Desktop", path: desktopConfig, entry: jsonEntry(false), show: jsonShow(false)},40+}41+42+// executable is this binary's path; tests replace it.43+var executable = os.Executable44+45+// installMCP writes the entry for host, or prints it with print.46+func installMCP(e env, host string, roots []string, print bool) error {47+ var h *mcpHost48+ for i := range mcpHosts {49+ if mcpHosts[i].name == host {50+ h = &mcpHosts[i]51+ }52+ }53+ if h == nil {54+ return usageError(fmt.Sprintf("--install-mcp takes claude-code, claude-desktop or codex, not %q", host), agentUsage)55+ }56+ command, err := executable()57+ if err != nil {58+ return err59+ }60+ if command, err = filepath.Abs(command); err != nil {61+ return err62+ }63+ args := []string{"mcp"}64+ for _, r := range roots {65+ abs, err := filepath.Abs(r)66+ if err != nil {67+ return err68+ }69+ args = append(args, "--root", abs)70+ }71+ path, err := h.path(e)72+ if err != nil {73+ return err74+ }75+ if print {76+ _, err := fmt.Fprintf(e.stdout, "%s, in %s:\n\n%s", h.app, path, h.show(command, args))77+ return err78+ }79+ return writeEntry(e, h, path, command, args)80+}81+82+// writeEntry sets 012's entry in the configuration at path, backing the83+// file up first.84+func writeEntry(e env, h *mcpHost, path, command string, args []string) error {85+ old, err := os.ReadFile(path)86+ if err != nil && !errors.Is(err, fs.ErrNotExist) {87+ return err88+ }89+ text, same, err := h.entry(old, command, args)90+ if err != nil {91+ return fmt.Errorf("%s: %w", path, err)92+ }93+ if same {94+ fmt.Fprintf(e.stdout, "%s already runs %s mcp: %s is up to date\n", h.app, command, path)95+ return nil96+ }97+ mode := fs.FileMode(0o600)98+ if st, err := os.Stat(path); err == nil {99+ mode = st.Mode().Perm()100+ backup := path + ".bak-" + time.Now().Format("20060102-150405")101+ if err := os.WriteFile(backup, old, mode); err != nil {102+ return err103+ }104+ fmt.Fprintln(e.stdout, "Backed up", path, "to", backup)105+ } else if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {106+ return err107+ }108+ if err := os.WriteFile(path, text, mode); err != nil {109+ return err110+ }111+ fmt.Fprintf(e.stdout, "Wrote the 012 entry to %s: %s %s\n", path, command, strings.Join(args, " "))112+ fmt.Fprintf(e.stdout, "Restart %s to start it; it works on the workbooks in the folders it's given, without naming one.\n", h.app)113+ for _, other := range otherEntries(h, text, command) {114+ fmt.Fprintf(e.stdout, "%s also starts 012 as %q; remove that entry if it was for one workbook.\n", path, other)115+ }116+ return nil117+}118+119+// claudeConfig is Claude Code's user configuration, where claude mcp add120+// --scope user puts servers.121+func claudeConfig(e env) (string, error) {122+ if dir := e.getenv("CLAUDE_CONFIG_DIR"); dir != "" {123+ return filepath.Join(dir, ".claude.json"), nil124+ }125+ home, err := os.UserHomeDir()126+ return filepath.Join(home, ".claude.json"), err127+}128+129+// codexConfig is Codex's configuration, in CODEX_HOME or ~/.codex.130+func codexConfig(e env) (string, error) {131+ if dir := e.getenv("CODEX_HOME"); dir != "" {132+ return filepath.Join(dir, "config.toml"), nil133+ }134+ home, err := os.UserHomeDir()135+ return filepath.Join(home, ".codex", "config.toml"), err136+}137+138+// desktopConfig is Claude Desktop's configuration, in the folder the139+// system keeps applications' settings in.140+func desktopConfig(env) (string, error) {141+ dir, err := os.UserConfigDir()142+ return filepath.Join(dir, "Claude", "claude_desktop_config.json"), err143+}144+145+// jsonServer is an entry of mcpServers; Claude Code's says its type.146+func jsonServer(typed bool, command string, args []string) map[string]any {147+ s := map[string]any{"command": command, "args": args}148+ if typed {149+ s["type"], s["env"] = "stdio", map[string]any{}150+ }151+ return s152+}153+154+func jsonShow(typed bool) func(string, []string) string {155+ return func(command string, args []string) string {156+ data, _ := json.MarshalIndent(map[string]any{"mcpServers": map[string]any{"012": jsonServer(typed, command, args)}}, "", " ")157+ return string(data) + "\n"158+ }159+}160+161+// jsonEntry sets mcpServers["012"] in a JSON configuration, keeping the162+// rest of it as it was, keys in order.163+func jsonEntry(typed bool) func([]byte, string, []string) ([]byte, bool, error) {164+ return func(old []byte, command string, args []string) ([]byte, bool, error) {165+ top := map[string]json.RawMessage{}166+ if len(bytes.TrimSpace(old)) > 0 {167+ if err := json.Unmarshal(old, &top); err != nil {168+ return nil, false, fmt.Errorf("not JSON 012 can add to: %w", err)169+ }170+ }171+ servers := map[string]json.RawMessage{}172+ if raw, ok := top["mcpServers"]; ok {173+ if err := json.Unmarshal(raw, &servers); err != nil {174+ return nil, false, fmt.Errorf("mcpServers isn't an object: %w", err)175+ }176+ }177+ entry, _ := json.Marshal(jsonServer(typed, command, args))178+ if sameJSON(servers["012"], entry) {179+ return old, true, nil180+ }181+ servers["012"] = entry182+ top["mcpServers"], _ = json.Marshal(servers)183+ text, err := json.MarshalIndent(top, "", " ")184+ return append(text, '\n'), false, err185+ }186+}187+188+func sameJSON(a, b []byte) bool {189+ var x, y any190+ return json.Unmarshal(a, &x) == nil && json.Unmarshal(b, &y) == nil && reflect.DeepEqual(x, y)191+}192+193+// otherEntries are the names of the configuration's other servers that194+// start this 012.195+func otherEntries(h *mcpHost, text []byte, command string) []string {196+ var out []string197+ if h.name == "codex" {198+ for _, m := range tomlTable.FindAllSubmatchIndex(text, -1) {199+ name := string(text[m[2]:m[3]])200+ if unquote(name) != "012" && bytes.Contains(text[m[1]:tomlTableEnd(text, m[1])], []byte(tomlString(command))) {201+ out = append(out, name)202+ }203+ }204+ return out205+ }206+ var top struct {207+ MCPServers map[string]struct {208+ Command string `json:"command"`209+ } `json:"mcpServers"`210+ }211+ _ = json.Unmarshal(text, &top)212+ for name, s := range top.MCPServers {213+ if name != "012" && s.Command == command {214+ out = append(out, name)215+ }216+ }217+ return out218+}219+220+// tomlTable finds [mcp_servers.<name>] tables' headers.221+var tomlTable = regexp.MustCompile(`(?m)^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*("[^"\n]*"|'[^'\n]*'|[A-Za-z0-9_-]+)[ \t]*\][ \t]*(?:#.*)?$`)222+223+// tomlKey finds a command or args key's line in a table.224+var tomlKey = regexp.MustCompile(`^[ \t]*(command|args)[ \t]*=`)225+226+func unquote(name string) string { return strings.Trim(name, `"'`) }227+228+// tomlTableEnd is where the table whose header ends at i ends: the next229+// table's header, or the end.230+func tomlTableEnd(text []byte, i int) int {231+ if m := tomlHeader.FindIndex(text[i:]); m != nil {232+ return i + m[0]233+ }234+ return len(text)235+}236+237+// tomlHeader finds any table's header; tomlInline012 a key 012 set238+// inline in [mcp_servers].239+var (240+ tomlHeader = regexp.MustCompile(`(?m)^[ \t]*\[`)241+ tomlInline012 = regexp.MustCompile(`(?m)^[ \t]*("012"|'012'|012)[ \t]*=`)242+)243+244+// tomlString is s as a TOML basic string.245+func tomlString(s string) string {246+ data, _ := json.Marshal(s) // JSON's escapes are TOML's247+ return string(data)248+}249+250+func tomlLines(command string, args []string) (string, string) {251+ quoted := make([]string, len(args))252+ for i, a := range args {253+ quoted[i] = tomlString(a)254+ }255+ return "command = " + tomlString(command), "args = [" + strings.Join(quoted, ", ") + "]"256+}257+258+func tomlShow(command string, args []string) string {259+ c, a := tomlLines(command, args)260+ return "[mcp_servers.012]\n" + c + "\n" + a + "\n"261+}262+263+// tomlEntry sets command and args in Codex's [mcp_servers.012] table,264+// keeping its other keys (env, timeouts) and everything else in the265+// file as written, or adds the table at the end.266+func tomlEntry(old []byte, command string, args []string) ([]byte, bool, error) {267+ c, a := tomlLines(command, args)268+ var header []int269+ for _, m := range tomlTable.FindAllSubmatchIndex(old, -1) {270+ if unquote(string(old[m[2]:m[3]])) == "012" {271+ header = m272+ }273+ }274+ if header == nil {275+ if tomlInline012.Match(old) {276+ return nil, false, errors.New("012 is set inline in [mcp_servers]; replace it with --print's table")277+ }278+ text := append([]byte{}, old...)279+ if len(text) > 0 && !bytes.HasSuffix(text, []byte("\n")) {280+ text = append(text, '\n')281+ }282+ if len(text) > 0 {283+ text = append(text, '\n')284+ }285+ return append(text, tomlShow(command, args)...), false, nil286+ }287+ start, end := header[1]+1, tomlTableEnd(old, min(header[1]+1, len(old)))288+ if start > len(old) {289+ start = len(old)290+ }291+ body := strings.SplitAfter(string(old[start:end]), "\n")292+ var kept []string293+ for i := 0; i < len(body); i++ {294+ m := tomlKey.FindStringSubmatch(body[i])295+ if m == nil {296+ kept = append(kept, body[i])297+ continue298+ }299+ if m[1] == "args" && strings.Count(body[i], "[") > strings.Count(body[i], "]") {300+ for i+1 < len(body) && !strings.Contains(body[i], "]") {301+ i++ // an array written over several lines302+ }303+ }304+ }305+ newBody := c + "\n" + a + "\n" + strings.Join(kept, "")306+ if newBody == string(old[start:end]) {307+ return old, true, nil308+ }309+ text := string(old[:start]) + newBody + string(old[end:])310+ return []byte(text), false, nil311+}
A cmd/012/installmcp_test.go +134 -0
@@ -0,0 +1,134 @@
1+package main2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+// mcpHome gives the test a home folder of its own and this 012 a path.12+func mcpHome(t *testing.T) string {13+ t.Helper()14+ home := t.TempDir()15+ t.Setenv("HOME", home)16+ t.Setenv("USERPROFILE", home)17+ t.Setenv("APPDATA", filepath.Join(home, "AppData"))18+ old := executable19+ executable = func() (string, error) { return "/opt/012/bin/012", nil }20+ t.Cleanup(func() { executable = old })21+ return home22+}23+24+func backups(t *testing.T, path string) int {25+ t.Helper()26+ found, _ := filepath.Glob(path + ".bak-*")27+ return len(found)28+}29+30+func TestInstallMCPClaude(t *testing.T) {31+ home := mcpHome(t)32+ e, out, _ := scriptEnv(t)33+ path := filepath.Join(home, ".claude.json")34+ os.WriteFile(path, []byte(`{"numStartups": 3, "mcpServers": {"other": {"command": "x"}}, "projects": {"/p": {"allowedTools": []}}}`), 0o600)35+ if code, err := status(e, "agent", "--install-mcp", "claude-code"); code != 0 {36+ t.Fatal(err)37+ }38+ var got struct {39+ NumStartups int `json:"numStartups"`40+ Projects map[string]any `json:"projects"`41+ MCPServers map[string]json.RawMessage `json:"mcpServers"`42+ }43+ data, _ := os.ReadFile(path)44+ if err := json.Unmarshal(data, &got); err != nil {45+ t.Fatal(err)46+ }47+ if got.NumStartups != 3 || got.Projects["/p"] == nil || got.MCPServers["other"] == nil ||48+ !sameJSON(got.MCPServers["012"], []byte(`{"type":"stdio","command":"/opt/012/bin/012","args":["mcp"],"env":{}}`)) {49+ t.Errorf("~/.claude.json: %s", data)50+ }51+ if backups(t, path) != 1 || !strings.Contains(out.String(), "Restart Claude Code") {52+ t.Errorf("backups %d, said %q", backups(t, path), out)53+ }54+ out.Reset()55+ if status(e, "agent", "--install-mcp", "claude-code"); !strings.Contains(out.String(), "is up to date") || backups(t, path) != 1 {56+ t.Errorf("again: %q, %d backups", out, backups(t, path))57+ }58+}59+60+func TestInstallMCPCodex(t *testing.T) {61+ home := mcpHome(t)62+ e, out, _ := scriptEnv(t)63+ path := filepath.Join(home, ".codex", "config.toml")64+ os.MkdirAll(filepath.Dir(path), 0o755)65+ old := `model = "gpt-5" # a comment66+67+[mcp_servers.012-demo]68+command = "/opt/012/bin/012"69+args = ["mcp", "/tmp/demo.012"]70+71+[mcp_servers.other.http_headers]72+Authorization = "Bearer secret"73+`74+ os.WriteFile(path, []byte(old), 0o600)75+ if code, err := status(e, "agent", "--install-mcp", "codex"); code != 0 {76+ t.Fatal(err)77+ }78+ data, _ := os.ReadFile(path)79+ want := old + "\n[mcp_servers.012]\ncommand = \"/opt/012/bin/012\"\nargs = [\"mcp\"]\n"80+ if string(data) != want {81+ t.Errorf("config.toml:\n%s\nwant\n%s", data, want)82+ }83+ if !strings.Contains(out.String(), `also starts 012 as "012-demo"`) || backups(t, path) != 1 {84+ t.Errorf("said %q, %d backups", out, backups(t, path))85+ }86+ out.Reset()87+ if status(e, "agent", "--install-mcp", "codex"); !strings.Contains(out.String(), "is up to date") {88+ t.Errorf("again: %q", out)89+ }90+91+ // A table already there keeps its other keys; its command and args92+ // (an array over several lines) are replaced.93+ os.WriteFile(path, []byte("[mcp_servers.\"012\"]\ncommand = \"012\"\nargs = [\n \"mcp\",\n \"a.012\",\n]\nstartup_timeout_sec = 20\n\n[mcp_servers.\"012\".env]\nX = \"1\"\n"), 0o600)94+ if code, err := status(e, "agent", "--install-mcp", "codex", "--root", home); code != 0 {95+ t.Fatal(err)96+ }97+ data, _ = os.ReadFile(path)98+ want = "[mcp_servers.\"012\"]\ncommand = \"/opt/012/bin/012\"\nargs = [\"mcp\", \"--root\", \"" + home + "\"]\nstartup_timeout_sec = 20\n\n[mcp_servers.\"012\".env]\nX = \"1\"\n"99+ if string(data) != want {100+ t.Errorf("replaced:\n%s\nwant\n%s", data, want)101+ }102+}103+104+func TestInstallMCPDesktopAndPrint(t *testing.T) {105+ mcpHome(t)106+ e, out, _ := scriptEnv(t)107+ path, err := desktopConfig(e)108+ if err != nil {109+ t.Fatal(err)110+ }111+ if code, err := status(e, "agent", "--install-mcp", "claude-desktop", "--print"); code != 0 || !strings.Contains(out.String(), `"command": "/opt/012/bin/012"`) {112+ t.Fatalf("--print: %v %q", err, out)113+ }114+ if _, err := os.Stat(path); err == nil {115+ t.Error("--print wrote the file")116+ }117+ if code, err := status(e, "agent", "--install-mcp", "claude-desktop"); code != 0 {118+ t.Fatal(err)119+ }120+ data, _ := os.ReadFile(path)121+ var got map[string]map[string]json.RawMessage122+ if json.Unmarshal(data, &got) != nil || !sameJSON(got["mcpServers"]["012"], []byte(`{"command":"/opt/012/bin/012","args":["mcp"]}`)) || backups(t, path) != 0 {123+ t.Errorf("claude_desktop_config.json: %s", data)124+ }125+ out.Reset()126+ if status(e, "agent", "--install-mcp", "codex", "--print"); !strings.Contains(out.String(), "[mcp_servers.012]\ncommand = \"/opt/012/bin/012\"\nargs = [\"mcp\"]\n") {127+ t.Errorf("codex --print: %q", out)128+ }129+ for _, args := range [][]string{{"--install-mcp", "vscode"}, {"--install-mcp", "claude"}, {"--install-mcp", "desktop"}, {"--install-mcp", "codex", "--skill"}, {"--print"}} {130+ if code, _ := status(e, append([]string{"agent"}, args...)...); code != 2 {131+ t.Errorf("%v: status %d", args, code)132+ }133+ }134+}
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 cmd/012/skill/SKILL.md +21 -3
M docs/agents/README.md +1 -1
M docs/agents/mcp.md +102 -62
@@ -5,57 +5,77 @@ sidebar_position: 2
55 66 # MCP server77 8-`012 mcp book.012` is a [Model Context Protocol](https://modelcontextprotocol.io)9-server on one workbook, over standard input and output, so any MCP host10-(Claude Code, Claude Desktop, editors) can read and change it. It is the11-same code as [the commands](README.md#with-a-shell): reads are12-`012 describe` and `012 get`, and every write is typed through the13-checks `012 set` makes, as one change, saved atomically at14-once.8+`012 mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)9+server over standard input and output, so any MCP host (Claude Code,10+Codex, Claude Desktop, ChatGPT, editors) can read and change the11+workbooks in the folders open to it. One server works on every12+workbook: each tool takes the workbook's `path`. It is the same code as13+[the commands](README.md#with-a-shell): reads are `012 describe` and14+`012 get`, and every write is typed through the checks `012 set` makes,15+as one change, saved atomically at once.1516 1617 ```mermaid1718 flowchart LR18- host[MCP host] -- "stdio: tools, resources, prompts" --> server["012 mcp book.012"]19- server -- "open, read" --> file[(book.012)]19+ host[MCP host] -- "stdio: tools with a path, resources, prompts" --> server["012 mcp"]20+ server -- "open, read" --> file[("budget.012, q3/plan.012, data.csv")]2021 server -- "one change, Batch, atomic save" --> file21- person["012 book.012, 012 set, git"] --> file22+ person["012 budget.012, 012 set, git"] --> file2223 ```2324 24-The file is opened afresh for every call, so what the screen, a script25-or git saved meanwhile is what the next call sees. A file that doesn't26-exist is an empty workbook, saved on the first change.25+A file is opened afresh for every call, so what the screen, a script26+or git saved meanwhile is what the next call sees.2727 2828 ## Adding it to a host2929 30-Claude Code, for one workbook (`--scope project` writes `.mcp.json`31-to commit with the project; the default keeps it to you):30+`012 agent --install-mcp` writes the entry that starts this 012, by its31+absolute path and with no workbook, into the host's configuration:3232 3333 ```sh34-claude mcp add budget -- 012 mcp /path/to/budget.01235-claude mcp add --scope project budget -- 012 mcp budget.01234+012 agent --install-mcp claude-code # Claude Code: ~/.claude.json, as claude mcp add --scope user does35+012 agent --install-mcp codex # Codex: [mcp_servers.012] in ~/.codex/config.toml36+012 agent --install-mcp claude-desktop # Claude Desktop: claude_desktop_config.json37+012 agent --install-mcp codex --print # show the entry, write nothing38+012 agent --install-mcp claude-desktop --root ~/Documents # open these folders to it3639 ```3740 38-Claude Desktop, in `claude_desktop_config.json` (Settings > Developer >39-Edit Config), with the full paths, since Desktop doesn't start in your40-folder:41-42-```json43-{44- "mcpServers": {45- "budget": {46- "command": "/usr/local/bin/012",47- "args": ["mcp", "/Users/me/Documents/budget.012"]48- }49- }50-}51-```52-53-Other hosts take the same command and arguments.41+The file is copied beside itself (`config.toml.bak-20260929-150405`)42+before it changes, the host's other servers and settings stay as they43+were, and running it again when the entry is there changes nothing.44+It names other entries that start 012, such as one made earlier for a45+single workbook, which the new entry replaces. Restart the host to46+start the server; install again after moving 012.47+48+Other hosts take the command and arguments `--print` shows. A file49+named after `mcp` (`012 mcp budget.012`) is the workbook of tools50+called without a path, open wherever it is.51+52+## Which files it opens53+54+A `path` is confined to the folders the host shares as its roots55+(Claude Code shares the project's), or when it shares none, to the56+folders `--root` names (any number of them), or else the working57+directory, or the home folder when that's the file system's root, as58+where Claude Desktop starts servers. A relative path is looked for in59+each folder in turn; an absolute one must be inside one. Hidden files60+and folders and links that lead outside are refused, as61+[`012 serve`](../terminal/ssh.md#security-model) refuses them. Roots are deprecated in62+the protocol's 2026-07-28 version, whose clients the server can't ask,63+so they get `--root`'s folders.64+65+`describe` without a path lists the workbooks in those folders: `.012`66+files, and the files 012 [imports](../files/README.md#import-and-download) (CSV, XLSX,67+JSON, SQLite and the rest), four folders deep, without hidden folders68+or `node_modules`. Imported files are read as the app opens them, and69+are read only: `create_workbook` with `from` makes a `.012` workbook70+of one, to change. A path that doesn't exist is an error naming71+`create_workbook`, which makes a new workbook and never replaces a file.5472 5573 ## Flags5674 5775 | Flag | Does |5876 |---|---|77+| `file.012` | The workbook of tools called without a path |78+| `--root dir` | A folder workbooks may be in, when the host shares no roots; give it again for more |5979 | `--read-only` | Offers only the tools that read |6080 | `--force` | Lets writes change [protected ranges](../sheets/notes-protection.md#protected-sheets-and-ranges), as `012 set --force` |6181 | `--notebooks` | Offers `run_notebook_cell`, which runs a [notebook](../nushell/notebooks.md) cell with nu, if the workbook was saved on this computer |
@@ -69,18 +89,21 @@ notebook cells from running even with `--notebooks`.
6989 7090 ## Tools7191 72-References are written as in formulas (`B7`, `Q3!A1:C9`,73-`'Q3 plan'!A:A`, a named range, a table, `Sales[Amount]`, a sheet's74-name), and inputs as a person types them, in en-US form. Every write75-takes `dry_run`, which returns the change without making it.92+Every tool but `describe` takes `path`, the workbook, required unless93+the server was started with a file. References are written as in94+formulas (`B7`, `Q3!A1:C9`, `'Q3 plan'!A:A`, a named range, a table,95+`Sales[Amount]`, a sheet's name), and inputs as a person types them, in96+en-US form. Every write takes `dry_run`, which returns the change97+without making it.7698 7799 | Tool | Does |78100 |---|---|79-| `describe` | The workbook's sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |101+| `describe` | Without a path, the workbooks open to the server; with one, its sheets, used ranges, guessed header rows and column names, tables, outputs, charts, pivots, notebook cells and names, as [`012 describe`](../reference/json.md#describe) |80102 | `read_range` | A range's values as rows, its formulas by cell, and with `text` each cell as shown |81103 | `evaluate` | A formula's value, computed in the workbook (at `at`, or below the data) without writing it: its text, why it's an error, an array's spill |82104 | `find` | Cells by what they show or, with `in_formulas`, their formulas' text, as Edit > Find |83105 | `list_errors` | The formulas showing errors after recalculating, as `012 recalc` lists them, without saving |106+| `create_workbook` | A new `.012` workbook at `path`, empty or made `from` a file 012 imports or another workbook; it never replaces a file |84107 | `write_cells` | Entries typed into cells as one change, as `012 set` |85108 | `apply_operations` | Several operations as one change: `set`, `clear`, `insert_rows`, `delete_rows`, `insert_columns`, `delete_columns`, `add_sheet`, `rename_sheet`, `delete_sheet`, `define_name`, `sort` |86109 | `sort` | A range's rows sorted by columns, named by letter or header |
@@ -96,40 +119,57 @@ with nothing changed. Every result's schema is in
96119 97120 ## Views in the chat98121 99-Hosts that support [MCP Apps](https://github.com/modelcontextprotocol/ext-apps)100-(the `io.modelcontextprotocol/ui` extension) draw what `read_range`101-read and the chart `create_chart` made beside the result, as 012 draws102-them: the [web page](../files/README.md#web-pages) export's grid, with103-its fonts, colors, styles and charts as SVG, in the host's light or104-dark theme. Clicking a cell, or the arrow keys, puts the pointer on it105-and shows what was typed in it.106-107-Both tools name the view's resource, `ui://012/view`108-(`text/html;profile=mcp-app`), in their `_meta`; the host loads it into109-a sandboxed frame, and the result's `_meta` carries what to draw, at110-most 500 rows of a range. Hosts without the extension get the same111-results without it, and the page asks for nothing from the network.122+Hosts that draw tool results as pages show what `read_range` read and123+the chart `create_chart` made beside the result, as 012 draws them: the124+[web page](../files/README.md#web-pages) export's grid with its column125+and row headers, fonts, number formats, styles and charts as SVG, in126+the host's light or dark theme. Clicking a cell, or the arrow keys,127+puts the pointer on it and shows what was typed in it in the formula128+bar. The grid scrolls under its headers in at most 480 pixels inline129+(less when the host has less room, the whole frame full screen), and a130+smaller range takes only its own height.131+132+Two kinds of hosts do this, and the view works in both:133+134+| Host | Finds the page by | Its page | The page reads |135+|---|---|---|---|136+| [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) (Claude, and the extension's other hosts) | the tool's `_meta.ui.resourceUri` | `ui://012/view`, `text/html;profile=mcp-app` | the `ui/initialize` handshake's theme and room, then `ui/notifications/tool-result` |137+| The [OpenAI Apps SDK](https://developers.openai.com/apps-sdk) (ChatGPT, Codex) | the tool's `_meta["openai/outputTemplate"]` | `ui://012/view.skybridge`, `text/html+skybridge` | `window.openai`: `toolResponseMetadata`, `theme`, `maxHeight`, `displayMode`, and `openai:set_globals` events |138+139+What the page draws is in the result's `_meta["o12/view"]`, for every140+client, whether or not it declared the extension: the workbook's name,141+where the range or chart is, and its HTML, at most 200 rows of what142+`read_range` returned. Both kinds of hosts hand a result's `_meta` to143+the page (the Apps SDK as `window.openai.toolResponseMetadata`) and144+keep it from the model, which reads the result itself,145+`structuredContent` and the same JSON as text, with no HTML: a few146+kilobytes for 60 rows. The page asks for nothing from the network and147+calls no tools.112148 113149 ## Resources114150 115151 Hosts that let people attach data to a conversation list these; the116-scheme is `o12` since a URI's scheme starts with a letter, and a sheet's117-name is percent-encoded:152+scheme is `o12` since a URI's scheme starts with a letter. The153+workbook is named as tools name it (relative to the first folder open154+to the server, or absolute, `o12:///Users/me/budget.012/...`), each155+part of its path and a sheet's name percent-encoded:118156 119157 | URI | Is |120158 |---|---|121-| `o12://book/Sheet1!A1:D40` | A range, as `read_range` returns it; each sheet's used range is listed |122-| `o12://book/table/Sales` | A table, header row included; each table is listed |123-| `o12://book/notebook/Notes/2` | A notebook cell's source, and its output as NUON or why it failed; each code cell is listed |159+| `o12://budget.012/Sheet1!A1:D40` | A range, as `read_range` returns it; each sheet's used range is listed |160+| `o12://budget.012/table/Sales` | A table, header row included; each table is listed |161+| `o12://budget.012/notebook/Notes/2` | A notebook cell's source, and its output as NUON or why it failed; each code cell is listed |124162 125-The list follows the workbook: a change that adds a sheet or table126-updates it, and hosts that listen are told.163+The list holds the five workbooks the tools used last (and the file164+named on the command line) and follows them: a change that adds a sheet165+or table updates it, and hosts that listen are told. The templates166+reach any range of any workbook open to the server.127167 128168 ## Prompts129169 130170 | Prompt | Asks |131171 |---|---|132-| `summarize_workbook` | What each sheet holds and what its numbers say, without changing anything |133-| `fix_errors` | The formulas showing errors explained, fixes checked with `evaluate` and previewed before they're made |134-| `add_column` | A column of formulas computing something beside a range (`range`, `column`) |135-| `chart` | The chart that best answers a question about a range (`range`, `question`) |172+| `summarize_workbook` | What each sheet of a workbook (`path`) holds and what its numbers say, without changing anything |173+| `fix_errors` | The formulas showing errors in a workbook (`path`) explained, fixes checked with `evaluate` and previewed before they're made |174+| `add_column` | A column of formulas computing something beside a range (`path`, `range`, `column`) |175+| `chart` | The chart that best answers a question about a range (`path`, `range`, `question`) |
M docs/contributing/testing.md +16 -0
M docs/reference/command-line.md +3 -2
@@ -29,12 +29,13 @@ The flags are the options' flags, listed in [Configuration](config.md#options).
2929 | `012 recalc file.012` | Recalculates, saves and lists the cells showing errors: [Scripts](../files/scripts.md#recalc) |3030 | `012 export file.012 out [ref]` | Writes the workbook, a sheet or a range in another format: [Scripts](../files/scripts.md#export) |3131 | `012 describe file.012` | Lists the sheets and what's on them: [Agents](../agents/README.md#with-a-shell) |32-| `012 mcp file.012` | Serves the workbook to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |32+| `012 mcp [file.012]` | Serves the workbooks in the folders open to it to MCP hosts over standard input and output: [MCP server](../agents/mcp.md) |3333 | `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |3434 | `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |3535 | `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |3636 | `012 nu --module`, `012 nu --install-module [--force] [path]` | Prints or installs the nushell module: [Nushell](../nushell/README.md#install-the-sheet-command) |3737 | `012 agent --skill`, `012 agent --install-skill [--force] [dir]` | Prints or installs the Claude Code skill: [Agents](../agents/README.md#the-claude-code-skill) |38+| `012 agent --install-mcp claude-code\|claude-desktop\|codex [--root dir]... [--print]` | Adds `012 mcp` to a host's configuration, or prints the entry: [MCP server](../agents/mcp.md#adding-it-to-a-host) |3839 | `012 config [path\|edit\|default\|themes\|set-key\|delete-key]` | The settings: [Configuration](config.md#commands) |3940 | `012 version [--format text\|json\|nuon]` | Prints the version |4041
@@ -57,7 +58,7 @@ flag.
5758 | `--chart n\|title` | `export` | One chart of the sheet as a web page, by its number or title |5859 | `--notebooks` | `get`, `recalc`, `export`, `describe` | Runs the notebooks' cells first, when the file is trusted here |5960 | `--trust` | with `--notebooks` | Runs the cells of a file saved on another computer |60-| `--read-only`, `--force`, `--notebooks`, `--jev` | `mcp` | The tools offered and what they may do: [MCP server](../agents/mcp.md#flags) |61+| `--root dir`, `--read-only`, `--force`, `--notebooks`, `--jev` | `mcp` | The folders open to it, the tools offered and what they may do: [MCP server](../agents/mcp.md#flags) |6162 | `--jev` | `get`, `recalc`, `export`, `describe` | Asks JEV for the JEV functions' answers first |6263 | `--format text\|json\|nuon` | `diff` | The form of the changes; text by default |6364 | `--color auto\|always\|never` | `diff` | Colors the text; auto colors on a terminal or git's pager unless `NO_COLOR` is set |
M docs/reference/json.md +23 -6
@@ -129,9 +129,23 @@ are as without it.
129129 ## MCP tools130130 131131 The [MCP server](../agents/mcp.md)'s tools return these, as structured132-content with the same JSON as text beside it. `describe` returns133-[describe's](#describe) schema, `list_errors` recalc's `errors` and134-`circular`.132+content with the same JSON as text beside it. `describe` with a path returns `path` and133+[describe's](#describe) schema, and so does `create_workbook`, of the134+workbook it made; without a path, `describe` returns the folders open135+to the server and the workbooks in them, `more` set when there were136+more than it lists:137+138+```json139+{140+ "roots": ["/Users/me/Documents/budgets"],141+ "workbooks": [142+ {"path": "2026.012", "format": "012", "size": 18422, "modified": "2026-09-29T14:02:11Z"},143+ {"path": "bank/september.csv", "format": "CSV", "size": 5120, "modified": "2026-09-28T09:30:00Z"}144+ ]145+}146+```147+148+`list_errors` returns recalc's `errors` and `circular`.135149 136150 `read_range`, and the range resources:137151
@@ -148,13 +162,16 @@ content with the same JSON as text beside it. `describe` returns
148162 `values` are typed as [`get`](#get) types one cell (blank is `null`, an149163 error its text); `text` comes only when asked for; `truncated` is set150164 when the range held more cells than `max_cells`, and `rows` says how151-many came back.165+many came back. The result's `_meta["o12/view"]` holds what166+[the view](../agents/mcp.md#views-in-the-chat) draws: `title` (the167+workbook's name), `where` and `html`.152168 153169 Writes (`write_cells`, `apply_operations`, `sort`, `filter`) return154170 `{"saved", "changes", "warnings"}` as [`set`](#set) does, `saved` false155171 for a dry run or a change that changed nothing; `create_chart` adds156-`"chart": {"sheet", "number", "title"}` and `create_pivot` `"sheet"`,157-the pivot's.172+`"chart": {"sheet", "number", "title"}`, its view in `_meta` as173+`read_range`'s (with `name`, the chart's data), and `create_pivot`174+`"sheet"`, the pivot's.158175 159176 | Tool | Returns |160177 |---|---|
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/prompts.go +23 -10
@@ -15,44 +15,57 @@ var prompts = []struct {
1515 text string // with {arg} for each argument1616 }{{1717 prompt: &sdk.Prompt{Name: "summarize_workbook", Title: "Summarize the workbook",18- Description: "What each sheet holds and what its numbers say"},19- text: "Call describe, then read the main ranges it points at with read_range (a few hundred rows at most). " +18+ Description: "What each sheet holds and what its numbers say", Arguments: []*sdk.PromptArgument{pathArg}},19+ text: "Call describe on {path}, then read the main ranges it points at with read_range (a few hundred rows at most). " +2020 "Summarize what each sheet holds, how the sheets relate (formulas between them, tables, pivots, charts), and what the numbers say. Don't change anything.",2121 }, {2222 prompt: &sdk.Prompt{Name: "fix_errors", Title: "Fix formula errors",23- Description: "Find the formulas showing errors, explain them and fix them"},24- text: "Call list_errors. For each formula showing an error, read the cells it reads, and explain the cause. " +23+ Description: "Find the formulas showing errors, explain them and fix them", Arguments: []*sdk.PromptArgument{pathArg}},24+ text: "Call list_errors on {path}. For each formula showing an error, read the cells it reads, and explain the cause. " +2525 "Propose a fix, check it with evaluate at the same cell, and show it with write_cells and dry_run. Make the fixes once they're agreed.",2626 }, {2727 prompt: &sdk.Prompt{Name: "add_column", Title: "Add a computed column",2828 Description: "A new column of formulas beside a table of data",29- Arguments: []*sdk.PromptArgument{29+ Arguments: []*sdk.PromptArgument{pathArg,3030 {Name: "range", Description: "the data, e.g. Sales!A1:F99, or a table's name", Required: true},3131 {Name: "column", Description: "what the new column computes, e.g. profit as revenue minus cost", Required: true},3232 }},33- text: "Add a column to {range} that computes {column}. Call describe and read_range to find the header row and the columns involved. " +33+ text: "Add a column to {range} in {path} that computes {column}. Call describe and read_range to find the header row and the columns involved. " +3434 "Write the header, then the formula for the first data row: check it with evaluate at that cell, then write every row's formula with write_cells (dry_run first), relative references moving with the row. " +3535 "Follow the columns' number formats. Finish with list_errors.",3636 }, {3737 prompt: &sdk.Prompt{Name: "chart", Title: "Chart some data",3838 Description: "A chart that answers a question about a range",39- Arguments: []*sdk.PromptArgument{39+ Arguments: []*sdk.PromptArgument{pathArg,4040 {Name: "range", Description: "the data, e.g. Sales!A1:C13", Required: true},4141 {Name: "question", Description: "what the chart should show, e.g. how sales grew by month"},4242 }},43- text: "Read {range} with read_range and pick the chart that best shows {question}: line or area for change over time, column or bar to compare, pie for shares of a whole, scatter for two measures. " +43+ text: "Read {range} in {path} with read_range and pick the chart that best shows {question}: line or area for change over time, column or bar to compare, pie for shares of a whole, scatter for two measures. " +4444 "If the data needs summarizing first, use create_pivot and chart its result. Create it with create_chart, with a title that says what it shows.",4545 }}4646 47+// pathArg is every prompt's workbook; without it the model finds one.48+var pathArg = &sdk.PromptArgument{Name: "path", Description: "the workbook, e.g. budget.012; describe without a path lists them"}49+50+// unset are what a prompt says for the arguments not given.51+var unset = map[string]string{52+ "{question}": "the data's main point",53+ "{path}": "the workbook (describe without a path lists them)",54+}55+4756 func (s *Server) addPrompts() {4857 for _, p := range prompts {4958 text := p.text5059 s.AddPrompt(p.prompt, func(_ context.Context, req *sdk.GetPromptRequest) (*sdk.GetPromptResult, error) {5160 msg := text5261 for k, v := range req.Params.Arguments {53- msg = strings.ReplaceAll(msg, "{"+k+"}", v)62+ if v != "" {63+ msg = strings.ReplaceAll(msg, "{"+k+"}", v)64+ }65+ }66+ for k, v := range unset {67+ msg = strings.ReplaceAll(msg, k, v)5468 }55- msg = strings.ReplaceAll(msg, "{question}", "the data's main point")5669 return &sdk.GetPromptResult{Description: req.Params.Name,5770 Messages: []*sdk.PromptMessage{{Role: "user", Content: &sdk.TextContent{Text: msg}}}}, nil5871 })
M internal/mcp/read.go +102 -26
@@ -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,44 @@ 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+2240 type evaluateIn struct {41+ bookIn2342 Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`2443 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"`2544 }2645 2746 type findIn struct {47+ bookIn2848 Query string `json:"query" jsonschema:"what to look for"`2949 headless.FindOptions3050 }3151 32-type errorsIn struct{}52+type errorsIn struct{ bookIn }3353 3454 // errorsOut lists the formulas showing errors.3555 type errorsOut struct {
@@ -42,22 +62,17 @@ var readOnly = &sdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, Op
4262 func ptr[T any](v T) *T { return &v }4363 4464 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."},65+ tool(s, &sdk.Tool{Name: "describe", Annotations: readOnly, OutputSchema: describeSchema(),66+ Description: "Without a path: the workbooks (.012 files, and files 012 imports) in the folders open to the server. With one: what the workbook holds, each sheet's used range, guessed header row and column names, tables, notebook outputs and linked files, charts, pivot tables, notebook cells, and the named ranges. Call it first."},67+ s.describe)68+ tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: viewMeta("Reading the range", "Read the range"),69+ Description: "The values of a range as rows (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."},5470 s.readRange)5571 tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,5672 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) {73+ func(ctx context.Context, req *sdk.CallToolRequest, in evaluateIn) (*sdk.CallToolResult, headless.Evaluated, error) {5874 var out headless.Evaluated59- err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {60- var err error75+ err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) (err error) {6176 out, err = headless.Evaluate(w, in.Formula, in.At)6277 return err6378 })
@@ -65,10 +80,9 @@ func (s *Server) addReadTools() {
6580 })6681 tool(s, &sdk.Tool{Name: "find", Annotations: readOnly,6782 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) {83+ func(ctx context.Context, req *sdk.CallToolRequest, in findIn) (*sdk.CallToolResult, headless.Found, error) {6984 var out headless.Found70- err := s.b.View(ctx, func(w *sheet.Workbook) error {71- var err error85+ err := s.view(ctx, req, in.Path, func(_ *book, w *sheet.Workbook) (err error) {7286 out, err = headless.Find(w, in.Query, in.FindOptions)7387 return err7488 })
@@ -76,9 +90,9 @@ func (s *Server) addReadTools() {
7690 })7791 tool(s, &sdk.Tool{Name: "list_errors", Annotations: readOnly,7892 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) {93+ func(ctx context.Context, req *sdk.CallToolRequest, in errorsIn) (*sdk.CallToolResult, errorsOut, error) {8094 var out errorsOut81- err := s.b.Scratch(ctx, func(w *sheet.Workbook) error {95+ err := s.scratch(ctx, req, in.Path, func(w *sheet.Workbook) error {8296 problems, circular := headless.Recalc(w)8397 out = errorsOut{Errors: headless.Problems(problems), Circular: circular}8498 return nil
@@ -87,17 +101,79 @@ func (s *Server) addReadTools() {
87101 })88102 }89103 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 {104+// describeSchema is describeOut's, whose workbook's sheets and names105+// are there only with a path.106+func describeSchema() *jsonschema.Schema {107+ schema, err := jsonschema.For[describeOut](rawSchemas)108+ if err != nil {109+ panic(err)110+ }111+ schema.Required = slices.DeleteFunc(schema.Required, func(p string) bool { return p == "sheets" || p == "names" })112+ return schema113+}114+115+func (s *Server) describe(ctx context.Context, req *sdk.CallToolRequest, in describeIn) (*sdk.CallToolResult, describeOut, error) {116+ if in.Path == "" && s.opts.Default == "" {117+ roots := s.roots(ctx, req.Session)118+ files, more := s.listFiles(roots)119+ return nil, describeOut{Roots: rootDirs(roots), Workbooks: files, More: more}, nil120+ }121+ var out describeOut122+ err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {123+ d := headless.Describe(w)124+ out = describeOut{Path: b.name, Description: &d}125+ return nil126+ })127+ return nil, out, err128+}129+130+func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, RangeRead, error) {131+ var out RangeRead132+ var view *View133+ err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {94134 t, err := headless.Resolve(w, in.Ref)95135 if err != nil {96136 return err97137 }98138 out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})99- res = s.rangeView(req, t)139+ view = rangeView(b, t, out.Rows)100140 return nil101141 })102- return res, out, err142+ if err != nil {143+ return nil, RangeRead{}, err144+ }145+ return viewResult(view), out, nil146+}147+148+// view runs fn on the workbook path names, as it is.149+func (s *Server) view(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*book, *sheet.Workbook) error) error {150+ b, err := s.open(ctx, req, path)151+ if err != nil {152+ return err153+ }154+ return b.View(ctx, func(w *sheet.Workbook) error { return fn(b, w) })155+}156+157+// scratch runs fn on a copy of the workbook path names, thrown away.158+func (s *Server) scratch(ctx context.Context, req *sdk.CallToolRequest, path string, fn func(*sheet.Workbook) error) error {159+ b, err := s.open(ctx, req, path)160+ if err != nil {161+ return err162+ }163+ return b.Scratch(ctx, fn)164+}165+166+// open is the workbook path names for req's client, its resources167+// listed from now on.168+func (s *Server) open(ctx context.Context, req *sdk.CallToolRequest, path string) (*book, error) {169+ var ss *sdk.ServerSession170+ if req != nil {171+ ss = req.Session172+ }173+ b, err := s.book(ctx, ss, path)174+ if err != nil {175+ return nil, err176+ }177+ s.used(ctx, b)178+ return b, nil103179 }
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 +34 -22
@@ -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 := ""
@@ -290,7 +302,7 @@ func TestResourcesAndPrompts(t *testing.T) {
290302 if err != nil {291303 t.Fatal(err)292304 }293- if text := res.Messages[0].Content.(*sdk.TextContent).Text; !strings.Contains(text, "Add a column to Sheet1!A1:C4 that computes revenue") {305+ if text := res.Messages[0].Content.(*sdk.TextContent).Text; !strings.Contains(text, "Add a column to Sheet1!A1:C4 in the workbook (describe without a path lists them) that computes revenue") {294306 t.Errorf("prompt: %s", text)295307 }296308 }
@@ -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 +161 -0
@@ -0,0 +1,161 @@
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, toolOutput and toolResponseMetadata null at6+// first and then set by an openai:set_globals event, as ChatGPT does7+// when the result arrives;8+// notifyIntrinsicHeight resizes the frame.9+// - mcp-apps: the MCP Apps way. The host answers ui/initialize with its10+// context, sends ui/notifications/tool-input and tool-result once the11+// view says it's initialized, and sizes the frame as12+// ui/notifications/size-changed says.13+//14+// Usage: node view.mjs cases.json [screenshot folder]. It exits 0 when15+// every check passes, 3 when there's no browser to run, 1 otherwise.16+import { readFileSync } from "node:fs";17+import { createRequire } from "node:module";18+import { join } from "node:path";19+20+const require = createRequire(import.meta.url);21+const { chromium } = require("playwright");22+const [, , casesFile, shots] = process.argv;23+const cases = JSON.parse(readFileSync(casesFile, "utf8"));24+const failures = [];25+const check = (ok, what) => { if (!ok) failures.push(what); };26+27+let browser;28+try {29+ browser = await chromium.launch();30+} catch (e) {31+ console.error(String(e).split("\n")[0]);32+ process.exit(3);33+}34+35+// js is v as a script's literal, safe inside a script element.36+const js = (v) => JSON.stringify(v).replace(/</g, "\\u003c");37+38+// openaiDoc is the view's page with window.openai set first.39+function openaiDoc(page, args, theme) {40+ const boot = `<script>41+window.openai={theme:${js(theme)},displayMode:"inline",maxHeight:520,locale:"en-US",42+ toolInput:${js(args)},toolOutput:null,toolResponseMetadata:null,widgetState:null,43+ setWidgetState:function(){return Promise.resolve()},callTool:function(){return Promise.reject(new Error("no tools here"))},44+ notifyIntrinsicHeight:function(h){parent.postMessage({harness:"height",height:h},"*")}};45+</script>`;46+ return page.replace("<head>", "<head>" + boot);47+}48+49+// hostPage is the page around the frame: it relays the frame's size and,50+// as an MCP Apps host, speaks the extension's messages.51+function hostPage(mode, doc, c, theme) {52+ const hc = { theme, displayMode: "inline", containerDimensions: { maxHeight: 520 }, locale: "en-US", platform: "web" };53+ return `<!doctype html><html><body style="margin:24px;background:${theme === "dark" ? "#111" : "#eee"}">54+<iframe id="f" sandbox="allow-scripts" style="width:720px;height:150px;border:1px solid #888;display:block"></iframe>55+<script>56+var f=document.getElementById("f"),seen=[];window.seen=seen;57+window.addEventListener("message",function(e){58+ if(e.source!==f.contentWindow)return;var m=e.data;seen.push(m);59+ if(m.harness==="height"){f.style.height=m.height+"px";return}60+ if(${js(mode)}!=="mcp-apps"||!m||m.jsonrpc!=="2.0")return;61+ function post(x){f.contentWindow.postMessage(Object.assign({jsonrpc:"2.0"},x),"*")}62+ if(m.method==="ui/initialize")post({id:m.id,result:{protocolVersion:"2026-01-26",hostInfo:{name:"harness",version:"1"},hostCapabilities:{},hostContext:${js(hc)}}});63+ if(m.method==="ui/notifications/initialized"){post({method:"ui/notifications/tool-input",params:{arguments:${js(c.args)}}});64+ post({method:"ui/notifications/tool-result",params:${js(c.result)}})}65+ if(m.method==="ui/notifications/size-changed")f.style.height=m.params.height+"px";66+});67+f.srcdoc=${js(doc)};68+</script></body></html>`;69+}70+71+async function run(mode, name, c, theme) {72+ const page = await browser.newPage({ viewport: { width: 800, height: 700 } });73+ const errors = [];74+ page.on("pageerror", (e) => errors.push(String(e)));75+ const doc = mode === "openai" ? openaiDoc(cases.page, c.args, theme) : cases.page;76+ await page.setContent(hostPage(mode, doc, c, theme));77+ const frame = page.frameLocator("#f");78+ if (mode === "openai") {79+ await frame.locator("#o12-view").waitFor({ state: "attached" });80+ await page.waitForTimeout(100);81+ await frame.locator("body").evaluate((_, r) => {82+ window.openai.toolOutput = r.structuredContent;83+ window.openai.toolResponseMetadata = r._meta;84+ const globals = { toolOutput: r.structuredContent, toolResponseMetadata: r._meta };85+ window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals } }));86+ }, c.result);87+ }88+ const what = `${mode} ${name}`;89+ try {90+ await frame.locator("#o12-view > *").first().waitFor({ timeout: 5000 });91+ } catch {92+ failures.push(`${what}: nothing drawn`);93+ await page.close();94+ return;95+ }96+ await page.waitForTimeout(300);97+ const v = c.result._meta["o12/view"];98+ const state = () => frame.locator("body").evaluate(() => {99+ const sh = document.querySelector("#o12-view .sheet");100+ return {101+ book: document.querySelector(".panel .book").textContent,102+ name: document.getElementById("o12-name").textContent,103+ input: document.getElementById("o12-input").textContent,104+ where: document.getElementById("o12-context").textContent,105+ theme: document.documentElement.className,106+ height: Math.ceil(document.documentElement.getBoundingClientRect().height),107+ scroll: sh ? { client: sh.clientHeight, full: sh.scrollHeight } : null,108+ };109+ });110+ let s = await state();111+ const frameHeight = await page.locator("#f").evaluate((f) => f.getBoundingClientRect().height);112+ check(s.book === v.title && s.where === v.where, `${what}: panel says ${s.book} / ${s.where}`);113+ check(s.theme === theme, `${what}: theme ${s.theme}, want ${theme}`);114+ check(Math.abs(frameHeight - s.height - 2) <= 2, `${what}: frame ${frameHeight}px for ${s.height}px of view`);115+ check(s.height <= 520, `${what}: ${s.height}px, more than the host's room`);116+ check(errors.length === 0, `${what}: script errors ${errors}`);117+ if (c.grid) {118+ check(s.name === "A1" && s.input === c.grid.a1, `${what}: pointer on ${s.name} showing ${s.input}`);119+ await frame.locator(`td[data-a="${c.grid.cell}"]`).click();120+ s = await state();121+ check(s.name === c.grid.cell && s.input === c.grid.input, `${what}: clicking ${c.grid.cell} shows ${s.name} ${s.input}`);122+ await page.keyboard.press("ArrowDown");123+ s = await state();124+ check(s.name === c.grid.below, `${what}: the arrow key moved to ${s.name}`);125+ check(s.scroll && s.scroll.full > s.scroll.client, `${what}: the grid doesn't scroll (${JSON.stringify(s.scroll)})`);126+ const header = await frame.locator("#o12-view .sheet").evaluate((sh) => {127+ sh.scrollTop = 400;128+ const th = sh.querySelector("thead th"), top = sh.getBoundingClientRect().top;129+ return { scrolled: sh.scrollTop, headerAt: Math.round(th.getBoundingClientRect().top - top) };130+ });131+ check(header.scrolled > 0 && header.headerAt === 0, `${what}: scrolled ${header.scrolled}, column header ${header.headerAt}px down`);132+ await frame.locator("#o12-view .sheet").evaluate((sh) => { sh.scrollTop = 0; });133+ const style = await frame.locator(`td[data-a="${c.grid.cell}"]`).evaluate((td) => getComputedStyle(td).textAlign);134+ check(style === "right", `${what}: a number is aligned ${style}`);135+ } else {136+ check(await frame.locator("#o12-view svg").count() === 1, `${what}: no chart drawn`);137+ check(s.name === v.name, `${what}: name box ${s.name}`);138+ }139+ if (mode === "mcp-apps") {140+ const seen = await page.evaluate(() => window.seen.map((m) => m.method || (m.id ? "response" : m.harness)));141+ check(seen[0] === "ui/initialize" && seen.includes("ui/notifications/initialized") && seen.includes("ui/notifications/size-changed"),142+ `${what}: messages ${seen}`);143+ }144+ if (shots) {145+ await page.screenshot({ path: join(shots, `${mode}-${name}-${theme}.png`) });146+ }147+ await page.close();148+}149+150+for (const mode of ["openai", "mcp-apps"]) {151+ for (const [name, c] of Object.entries(cases.cases)) {152+ for (const theme of ["light", "dark"]) {153+ await run(mode, name, c, theme);154+ }155+ }156+}157+await browser.close();158+if (failures.length) {159+ console.error(failures.join("\n"));160+ process.exit(1);161+}
M internal/mcp/views.go +92 -78
@@ -10,110 +10,124 @@ import (
1010 "github.com/FelineStateMachine/012/internal/sheet"1111 )1212 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.13+// Views are the range read_range read and the chart create_chart made,14+// drawn as 012 draws them in a sandboxed frame beside the result, by15+// hosts that implement either of the two ways of doing that:16+//17+// - MCP Apps (the io.modelcontextprotocol/ui extension, spec18+// 2026-01-26): the tool's _meta.ui.resourceUri names ui://012/view,19+// text/html;profile=mcp-app, and the page speaks the extension's20+// messages over postMessage (ui/initialize, the tool's result, the21+// host's theme and room, the page's size).22+// - The OpenAI Apps SDK (ChatGPT, Codex): the tool's23+// _meta["openai/outputTemplate"] names ui://012/view.skybridge, the24+// same page as text/html+skybridge, which reads window.openai25+// (toolResponseMetadata, theme, maxHeight, displayMode) instead.26+//27+// The page is the HTML export's with nothing in it yet (fonts,28+// palettes, the pointer's script). What it draws is in the result's29+// _meta under viewKey, for every client, since both kinds of hosts hand30+// a result's _meta to the view and neither shows it to the model: its31+// title, where it is and the grid's or chart's HTML. structuredContent32+// stays the tool's result, what the model reads.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+ viewKey = "o12/view"39+ skybridgeURI = "ui://012/view.skybridge"2740 // maxViewRows bounds the rows a range's view draws.28- maxViewRows = 50041+ maxViewRows = 2002942 )3043 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- })44+// View is what a view draws of a result, in its _meta under viewKey:45+// the workbook's name, what the name box shows before a cell is46+// pointed at (a chart's data), the range or the chart's kind, sheet and47+// data, and the grid or chart as the HTML export draws it.48+type View struct {49+ Title string `json:"title"`50+ Name string `json:"name,omitempty"`51+ Where string `json:"where"`52+ HTML string `json:"html"`3953 }4054 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}55+// viewResourceMeta says how hosts should frame the view, in both56+// dialects: a border, and no network (an empty content security57+// policy's lists).58+var viewResourceMeta = sdk.Meta{59+ "ui": map[string]any{"prefersBorder": true, "csp": map[string]any{}},60+ "openai/widgetPrefersBorder": true,61+ "openai/widgetCSP": map[string]any{"connect_domains": []string{}, "resource_domains": []string{}},62+ "openai/widgetDescription": "The range or chart the tool returned, drawn as 012's grid: the model need not repeat it.",4563 }4664 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 false65+func (s *Server) addViews() {66+ for _, r := range []struct{ uri, mime string }{{viewURI, uiMIME}, {skybridgeURI, skybridgeMIME}} {67+ s.AddResource(&sdk.Resource{URI: r.uri, Name: "012 view", Title: "012", MIMEType: r.mime, Meta: viewResourceMeta,68+ Description: "The range or chart a tool returned, drawn as 012 draws it"},69+ func(context.Context, *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {70+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: r.uri, MIMEType: r.mime, Text: viewPage(), Meta: viewResourceMeta}}}, nil71+ })5572 }56- _, ok := p.Capabilities.Extensions[uiExtension]57- return ok5873 }5974 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}}}75+// viewPage is the view's page: the panel, an empty place for the view76+// and the scripts.77+func viewPage() string {78+ return fileio.HTMLDocument("012", "<style>"+viewCSS+"</style>"+fileio.HTMLPanel("012", "", "")+`<div id="o12-view"></div>`, viewScript)79+}80+81+// viewMeta is what a tool drawn in the view says of it: the resource82+// under MCP Apps' key and its first drafts' flat one, and the Apps83+// SDK's template and the status lines it shows while the tool runs.84+// The view calls no tools, so openai/widgetAccessible stays false.85+func viewMeta(invoking, invoked string) sdk.Meta {86+ return sdk.Meta{"ui": map[string]any{"resourceUri": viewURI}, "ui/resourceUri": viewURI,87+ "openai/outputTemplate": skybridgeURI, "openai/toolInvocation/invoking": invoking, "openai/toolInvocation/invoked": invoked}6388 }6489 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) {90+// viewResult is a result carrying v in its _meta, nil without one.91+func viewResult(v *View) *sdk.CallToolResult {92+ if v == nil {6893 return nil6994 }95+ return &sdk.CallToolResult{Meta: sdk.Meta{viewKey: v}}96+}97+98+// rangeView draws the rows read_range returned of t, at most99+// maxViewRows.100+func rangeView(b *book, t headless.Target, rows int) *View {70101 r := t.Range71102 if t.Whole {72- r, _ = t.Sheet.UsedRange()103+ var ok bool104+ if r, ok = t.Sheet.UsedRange(); !ok {105+ r = sheet.Rect{}106+ }73107 }74- r.To.Row = min(r.To.Row, r.From.Row+maxViewRows-1)108+ r.To.Row = min(r.To.Row, r.From.Row+max(rows, 1)-1, r.From.Row+maxViewRows-1)75109 snap := fileio.Snap(t.Sheet, r, t.Sheet.Name())76110 grid, _ := fileio.HTMLGrid(snap)77- return viewResult(s.b.Name(), sheet.Qualified(t.Sheet.Name(), snap.Range), grid)111+ return &View{Title: b.Name(), Where: sheet.Qualified(t.Sheet.Name(), snap.Range), HTML: grid}78112 }79113 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- }114+// chartView draws the chart create_chart made in b.115+func chartView(b *book, w *sheet.Workbook, made headless.ChartMade) *View {85116 c, ok := fileio.ChartSnap(w.Lookup(made.Sheet), made.Number-1)86117 if !ok {87118 return nil88119 }89- return viewResult(made.Title, made.Sheet+" "+c.Data.String(), fileio.HTMLChart(c))120+ data := sheet.Qualified(made.Sheet, c.Data)121+ return &View{Title: b.Name(), Name: c.Data.String(), Where: c.Type.Title() + " chart of " + data, HTML: fileio.HTMLChart(c)}90122 }91123 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-})();124+// viewCSS fits the export's page to a frame: the panel on top, the125+// grid scrolling in what room the host gives, its headers sticking to126+// the grid's edges.127+const viewCSS = `html,body{overflow:hidden}128+.o12{width:auto;min-width:0}129+#o12-view{overflow-x:auto}130+#o12-view .sheet{overflow:auto;max-height:480px;overscroll-behavior:contain}131+#o12-view .grid thead th{top:0}132+#o12-view:empty::before{content:"Waiting for the tool's result";display:block;color:var(--muted);padding:0 9px;height:21px}119133 `
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 PLAYWRIGHT_DIR (a folder whose node_modules23+// holds it) or NODE_PATH, and is skipped without them.24+// 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("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("PLAYWRIGHT_DIR"); 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 PLAYWRIGHT_DIR to a folder whose node_modules holds it")100+ }101+ return nodePath102+}
M internal/mcp/views_test.go +90 -37
@@ -2,42 +2,47 @@ package mcp
22 33 import (44 "context"5+ "encoding/json"6+ "fmt"7+ "path/filepath"58 "strings"69 "testing"710 811 sdk "github.com/modelcontextprotocol/go-sdk/mcp"12+13+ "github.com/FelineStateMachine/012/internal/sheet"914 )1015 1116 // connectApps is connect with a client that declares the MCP Apps1217 // extension.13-func connectApps(t *testing.T, b Backend) *sdk.ClientSession {18+func connectApps(t *testing.T, o Options) *sdk.ClientSession {1419 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() })2220 caps := &sdk.ClientCapabilities{}2321 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 {22+ return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}), nil)23+}24+25+// resultView is the view in a result's _meta.26+func resultView(t *testing.T, res *sdk.CallToolResult) View {27+ t.Helper()28+ var out View29+ data, _ := json.Marshal(res.Meta[viewKey])30+ if err := json.Unmarshal(data, &out); err != nil {2631 t.Fatal(err)2732 }28- t.Cleanup(func() { cs.Close() })29- return cs33+ return out3034 }3135 32-func viewHTML(res *sdk.CallToolResult) string {33- v, _ := res.Meta[viewKey].(map[string]any)34- html, _ := v["html"].(string)35- return html36+// forModel is what of a result the model reads: its structured content37+// and its text.38+func forModel(t *testing.T, res *sdk.CallToolResult) string {39+ t.Helper()40+ data, _ := json.Marshal(res.StructuredContent)41+ return string(data) + res.Content[0].(*sdk.TextContent).Text3642 }3743 38-func TestViews(t *testing.T) {39- path := bookFile(t)40- cs := connectApps(t, &FileBackend{Path: path})44+func TestViewResources(t *testing.T) {45+ cs := connectApps(t, Options{Default: bookFile(t)})4146 ctx := context.Background()4247 for tl, err := range cs.Tools(ctx, nil) {4348 if err != nil {
@@ -45,30 +50,78 @@ func TestViews(t *testing.T) {
4550 }4651 ui, _ := tl.Meta["ui"].(map[string]any)4752 drawn := tl.Name == "read_range" || tl.Name == "create_chart"48- if (ui["resourceUri"] == viewURI) != drawn {53+ if (ui["resourceUri"] == viewURI) != drawn || (tl.Meta["openai/outputTemplate"] == skybridgeURI) != drawn {4954 t.Errorf("%s: _meta %v", tl.Name, tl.Meta)5055 }5156 }52- res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: viewURI})53- if err != nil {54- t.Fatal(err)57+ for uri, mime := range map[string]string{viewURI: uiMIME, skybridgeURI: skybridgeMIME} {58+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri})59+ if err != nil {60+ t.Fatal(err)61+ }62+ page := res.Contents[0]63+ if page.MIMEType != mime || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "openai:set_globals") ||64+ !strings.Contains(page.Text, "@font-face") || !strings.Contains(page.Text, `<div id="o12-view"></div>`) {65+ t.Errorf("%s: %s %.200s", uri, page.MIMEType, page.Text)66+ }67+ if ui, _ := page.Meta["ui"].(map[string]any); ui["prefersBorder"] != true || page.Meta["openai/widgetPrefersBorder"] != true {68+ t.Errorf("%s: _meta %v", uri, page.Meta)69+ }70+ }71+}72+73+// TestViewsForEveryClient checks the view is in the result's _meta74+// whether or not the client declared the extension (the Apps SDK's75+// hosts don't), and left out of the structured content and text models76+// read.77+func TestViewsForEveryClient(t *testing.T) {78+ path := bookFile(t)79+ for name, cs := range map[string]*sdk.ClientSession{80+ "apps": connectApps(t, Options{Default: path}),81+ "plain": connect(t, Options{Default: path}),82+ } {83+ got := call(t, cs, "read_range", map[string]any{"ref": "A1:C4"}, nil)84+ v := resultView(t, got)85+ if !strings.Contains(v.HTML, `<table class="grid">`) || !strings.Contains(v.HTML, `data-a="A2"`) || v.Where != "Sheet1!A1:C4" || v.Title != "book" {86+ t.Errorf("%s: read_range view: %+v", name, v)87+ }88+ if text := forModel(t, got); strings.Contains(text, "html") || strings.Contains(text, "<table") || !strings.Contains(text, `"range":"Sheet1!A1:C4"`) {89+ t.Errorf("%s: what the model reads: %s", name, text)90+ }91+ got = call(t, cs, "read_range", map[string]any{"ref": "A1:C4", "max_cells": 6}, nil)92+ if v := resultView(t, got); v.Where != "Sheet1!A1:C2" {93+ t.Errorf("%s: the view draws %s, not the rows read", name, v.Where)94+ }5595 }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)96+ cs := connect(t, Options{Default: path})97+ got := call(t, cs, "create_chart", map[string]any{"data": "A1:B4", "title": "Units"}, nil)98+ 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" {99+ t.Errorf("create_chart view: %.300s", v.HTML)60100 }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)101+ if text := forModel(t, got); strings.Contains(text, "<svg") || !strings.Contains(text, `"saved":true`) {102+ t.Errorf("create_chart, what the model reads: %s", text)103+ }104+}105+106+// TestViewLeavesTheModelSmall checks what the model reads of a 60-row107+// read stays a few kilobytes while the view gets the grid.108+func TestViewLeavesTheModelSmall(t *testing.T) {109+ w := sheet.NewBook()110+ s := w.Sheet(0)111+ for r := range 60 {112+ s.Set(sheet.Addr{Row: r}, fmt.Sprintf("Week %d", r+1))113+ s.Set(sheet.Addr{Col: 1, Row: r}, fmt.Sprintf("$%d,%03d", 10+r%7, r*37%1000))114+ s.Set(sheet.Addr{Col: 2, Row: r}, fmt.Sprintf("$%d,%03d", 6+r%3, r*53%1000))115+ s.Set(sheet.Addr{Col: 3, Row: r}, fmt.Sprintf("=B%d-C%d", r+1, r+1))64116 }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)117+ path := filepath.Join(t.TempDir(), "weeks.012")118+ writeBook(t, w, path)119+ got := call(t, connect(t, Options{Default: path}), "read_range", map[string]any{"ref": "A1:D60"}, nil)120+ structured, _ := json.Marshal(got.StructuredContent)121+ if len(structured) > 5000 || strings.Contains(string(structured), "<") {122+ t.Errorf("structuredContent of 60 rows: %d bytes", len(structured))68123 }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")124+ if v := resultView(t, got); strings.Count(v.HTML, "<tr") != 61 {125+ t.Errorf("the view has %d rows", strings.Count(v.HTML, "<tr"))73126 }74127 }
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 toolResponseMetadata's 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+// view in the _meta 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(meta){return meta&&meta["o12/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.toolResponseMetadata)show(viewOf(g.toolResponseMetadata));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&&m.params._meta));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 +46 -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 }
@@ -76,45 +83,46 @@ func (s *Server) addWriteTools() {
7683 set := headless.SetOptions{Force: s.opts.Force}7784 tool(s, &sdk.Tool{Name: "write_cells", Annotations: writes,7885 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) {86+ func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {8087 var warnings []string81- return s.change(ctx, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {88+ return s.change(ctx, req, in.Path, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {8289 warnings, err = headless.Set(w, in.Entries, set)8390 return err8491 })8592 })8693 tool(s, &sdk.Tool{Name: "apply_operations", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},8794 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) {95+ func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {8996 var warnings []string90- return s.change(ctx, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {97+ return s.change(ctx, req, in.Path, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {9198 warnings, err = headless.Apply(w, in.Operations, set)9299 return err93100 })94101 })95102 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) })103+ func(ctx context.Context, req *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {104+ 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) })98105 })99106 tool(s, &sdk.Tool{Name: "filter", Annotations: writes,100107 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) })108+ func(ctx context.Context, req *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {109+ 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) })103110 })104- tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: s.viewMeta(),111+ tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: viewMeta("Adding the chart", "Added the chart"),105112 Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},106113 s.createChart)107114 tool(s, &sdk.Tool{Name: "create_pivot", Annotations: writes,108115 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) {116+ func(ctx context.Context, req *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {110117 var out pivotOut111- _, changed, err := s.change(ctx, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {118+ _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {112119 out.Sheet, err = headless.AddPivot(w, in.PivotSpec)113120 return err114121 })115122 out.Changed = changed116123 return nil, out, err117124 })125+ s.addCreateTool()118126 if s.opts.Notebooks != nil {119127 tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},120128 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 +132,34 @@ func (s *Server) addWriteTools() {
124132 125133 func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {126134 var out chartOut127- var res *sdk.CallToolResult128- _, changed, err := s.change(ctx, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {135+ var view *View136+ 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+ 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+ return viewResult(view), out, nil137152 }138153 139-func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {154+func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {140155 var out headless.CellRun141- _, _, err := s.change(ctx, "run notebook cell", false, nil, func(w *sheet.Workbook) error {156+ b, err := s.open(ctx, req, in.Path)157+ if err != nil {158+ return nil, out, err159+ }160+ _, _, err = s.change(ctx, req, in.Path, "run notebook cell", false, nil, func(w *sheet.Workbook) error {142161 if s.opts.MayRun != nil {143- if err := s.opts.MayRun(w); err != nil {162+ if err := s.opts.MayRun(b.path, w); err != nil {144163 return err145164 }146165 }
@@ -151,10 +170,14 @@ func (s *Server) runCell(ctx context.Context, _ *sdk.CallToolRequest, in runCell
151170 return nil, out, err152171 }153172 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)173+// change makes fn a change of the workbook path names and says what it174+// changed; warnings, when set, are fn's.175+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) {176+ b, err := s.open(ctx, req, path)177+ if err != nil {178+ return nil, Changed{}, err179+ }180+ changes, err := b.Change(ctx, label, dryRun, fn)158181 if err != nil {159182 return nil, Changed{}, err160183 }