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
| M | ROADMAP.md | 2 | +- |
| M | docs/ | 16 | ++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 111 | +++++++++++++++++++++++++++++- |
| A | internal/ | 74 | ++++++++++++++++++++ |
@@ -76,7 +76,6 @@ section 3's shared editing with an agent as one participant.
7676 7777 | Step | Result | Size |7878 |---|---|---|79-| MCP Apps views: a sheet or chart rendered as an interactive view inside the host, from the HTML export | Workbooks shown in the chat | S to M |8079 | Live mode: `012 --listen` and `012 mcp --attach`; the agent's cursor and name in the grid, its changes arriving as a suggestion (marked cells, accepted or rejected whole or by cell) unless direct edits are allowed, its own undo, a scope (sheet, range, read-only), elicitation to ask the person | Coworking with an agent in the grid | M to L |8180 8281 ## Later: other transports (shelved)@@ -163,6 +162,7 @@ stream above rather than a design of their own.
163162 164163 - `012 describe`, results as JSON with stable schemas, `012 set --dry-run` as a diff, and a Claude Code skill installed by `012 agent --install-skill`: [Agents](docs/agents/README.md)165164 - `012 mcp`, an MCP server on a workbook file: tools that read, evaluate, write, sort, filter, chart, pivot and run notebook cells through the same checks as `012 set`, resources and prompts: [MCP server](docs/agents/mcp.md)165+- MCP Apps views: ranges read and charts made drawn in the chat from the HTML export, where the host supports the extension: [MCP server](docs/agents/mcp.md#views-in-the-chat)166166 167167 **Upkeep**168168 @@ -94,6 +94,22 @@ lists them) and `warnings`; a refused one is an error naming the cell,
9494 with nothing changed. Every result's schema is in9595 [JSON output](../reference/json.md#mcp-tools).9696 97+## Views in the chat98+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.112+97113 ## Resources98114 99115 Hosts that let people attach data to a conversation list these; the@@ -49,7 +49,7 @@ func (s *Server) addReadTools() {
4949 err := s.b.View(ctx, func(w *sheet.Workbook) error { d = headless.Describe(w); return nil })5050 return nil, d, err5151 })52- tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly,52+ tool(s, &sdk.Tool{Name: "read_range", Annotations: readOnly, Meta: s.viewMeta(),5353 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."},5454 s.readRange)5555 tool(s, &sdk.Tool{Name: "evaluate", Annotations: readOnly,@@ -50,7 +50,7 @@ type Server struct {
5050 func New(b Backend, o Options) *Server {5151 s := &Server{b: b, opts: o, resources: map[string]bool{}}5252 s.Server = sdk.NewServer(&sdk.Implementation{Name: "012", Title: "012 spreadsheet", Version: orDevel(o.Version)},53- &sdk.ServerOptions{Instructions: instructions, Capabilities: &sdk.ServerCapabilities{}})53+ &sdk.ServerOptions{Instructions: instructions, Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}})5454 s.addReadTools()5555 if !o.ReadOnly {5656 s.addWriteTools()@@ -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+`@@ -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+}