1package mcp23import (4 "context"5 "encoding/json"6 "fmt"7 "path/filepath"8 "strings"9 "testing"1011 sdk "github.com/modelcontextprotocol/go-sdk/mcp"1213 "src.rest/cat/012/internal/sheet"14)1516// connectApps is connect with a client that declares the MCP Apps17// extension.18func connectApps(t *testing.T, o Options) *sdk.ClientSession {19 t.Helper()20 caps := &sdk.ClientCapabilities{}21 caps.AddExtension(uiExtension, map[string]any{"mimeTypes": []string{uiMIME}})22 return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}), nil)23}2425// resultView is the view in a result's _meta.26func 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 {31 t.Fatal(err)32 }33 return out34}3536// forModel is what of a result the model reads: its structured content37// and its text.38func 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).Text42}4344func TestViewResources(t *testing.T) {45 cs := connectApps(t, Options{Default: bookFile(t)})46 ctx := context.Background()47 for tl, err := range cs.Tools(ctx, nil) {48 if err != nil {49 t.Fatal(err)50 }51 ui, _ := tl.Meta["ui"].(map[string]any)52 drawn := tl.Name == "read_range" || tl.Name == "create_chart"53 if (ui["resourceUri"] == viewURI) != drawn || (tl.Meta["openai/outputTemplate"] == skybridgeURI) != drawn {54 t.Errorf("%s: _meta %v", tl.Name, tl.Meta)55 }56 }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}7273// 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.77func 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 }95 }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)100 }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}105106// TestViewLeavesTheModelSmall checks what the model reads of a 60-row107// read, typed values and the text shown, stays about ten kilobytes108// while the view gets the grid.109func TestViewLeavesTheModelSmall(t *testing.T) {110 w := sheet.NewBook()111 s := w.Sheet(0)112 for r := range 60 {113 s.Set(sheet.Addr{Row: r}, fmt.Sprintf("Week %d", r+1))114 s.Set(sheet.Addr{Col: 1, Row: r}, fmt.Sprintf("$%d,%03d", 10+r%7, r*37%1000))115 s.Set(sheet.Addr{Col: 2, Row: r}, fmt.Sprintf("$%d,%03d", 6+r%3, r*53%1000))116 s.Set(sheet.Addr{Col: 3, Row: r}, fmt.Sprintf("=B%d-C%d", r+1, r+1))117 }118 path := filepath.Join(t.TempDir(), "weeks.012")119 writeBook(t, w, path)120 got := call(t, connect(t, Options{Default: path}), "read_range", map[string]any{"ref": "A1:D60"}, nil)121 structured, _ := json.Marshal(got.StructuredContent)122 if len(structured) > 12000 || strings.Contains(string(structured), "<") {123 t.Errorf("structuredContent of 60 rows: %d bytes", len(structured))124 }125 if v := resultView(t, got); strings.Count(v.HTML, "<tr") != 61 {126 t.Errorf("the view has %d rows", strings.Count(v.HTML, "<tr"))127 }128}