cat / 012
clone

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

cat/module-path
012 / internal / mcp / books.go
books.go 6.2K
1package mcp23import (4 "context"5 "errors"6 "fmt"7 "io/fs"8 "net/url"9 "os"10 "path/filepath"11 "strings"1213 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1415 "src.rest/cat/012/internal/confine"16 "src.rest/cat/012/internal/fileio"17 "src.rest/cat/012/internal/sheet"18)1920// 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.2728// bookIn is the argument naming a tool's workbook.29type 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}3233// book is a workbook a tool works on: its backend and the path that34// names it to the client.35type book struct {36 Backend37 path string // absolute, as resolved38 name string // as tools and resources name it: relative to its root, or absolute39}4041// NewRoots confines paths to dirs, the working directory when there42// are none.43func 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}5758// resolve is the file on disk a tool's path names, within roots.59func 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}9495// outside is why name was refused, err, naming the folders open.96func 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}99100// realPath is p with the symbolic links along the part of it that101// exists followed, so it compares with a root's resolved folder.102func 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}115116// displayName is how tools and resources name the file at path: relative117// to the first root when it's in it, else absolute.118func 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}126127// isWorkbook reports whether a file's name is a .012 workbook's, and128// readable whether it's one 012 at least imports.129func 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}136137// book is the workbook a tool's path names for the client of req, or138// the default one when the path is empty.139func (s *Server) book(ctx context.Context, ss *sdk.ServerSession, name string) (*book, error) {140 if l := s.opts.Live; l != nil {141 return s.liveBook(l, name)142 }143 roots := s.roots(ctx, ss)144 var path string145 switch {146 case name == "" && s.opts.Default == "":147 return nil, errors.New("name the workbook with path; describe without one lists the workbooks open to this server")148 case name == "" || s.opts.Default != "" && filepath.IsAbs(name) && realPath(name) == s.opts.Default:149 path = s.opts.Default150 default:151 p, err := resolve(roots, name)152 if err != nil {153 return nil, err154 }155 path = p156 }157 if path != s.opts.Default {158 if _, ok := isWorkbook(path); !ok {159 return nil, fmt.Errorf("%s isn't a workbook: a %s file, or one 012 imports", name, sheet.FileExt)160 }161 if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {162 return nil, fmt.Errorf("%s doesn't exist: create_workbook makes a workbook, and describe without a path lists them", name)163 }164 }165 return &book{Backend: s.backend(path), path: path, name: displayName(roots, path)}, nil166}167168// liveBook is the live workbook, which a path, when given, must name.169func (s *Server) liveBook(l Live, name string) (*book, error) {170 n := l.Name()171 base := strings.TrimSuffix(filepath.Base(filepath.FromSlash(name)), filepath.Ext(name))172 if name != "" && name != n && base != n {173 return nil, fmt.Errorf("this server is attached to %s, open on a person's screen, and works on it alone: leave path out", n)174 }175 return &book{Backend: l, path: n, name: n}, nil176}177178// backend is the one backend of the file at path, so calls on the same179// file take turns.180func (s *Server) backend(path string) Backend {181 s.mu.Lock()182 defer s.mu.Unlock()183 if b, ok := s.backends[path]; ok {184 return b185 }186 var b Backend187 if own, _ := isWorkbook(path); own || path == s.opts.Default {188 b = &FileBackend{Path: path, Prepare: s.opts.Prepare, Save: s.opts.Save}189 } else {190 b = &ImportBackend{Path: path, Prepare: s.opts.Prepare}191 }192 s.backends[path] = b193 return b194}195196// rootDirs are the roots' folders, for describe.197func rootDirs(roots []confine.Root) []string {198 dirs := make([]string, len(roots))199 for i, r := range roots {200 dirs[i] = r.Dir()201 }202 return dirs203}