Both kinds of hosts hand a result's _meta to the view (the Apps SDK as window.openai.toolResponseMetadata) and keep it from the model, so the view's HTML goes there for every client and structuredContent stays the tool's result: a few kilobytes for 60 rows instead of the markup too.
- commit
- f2207d5e3cc11ae16e04683c75b4a8846ebf23e0
- parent
- c8b191b00fe4ef574fcd2c7150033c9f320da46f
- author
- Dami <dami.etoile@gmail.com>
- kind
- refactor risk score 2.4 of 3, Jev's estimate
| M | docs/ | 20 | ++++++----- |
| M | docs/ | 12 | +++--- |
| M | internal/ | 20 | ++++------- |
| M | internal/ | 17 | +++++---- |
| M | internal/ | 36 | ++++++++++--------- |
| M | internal/ | 56 | +++++++++++++++++++++++++----- |
| M | internal/ | 10 | +++--- |
| M | internal/ | 8 | ++--- |
@@ -134,15 +134,17 @@ Two kinds of hosts do this, and the view works in both:
134134 | Host | Finds the page by | Its page | The page reads |135135 |---|---|---|---|136136 | [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) (Claude, and the extension's other hosts) | the tool's `_meta.ui.resourceUri` | `ui://012/view`, `text/html;profile=mcp-app` | the `ui/initialize` handshake's theme and room, then `ui/notifications/tool-result` |137-| The [OpenAI Apps SDK](https://developers.openai.com/apps-sdk) (ChatGPT, Codex) | the tool's `_meta["openai/outputTemplate"]` | `ui://012/view.skybridge`, `text/html+skybridge` | `window.openai`: `toolOutput`, `theme`, `maxHeight`, `displayMode`, and `openai:set_globals` events |138-139-What the page draws is in the result's `structuredContent.view`, for140-every client, whether or not it declared the extension: the workbook's141-name, where the range or chart is, and its HTML, at most 200 rows of142-what `read_range` returned. The result's text, which models read, is143-the same result without the view; hosts of the Apps SDK also show the144-model `structuredContent`, view included. The page asks for nothing145-from the network and calls no tools.137+| The [OpenAI Apps SDK](https://developers.openai.com/apps-sdk) (ChatGPT, Codex) | the tool's `_meta["openai/outputTemplate"]` | `ui://012/view.skybridge`, `text/html+skybridge` | `window.openai`: `toolResponseMetadata`, `theme`, `maxHeight`, `displayMode`, and `openai:set_globals` events |138+139+What the page draws is in the result's `_meta["o12/view"]`, for every140+client, whether or not it declared the extension: the workbook's name,141+where the range or chart is, and its HTML, at most 200 rows of what142+`read_range` returned. Both kinds of hosts hand a result's `_meta` to143+the page (the Apps SDK as `window.openai.toolResponseMetadata`) and144+keep it from the model, which reads the result itself,145+`structuredContent` and the same JSON as text, with no HTML: a few146+kilobytes for 60 rows. The page asks for nothing from the network and147+calls no tools.146148 147149 ## Resources148150 @@ -129,8 +129,7 @@ are as without it.
129129 ## MCP tools130130 131131 The [MCP server](../agents/mcp.md)'s tools return these, as structured132-content with the same JSON as text beside it (the text without the133-`view`). `describe` with a path returns `path` and132+content with the same JSON as text beside it. `describe` with a path returns `path` and134133 [describe's](#describe) schema, and so does `create_workbook`, of the135134 workbook it made; without a path, `describe` returns the folders open136135 to the server and the workbooks in them, `more` set when there were@@ -163,15 +162,16 @@ more than it lists:
163162 `values` are typed as [`get`](#get) types one cell (blank is `null`, an164163 error its text); `text` comes only when asked for; `truncated` is set165164 when the range held more cells than `max_cells`, and `rows` says how166-many came back. The tool adds `view`, what165+many came back. The result's `_meta["o12/view"]` holds what167166 [the view](../agents/mcp.md#views-in-the-chat) draws: `title` (the168-workbook's name), `where` and `html`; the range resources leave it out.167+workbook's name), `where` and `html`.169168 170169 Writes (`write_cells`, `apply_operations`, `sort`, `filter`) return171170 `{"saved", "changes", "warnings"}` as [`set`](#set) does, `saved` false172171 for a dry run or a change that changed nothing; `create_chart` adds173-`"chart": {"sheet", "number", "title"}` and its `view` (with `name`,174-the chart's data), and `create_pivot` `"sheet"`, the pivot's.172+`"chart": {"sheet", "number", "title"}`, its view in `_meta` as173+`read_range`'s (with `name`, the chart's data), and `create_pivot`174+`"sheet"`, the pivot's.175175 176176 | Tool | Returns |177177 |---|---|@@ -37,13 +37,6 @@ type readIn struct {
3737 // embedding it can have beside its own field Range.3838 type RangeRead = headless.Range3939 40-// rangeOut is what read_range returns: the range, and the view hosts41-// draw of it.42-type rangeOut struct {43- RangeRead44- View *View `json:"view,omitempty"`45-}46-4740 type evaluateIn struct {4841 bookIn4942 Formula string `json:"formula" jsonschema:"the formula, with or without its =: =SUMIF(B:B, \"North\", C:C)"`@@ -134,21 +127,22 @@ func (s *Server) describe(ctx context.Context, req *sdk.CallToolRequest, in desc
134127 return nil, out, err135128 }136129 137-func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, rangeOut, error) {138- var out rangeOut130+func (s *Server) readRange(ctx context.Context, req *sdk.CallToolRequest, in readIn) (*sdk.CallToolResult, RangeRead, error) {131+ var out RangeRead132+ var view *View139133 err := s.view(ctx, req, in.Path, func(b *book, w *sheet.Workbook) error {140134 t, err := headless.Resolve(w, in.Ref)141135 if err != nil {142136 return err143137 }144- out.RangeRead = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})145- out.View = rangeView(b, t, out.Rows)138+ out = headless.ReadRange(t, headless.ReadOptions{MaxCells: in.Max, Text: in.Text})139+ view = rangeView(b, t, out.Rows)146140 return nil147141 })148142 if err != nil {149- return nil, rangeOut{}, err143+ return nil, RangeRead{}, err150144 }151- return forModel(out.RangeRead), out, nil145+ return viewResult(view), out, nil152146 }153147 154148 // view runs fn on the workbook path names, as it is.@@ -2,8 +2,9 @@
22 // what a person would see (see views_browser_test.go):33 //44 // - openai: the OpenAI Apps SDK's way. window.openai is set before the5-// page's scripts run, its toolOutput null at first and then set by an6-// openai:set_globals event, as ChatGPT does when the result arrives;5+// page's scripts run, toolOutput and toolResponseMetadata null at6+// first and then set by an openai:set_globals event, as ChatGPT does7+// when the result arrives;78 // notifyIntrinsicHeight resizes the frame.89 // - mcp-apps: the MCP Apps way. The host answers ui/initialize with its910 // context, sends ui/notifications/tool-input and tool-result once the@@ -77,10 +78,12 @@ async function run(mode, name, c, theme) {
7778 if (mode === "openai") {7879 await frame.locator("#o12-view").waitFor({ state: "attached" });7980 await page.waitForTimeout(100);80- await frame.locator("body").evaluate((_, out) => {81- window.openai.toolOutput = out;82- window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals: { toolOutput: out } } }));83- }, c.result.structuredContent);81+ await frame.locator("body").evaluate((_, r) => {82+ window.openai.toolOutput = r.structuredContent;83+ window.openai.toolResponseMetadata = r._meta;84+ const globals = { toolOutput: r.structuredContent, toolResponseMetadata: r._meta };85+ window.dispatchEvent(new CustomEvent("openai:set_globals", { detail: { globals } }));86+ }, c.result);8487 }8588 const what = `${mode} ${name}`;8689 try {@@ -91,7 +94,7 @@ async function run(mode, name, c, theme) {
9194 return;9295 }9396 await page.waitForTimeout(300);94- const v = c.result.structuredContent.view;97+ const v = c.result._meta["o12/view"];9598 const state = () => frame.locator("body").evaluate(() => {9699 const sh = document.querySelector("#o12-view .sheet");97100 return {@@ -2,7 +2,6 @@ package mcp
22 33 import (44 "context"5- "encoding/json"65 76 sdk "github.com/modelcontextprotocol/go-sdk/mcp"87 @@ -23,29 +22,34 @@ import (
2322 // - The OpenAI Apps SDK (ChatGPT, Codex): the tool's2423 // _meta["openai/outputTemplate"] names ui://012/view.skybridge, the2524 // same page as text/html+skybridge, which reads window.openai26-// (toolOutput, theme, maxHeight, displayMode) instead.25+// (toolResponseMetadata, theme, maxHeight, displayMode) instead.2726 //2827 // The page is the HTML export's with nothing in it yet (fonts,29-// palettes, the pointer's script). What it draws is the result's30-// structuredContent.view, for every client: its title, where it is and31-// the grid's or chart's HTML. The result's text, which models read in32-// hosts that keep structuredContent for the view, leaves the view out.28+// palettes, the pointer's script). What it draws is in the result's29+// _meta under viewKey, for every client, since both kinds of hosts hand30+// a result's _meta to the view and neither shows it to the model: its31+// title, where it is and the grid's or chart's HTML. structuredContent32+// stays the tool's result, what the model reads.3333 const (3434 uiExtension = "io.modelcontextprotocol/ui"3535 uiMIME = "text/html;profile=mcp-app"3636 skybridgeMIME = "text/html+skybridge"3737 viewURI = "ui://012/view"38+ viewKey = "o12/view"3839 skybridgeURI = "ui://012/view.skybridge"3940 // maxViewRows bounds the rows a range's view draws.4041 maxViewRows = 2004142 )4243 43-// View is what a view draws of a result.44+// View is what a view draws of a result, in its _meta under viewKey:45+// the workbook's name, what the name box shows before a cell is46+// pointed at (a chart's data), the range or the chart's kind, sheet and47+// data, and the grid or chart as the HTML export draws it.4448 type View struct {45- Title string `json:"title" jsonschema:"the workbook's name"`46- Name string `json:"name,omitempty" jsonschema:"what the name box shows before a cell is pointed at: a chart's data"`47- Where string `json:"where" jsonschema:"the range, or the chart's kind, sheet and data"`48- HTML string `json:"html" jsonschema:"the grid or chart as 012's HTML export draws it, for the view"`49+ Title string `json:"title"`50+ Name string `json:"name,omitempty"`51+ Where string `json:"where"`52+ HTML string `json:"html"`4953 }5054 5155 // viewResourceMeta says how hosts should frame the view, in both@@ -83,14 +87,12 @@ func viewMeta(invoking, invoked string) sdk.Meta {
8387 "openai/outputTemplate": skybridgeURI, "openai/toolInvocation/invoking": invoking, "openai/toolInvocation/invoked": invoked}8488 }8589 86-// forModel is a result whose text is v as JSON: the structured result87-// without its view.88-func forModel(v any) *sdk.CallToolResult {89- data, err := json.Marshal(v)90- if err != nil {90+// viewResult is a result carrying v in its _meta, nil without one.91+func viewResult(v *View) *sdk.CallToolResult {92+ if v == nil {9193 return nil9294 }93- return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: string(data)}}}95+ return &sdk.CallToolResult{Meta: sdk.Meta{viewKey: v}}9496 }9597 9698 // rangeView draws the rows read_range returned of t, at most@@ -3,10 +3,14 @@ package mcp
33 import (44 "context"55 "encoding/json"6+ "fmt"7+ "path/filepath"68 "strings"79 "testing"810 911 sdk "github.com/modelcontextprotocol/go-sdk/mcp"12+13+ "github.com/FelineStateMachine/012/internal/sheet"1014 )1115 1216 // connectApps is connect with a client that declares the MCP Apps@@ -18,15 +22,23 @@ func connectApps(t *testing.T, o Options) *sdk.ClientSession {
1822 return connectClient(t, o, sdk.NewClient(&sdk.Implementation{Name: "host", Version: "1"}, &sdk.ClientOptions{Capabilities: caps}), nil)1923 }2024 21-// resultView is the view in a result's structured content.25+// resultView is the view in a result's _meta.2226 func resultView(t *testing.T, res *sdk.CallToolResult) View {2327 t.Helper()24- var out struct{ View View }25- data, _ := json.Marshal(res.StructuredContent)28+ var out View29+ data, _ := json.Marshal(res.Meta[viewKey])2630 if err := json.Unmarshal(data, &out); err != nil {2731 t.Fatal(err)2832 }29- return out.View33+ return out34+}35+36+// forModel is what of a result the model reads: its structured content37+// and its text.38+func 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).Text3042 }3143 3244 func TestViewResources(t *testing.T) {@@ -58,9 +70,10 @@ func TestViewResources(t *testing.T) {
5870 }5971 }6072 61-// TestViewsForEveryClient checks the view is in the structured content73+// TestViewsForEveryClient checks the view is in the result's _meta6274 // whether or not the client declared the extension (the Apps SDK's63-// hosts don't), and left out of the text models read.75+// hosts don't), and left out of the structured content and text models76+// read.6477 func TestViewsForEveryClient(t *testing.T) {6578 path := bookFile(t)6679 for name, cs := range map[string]*sdk.ClientSession{@@ -72,8 +85,8 @@ func TestViewsForEveryClient(t *testing.T) {
7285 if !strings.Contains(v.HTML, `<table class="grid">`) || !strings.Contains(v.HTML, `data-a="A2"`) || v.Where != "Sheet1!A1:C4" || v.Title != "book" {7386 t.Errorf("%s: read_range view: %+v", name, v)7487 }75- if text := got.Content[0].(*sdk.TextContent).Text; strings.Contains(text, "html") || !strings.Contains(text, `"range":"Sheet1!A1:C4"`) {76- t.Errorf("%s: text for the model: %s", name, text)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)7790 }7891 got = call(t, cs, "read_range", map[string]any{"ref": "A1:C4", "max_cells": 6}, nil)7992 if v := resultView(t, got); v.Where != "Sheet1!A1:C2" {@@ -85,7 +98,30 @@ func TestViewsForEveryClient(t *testing.T) {
8598 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" {8699 t.Errorf("create_chart view: %.300s", v.HTML)87100 }88- if text := got.Content[0].(*sdk.TextContent).Text; strings.Contains(text, "<svg") || !strings.Contains(text, `"saved":true`) {89- t.Errorf("create_chart text: %s", text)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+}105+106+// TestViewLeavesTheModelSmall checks what the model reads of a 60-row107+// read stays a few kilobytes while the view gets the grid.108+func TestViewLeavesTheModelSmall(t *testing.T) {109+ w := sheet.NewBook()110+ s := w.Sheet(0)111+ for r := range 60 {112+ s.Set(sheet.Addr{Row: r}, fmt.Sprintf("Week %d", r+1))113+ s.Set(sheet.Addr{Col: 1, Row: r}, fmt.Sprintf("$%d,%03d", 10+r%7, r*37%1000))114+ s.Set(sheet.Addr{Col: 2, Row: r}, fmt.Sprintf("$%d,%03d", 6+r%3, r*53%1000))115+ s.Set(sheet.Addr{Col: 3, Row: r}, fmt.Sprintf("=B%d-C%d", r+1, r+1))116+ }117+ path := filepath.Join(t.TempDir(), "weeks.012")118+ writeBook(t, w, path)119+ got := call(t, connect(t, Options{Default: path}), "read_range", map[string]any{"ref": "A1:D60"}, nil)120+ structured, _ := json.Marshal(got.StructuredContent)121+ if len(structured) > 5000 || strings.Contains(string(structured), "<") {122+ t.Errorf("structuredContent of 60 rows: %d bytes", len(structured))123+ }124+ if v := resultView(t, got); strings.Count(v.HTML, "<tr") != 61 {125+ t.Errorf("the view has %d rows", strings.Count(v.HTML, "<tr"))90126 }91127 }@@ -1,12 +1,12 @@
11 package mcp22 33 // viewScript is the view's side of both hosts' protocols. With4-// window.openai (the Apps SDK) it draws toolOutput.view, takes the4+// window.openai (the Apps SDK) it draws toolResponseMetadata's view, takes the55 // theme, maxHeight and displayMode from it and from openai:set_globals66 // events, and reports its height with notifyIntrinsicHeight. Otherwise77 // it speaks MCP Apps over postMessage: ui/initialize with its info and88 // display modes, then ui/notifications/initialized; it draws the9-// structuredContent.view of ui/notifications/tool-result, follows the9+// view in the _meta of ui/notifications/tool-result, follows the1010 // hostContext's theme, containerDimensions and displayMode as they1111 // change, answers ping and ui/resource-teardown, and sends1212 // ui/notifications/size-changed as its content's size changes,@@ -48,7 +48,7 @@ function show(v){
4848 var first=view.querySelector("td[data-a]");if(first&&window.o12point)window.o12point(first);4949 fit();size();5050 }51-function viewOf(r){return r&&r.structuredContent&&r.structuredContent.view}51+function viewOf(meta){return meta&&meta["o12/view"]}5252 function context(hc){5353 if(!hc)return;if(hc.theme)theme(hc.theme);5454 var d=hc.containerDimensions;@@ -60,14 +60,14 @@ function globals(g){
6060 if(!g)return;if(g.theme)theme(g.theme);6161 if(typeof g.maxHeight==="number")room=g.maxHeight;6262 if(g.displayMode)fill=g.displayMode==="fullscreen";63- if(g.toolOutput)show(g.toolOutput.view);63+ if(g.toolResponseMetadata)show(viewOf(g.toolResponseMetadata));6464 fit();size();6565 }6666 window.addEventListener("message",function(e){6767 var m=e.data;if(!m||m.jsonrpc!=="2.0"||oai)return;6868 if(m.id===1&&(m.result||m.error)){if(m.result)context(m.result.hostContext);send({method:"ui/notifications/initialized",params:{}});return}6969 switch(m.method){70- case "ui/notifications/tool-result":show(viewOf(m.params));break;70+ case "ui/notifications/tool-result":show(viewOf(m.params&&m.params._meta));break;7171 case "ui/notifications/host-context-changed":context(m.params);break;7272 case "ui/resource-teardown":case "ping":send({id:m.id,result:{}});break;7373 }@@ -70,7 +70,6 @@ type Changed struct {
7070 type chartOut struct {7171 Chart headless.ChartMade `json:"chart"`7272 Changed73- View *View `json:"view,omitempty"`7473 }7574 7675 type pivotOut struct {@@ -133,6 +132,7 @@ func (s *Server) addWriteTools() {
133132 134133 func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in chartIn) (*sdk.CallToolResult, chartOut, error) {135134 var out chartOut135+ var view *View136136 b, err := s.open(ctx, req, in.Path)137137 if err != nil {138138 return nil, out, err@@ -141,16 +141,14 @@ func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in c
141141 if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {142142 return err143143 }144- out.View = chartView(b, w, out.Chart)144+ view = chartView(b, w, out.Chart)145145 return nil146146 })147147 if err != nil {148148 return nil, chartOut{}, err149149 }150150 out.Changed = changed151- model := out152- model.View = nil153- return forModel(model), out, nil151+ return viewResult(view), out, nil154152 }155153 156154 func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, headless.CellRun, error) {