cat / 012
clone

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

MCP Apps views: ranges and charts drawn in the chat

Dami committed 2621da1
read_range and create_chart name ui://012/view (text/html;profile=mcp-app)
in their _meta, following the io.modelcontextprotocol/ui extension
(2026-01-26). The resource is the HTML export's page, empty, with a
script that speaks the view's side of the protocol (ui/initialize, the
host's theme, tool results, size changes); results carry the grid or
chart fragment in _meta for clients that declare the extension.
commit
2621da12cc778cb1a88b6bd2a6ab6735f1f484cd
parent
7266433d51507e6ed52ccde405f1eb1e50b05a16
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.2 of 3, Jev's estimate
6 files changed, +199 -8
MROADMAP.md2+-
Mdocs/agents/mcp.md16++++
Minternal/mcp/read.go2+-
Minternal/mcp/server.go2+-
Minternal/mcp/views.go111+++++++++++++++++++++++++++++-
Ainternal/mcp/views_test.go74++++++++++++++++++++
M ROADMAP.md +1 -1
M docs/agents/mcp.md +16 -0
M internal/mcp/read.go +1 -1
M internal/mcp/server.go +1 -1
M internal/mcp/views.go +106 -5
@@ -1,18 +1,119 @@
11 package mcp22 33 import (4+ "context"5+46 sdk "github.com/modelcontextprotocol/go-sdk/mcp"57 8+ "github.com/FelineStateMachine/012/internal/fileio"69 "github.com/FelineStateMachine/012/internal/headless"710 "github.com/FelineStateMachine/012/internal/sheet"811 )912 10-func (s *Server) addViews() {}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.22+const (23+ uiExtension = "io.modelcontextprotocol/ui"24+ uiMIME = "text/html;profile=mcp-app"25+ viewURI = "ui://012/view"26+ viewKey = "o12/view"27+ // maxViewRows bounds the rows a range's view draws.28+ maxViewRows = 50029+)30+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+ })39+}40+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}45+}46+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 false55+ }56+ _, ok := p.Capabilities.Extensions[uiExtension]57+ return ok58+}59+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}}}63+}1164 12-func (s *Server) viewMeta() sdk.Meta { return nil }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) {68+ return nil69+ }70+ r := t.Range71+ if t.Whole {72+ r, _ = t.Sheet.UsedRange()73+ }74+ r.To.Row = min(r.To.Row, r.From.Row+maxViewRows-1)75+ snap := fileio.Snap(t.Sheet, r, t.Sheet.Name())76+ grid, _ := fileio.HTMLGrid(snap)77+ return viewResult(s.b.Name(), sheet.Qualified(t.Sheet.Name(), snap.Range), grid)78+}1379 14-func (s *Server) rangeView(*sdk.CallToolRequest, headless.Target) *sdk.CallToolResult { return nil }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+ }85+ c, ok := fileio.ChartSnap(w.Lookup(made.Sheet), made.Number-1)86+ if !ok {87+ return nil88+ }89+ return viewResult(made.Title, made.Sheet+" "+c.Data.String(), fileio.HTMLChart(c))90+}1591 16-func (s *Server) chartView(*sdk.CallToolRequest, *sheet.Workbook, headless.ChartMade) *sdk.CallToolResult {17- return nil92+// 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();18106 }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+})();119+`
A internal/mcp/views_test.go +74 -0
@@ -0,0 +1,74 @@
1+package mcp2+3+import (4+ "context"5+ "strings"6+ "testing"7+8+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"9+)10+11+// connectApps is connect with a client that declares the MCP Apps12+// extension.13+func connectApps(t *testing.T, b Backend) *sdk.ClientSession {14+ 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() })22+ caps := &sdk.ClientCapabilities{}23+ caps.AddExtension(uiExtension, map[string]any{"mimeTypes": []string{uiMIME}})24+ cs, err := sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}).Connect(ctx, ct, nil)25+ if err != nil {26+ t.Fatal(err)27+ }28+ t.Cleanup(func() { cs.Close() })29+ return cs30+}31+32+func viewHTML(res *sdk.CallToolResult) string {33+ v, _ := res.Meta[viewKey].(map[string]any)34+ html, _ := v["html"].(string)35+ return html36+}37+38+func TestViews(t *testing.T) {39+ path := bookFile(t)40+ cs := connectApps(t, &FileBackend{Path: path})41+ ctx := context.Background()42+ for tl, err := range cs.Tools(ctx, nil) {43+ if err != nil {44+ t.Fatal(err)45+ }46+ ui, _ := tl.Meta["ui"].(map[string]any)47+ drawn := tl.Name == "read_range" || tl.Name == "create_chart"48+ if (ui["resourceUri"] == viewURI) != drawn {49+ t.Errorf("%s: _meta %v", tl.Name, tl.Meta)50+ }51+ }52+ res, err := cs.ReadResource(ctx, &sdk.ReadResourceParams{URI: viewURI})53+ if err != nil {54+ t.Fatal(err)55+ }56+ page := res.Contents[0]57+ if page.MIMEType != uiMIME || !strings.Contains(page.Text, `"ui/initialize"`) || !strings.Contains(page.Text, "@font-face") ||58+ !strings.Contains(page.Text, `<div id="o12-view"></div>`) {59+ t.Errorf("view resource: %s %.200s", page.MIMEType, page.Text)60+ }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)64+ }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)68+ }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")73+ }74+}