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 / resources.go
resources.go 8.8K
1package mcp23import (4 "context"5 "encoding/json"6 "fmt"7 "strconv"8 "strings"910 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1112 "src.rest/cat/012/internal/headless"13 "src.rest/cat/012/internal/notebook"14 "src.rest/cat/012/internal/sheet"15)1617// Resources are workbooks' data by address, for hosts that let people18// attach them to a conversation:19//20// 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 output23//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.30const (31 scheme = "o12://"32 maxListed = 20033 maxRecent = 5 // workbooks whose resources are listed34 tableSeg = "table"35 notebookSeg = "notebook"36)3738func (s *Server) addResources() {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",42 Description: "A named table, header row included"}, s.readResource)43 s.AddResourceTemplate(&sdk.ResourceTemplate{URITemplate: scheme + "{+book}/notebook/{sheet}/{cell}", Name: "notebook cell", MIMEType: "text/plain",44 Description: "A notebook cell's source, and its output as NUON or why it failed"}, s.readResource)45}4647// used notes that a tool used b, listing its resources from now on.48func (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}6768// refreshResources lists the resources of the workbooks used lately as69// they stand, telling clients when the list changes.70func (s *Server) refreshResources(ctx context.Context) {71 s.mu.Lock()72 recent := append([]*book(nil), s.recent...)73 s.mu.Unlock()74 var want []sdk.Resource75 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)]83 s.mu.Lock()84 defer s.mu.Unlock()85 keep := map[string]bool{}86 for _, r := range want {87 keep[r.URI] = true88 if !s.resources[r.URI] {89 res := r90 s.AddResource(&res, s.readResource)91 }92 }93 var gone []string94 for uri := range s.resources {95 if !keep[uri] {96 gone = append(gone, uri)97 }98 }99 if len(gone) > 0 {100 s.RemoveResources(gone...)101 }102 s.resources = keep103}104105// listResources are a workbook's resources: each sheet's used range,106// table and notebook code cell.107func listResources(w *sheet.Workbook, name string) []sdk.Resource {108 var out []sdk.Resource109 base := bookURI(name)110 for _, sh := range w.Sheets() {111 if sh.IsNotebook() {112 for i, c := range sh.NotebookCells() {113 if c.Kind == notebook.Code {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)})116 }117 }118 continue119 }120 if used, ok := sh.UsedRange(); ok {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})123 }124 for _, t := range sh.Tables() {125 out = append(out, sdk.Resource{URI: base + "/" + tableSeg + "/" + escape(t.Name), Name: name + " table " + t.Name, MIMEType: "application/json",126 Description: "Table " + t.Name + " on " + sh.Name() + ", " + t.Range.String() + ": " + strings.Join(t.Cols, ", ")})127 }128 }129 return out130}131132// bookURI is where a workbook's resources start: the scheme and its133// path, each part percent-encoded.134func 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}141142// rangeURI is the resource of a range of a sheet of a workbook.143func rangeURI(book, sheetName string, r sheet.Rect) string {144 return bookURI(book) + "/" + escape(sheetName) + "!" + r.String()145}146147func (s *Server) readResource(ctx context.Context, req *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {148 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 }157 var text, mime string158 err = b.View(ctx, func(w *sheet.Workbook) error {159 var err error160 text, mime, err = resourceText(w, rest)161 return err162 })163 if err != nil {164 return nil, err165 }166 return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: uri, MIMEType: mime, Text: text}}}, nil167}168169// 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.172func splitURI(uri string) (book string, rest []string, err error) {173 parts := strings.Split(strings.TrimPrefix(uri, scheme), "/")174 n := len(parts)175 switch {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, err191 }192 }193 return strings.Join(path, "/"), rest, nil194}195196// resourceText is what the rest of a resource's URI names in a197// workbook, and its type.198func 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])204 if err != nil {205 return "", "", err206 }207 return rangeJSON(w, name)208 }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)215}216217func rangeJSON(w *sheet.Workbook, ref string) (string, string, error) {218 t, err := headless.Resolve(w, ref)219 if err != nil {220 return "", "", err221 }222 data, err := json.Marshal(headless.ReadRange(t, headless.ReadOptions{}))223 return string(data), "application/json", err224}225226// notebookCellText is a cell's source, then its output or why it227// failed.228func notebookCellText(w *sheet.Workbook, sheetPart, cell string) (string, string, error) {229 name, err := unescape(sheetPart)230 if err != nil {231 return "", "", err232 }233 n, err := strconv.Atoi(cell)234 s := w.Lookup(name)235 if s == nil || !s.IsNotebook() || err != nil || n < 1 || n > len(s.NotebookCells()) {236 return "", "", fmt.Errorf("no cell %s of a notebook %q", cell, name)237 }238 c := s.NotebookCells()[n-1]239 var b strings.Builder240 b.WriteString(c.Source + "\n")241 switch o := w.Output(c.ID); {242 case c.Kind != notebook.Code:243 case o == nil || o.Unsaved:244 b.WriteString("\n(not run)\n")245 case o.Failed():246 b.WriteString("\nfailed: " + o.Err + "\n")247 default:248 b.WriteString("\n" + string(o.NUON) + "\n")249 }250 return b.String(), "text/plain", nil251}252253func firstLine(s string) string {254 line, _, _ := strings.Cut(s, "\n")255 return line256}257258// escape percent-encodes all but URI's unreserved characters, so a259// sheet's name or a part of a path fits a template's simple variable.260func escape(s string) string {261 var b strings.Builder262 for _, c := range []byte(s) {263 if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("-._~", c) >= 0 {264 b.WriteByte(c)265 } else {266 fmt.Fprintf(&b, "%%%02X", c)267 }268 }269 return b.String()270}271272func unescape(s string) (string, error) {273 var b strings.Builder274 for i := 0; i < len(s); i++ {275 if s[i] != '%' {276 b.WriteByte(s[i])277 continue278 }279 if i+2 >= len(s) {280 return "", fmt.Errorf("%q: a %% needs two hex digits", s)281 }282 v, err := strconv.ParseUint(s[i+1:i+3], 16, 8)283 if err != nil {284 return "", fmt.Errorf("%q: a %% needs two hex digits", s)285 }286 b.WriteByte(byte(v))287 i += 2288 }289 return b.String(), nil290}