From a7d76bc035bfa56fbb1a0ecc95f49c4ae90e1f7e Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Wed, 8 Jul 2026 15:46:39 +0000 Subject: [PATCH 1/5] Recover notebook URL via resources/read when hosts drop _meta Some MCP hosts strip _meta and structuredContent from tool results (ext-apps#696), so the viewer apps never receive the notebookUrl and can only show the text/image fallback. Cloud-notebook results now also carry an opaque HEXID marker in their (non-dropped) text content, where HEXID is the deployed notebook's base file name. When a viewer gets a result with no notebookUrl, it extracts the id and issues a resources/read for ui://wolfram/notebook-url/; the server reconstructs the cloud URL from the id statelessly (a CloudObject URL is derived locally from the base name and the user's cloud path) and returns it as the resource text, which hosts do not strip. resources/read is used instead of tools/call because the spec lets apps read any resource URI without the resolver becoming a model-visible tool. Server: - makeNotebookUIResult builds the UI result and appends the marker (cloud URLs only; inline notebooks have no reconstructable id) - readUIResource routes ui://wolfram/notebook-url/ to readNotebookURLResource, validating the id is hex before rebuilding Viewers: strip the marker in stripAgentOnlyText, extract the id, resolve via resources/read, then embed; a result-sequence guard discards a stale resolve. notebook-viewer applies the same recovery as a result-path fallback (it normally gets its URL from the tool input). Adds tests in MCPApps.wlt and documents the workaround in mcp-apps.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015Y8TNLJ63zJor51rq99TqQ --- Assets/Apps/evaluator-viewer.html | 70 +++++++++++- Assets/Apps/notebook-viewer.html | 83 +++++++++++++- Assets/Apps/wolframalpha-viewer.html | 102 +++++++++++++++-- Kernel/CommonSymbols.wl | 1 + Kernel/Tools/WolframAlpha.wl | 16 +-- Kernel/Tools/WolframLanguageEvaluator.wl | 16 +-- Kernel/UIResources.wl | 133 ++++++++++++++++++++++- Tests/MCPApps.wlt | 112 +++++++++++++++++++ docs/mcp-apps.md | 19 +++- 9 files changed, 511 insertions(+), 41 deletions(-) diff --git a/Assets/Apps/evaluator-viewer.html b/Assets/Apps/evaluator-viewer.html index 240d9a7..7094be2 100644 --- a/Assets/Apps/evaluator-viewer.html +++ b/Assets/Apps/evaluator-viewer.html @@ -142,6 +142,10 @@ // Bumped on every embedNotebook() call so a stale embed that resolves // after a newer result can be discarded instead of clobbering it. var embedGeneration = 0; + // Bumped whenever a new tool result or input supersedes the current one, so a + // late-resolving notebook-id lookup (resources/read) can be discarded instead of + // embedding a stale notebook over a newer result. + var latestResultSeq = 0; // ---- Load WolframNotebookEmbedder dynamically ---- var embedderLoaded = new Promise(function(resolve, reject) { @@ -202,6 +206,41 @@ return null; } + // ---- Recover a notebook id from the opaque content marker ---- + // Workaround for hosts that drop _meta/structuredContent from tool results + // (ext-apps#696): the tool appends "HEXID" to the text content, where + // HEXID is the deployed notebook's base file name. Unlike _meta, content survives, so + // we can recover the id here and ask the server for the full URL (resolveNotebookUrl). + function findNotebookId(content) { + if (!content || !Array.isArray(content)) return null; + for (var i = 0; i < content.length; i++) { + var item = content[i]; + if (item && item.type === "text" && typeof item.text === "string") { + var m = item.text.match(/([0-9a-fA-F]+)<\/nbid>/); + if (m) return m[1]; + } + } + return null; + } + + // ---- Ask the server to resolve a notebook id to its full cloud URL ---- + // resources/read is forwarded to the server by the host; the spec lets apps read any + // resource URI, and a resource's text payload is not stripped the way _meta is. + function resolveNotebookUrl(id) { + return sendRequest("resources/read", { + uri: "ui://wolfram/notebook-url/" + id + }).then(function(result) { + var contents = result && result.contents; + if (Array.isArray(contents)) { + for (var i = 0; i < contents.length; i++) { + var c = contents[i]; + if (c && typeof c.text === "string" && c.text) return c.text; + } + } + return null; + }); + } + // ---- Detach previous notebook ---- function detachNotebook() { if (embedded) { @@ -300,11 +339,13 @@ // never be surfaced in the widget: // * the Wolfram session-ID appended to evaluator output // * the interactive-widget notice injected by the host (e.g. Claude Desktop) + // * the opaque notebook-id marker (see findNotebookId / resolveNotebookUrl) function stripAgentOnlyText(text) { if (!text) return ""; return text .replace(/[\s\S]*?<\/system-reminder>/g, "") .replace(/\[This tool call rendered an interactive widget[\s\S]*?\]/g, "") + .replace(/[\s\S]*?<\/nbid>/g, "") .trim(); } @@ -352,6 +393,7 @@ // ---- Handle tool result: try notebook embed, or fall back to text+image ---- function handleToolResult(content, meta, structured) { + var seq = ++latestResultSeq; var notebookUrl = findNotebookUrl(content, meta, structured); log("handleToolResult: notebookUrl =", describeUrl(notebookUrl), "| meta =", describe(meta), @@ -364,11 +406,34 @@ // the notebook actually renders. renderContent(content); embedNotebook(notebookUrl); - } else { - log("handleToolResult: no notebookUrl; rendering text/image fallback"); + return; + } + + // No direct notebookUrl (the host dropped _meta/structuredContent). Try the + // workaround: recover the id from the content and resolve the URL via the + // server. Render the text/image fallback meanwhile so output is never blank. + var nbId = findNotebookId(content); + if (nbId) { + log("handleToolResult: no notebookUrl; resolving nbid", nbId, "via resources/read"); detachNotebook(); renderContent(content); + resolveNotebookUrl(nbId).then(function(url) { + if (seq !== latestResultSeq) return; // superseded by a newer result + if (url) { + embedNotebook(url); + } else { + log("handleToolResult: resources/read returned no url for", nbId); + } + }).catch(function(err) { + logError("handleToolResult: resources/read failed for", nbId, "->", err); + // text/image fallback is already on screen; leave it in place + }); + return; } + + log("handleToolResult: no notebookUrl or nbid; rendering text/image fallback"); + detachNotebook(); + renderContent(content); } // ---- postMessage transport ---- @@ -467,6 +532,7 @@ switch (method) { case "ui/notifications/tool-input": + latestResultSeq++; // invalidate any pending notebook-id resolution clearError(); detachNotebook(); resultsEl.innerHTML = ""; diff --git a/Assets/Apps/notebook-viewer.html b/Assets/Apps/notebook-viewer.html index 268ec90..0fe8c79 100644 --- a/Assets/Apps/notebook-viewer.html +++ b/Assets/Apps/notebook-viewer.html @@ -115,6 +115,9 @@ var nextId = 1; var pending = {}; var embedded = null; + // Whether an embed has been initiated (from tool-input, normally). Guards the + // tool-result fallback below against embedding the same notebook twice. + var embedRequested = false; // ---- Load WolframNotebookEmbedder dynamically ---- var embedderLoaded = new Promise(function(resolve, reject) { @@ -274,6 +277,79 @@ }); } + // ---- Find notebookUrl in structuredContent, top-level _meta, or content items ---- + function findNotebookUrl(content, meta, structured) { + if (structured && structured.notebookUrl) return structured.notebookUrl; + if (meta && meta.notebookUrl) return meta.notebookUrl; + if (!content || !Array.isArray(content)) return null; + for (var i = 0; i < content.length; i++) { + var item = content[i]; + if (item && item._meta && item._meta.notebookUrl) { + return item._meta.notebookUrl; + } + } + return null; + } + + // ---- Recover a notebook id from the opaque content marker ---- + // Workaround for hosts that drop _meta/structuredContent from tool results + // (ext-apps#696): a "HEXID" marker in the text content names the deployed + // notebook's base file name, which resolveNotebookUrl turns back into a full URL. + function findNotebookId(content) { + if (!content || !Array.isArray(content)) return null; + for (var i = 0; i < content.length; i++) { + var item = content[i]; + if (item && item.type === "text" && typeof item.text === "string") { + var m = item.text.match(/([0-9a-fA-F]+)<\/nbid>/); + if (m) return m[1]; + } + } + return null; + } + + // ---- Ask the server to resolve a notebook id to its full cloud URL ---- + // resources/read is forwarded to the server by the host; the spec lets apps read any + // resource URI, and a resource's text payload is not stripped the way _meta is. + function resolveNotebookUrl(id) { + return sendRequest("resources/read", { + uri: "ui://wolfram/notebook-url/" + id + }).then(function(result) { + var contents = result && result.contents; + if (Array.isArray(contents)) { + for (var i = 0; i < contents.length; i++) { + var c = contents[i]; + if (c && typeof c.text === "string" && c.text) return c.text; + } + } + return null; + }); + } + + // ---- Recover and embed a notebook from a tool result ---- + // This viewer normally embeds from tool-input (arguments.url). As a fallback for hosts + // that deliver a result but not the input, recover a URL from the result: directly from + // _meta/structuredContent, or via the workaround for hosts that drop those. + function recoverNotebookFromResult(params) { + if (!params) return; + var url = findNotebookUrl(params.content, params._meta, params.structuredContent); + if (url) { + embedRequested = true; + embedNotebook(url); + return; + } + var nbId = findNotebookId(params.content); + if (nbId) { + embedRequested = true; + log("recoverNotebookFromResult: resolving nbid", nbId, "via resources/read"); + resolveNotebookUrl(nbId).then(function(u) { + if (u) embedNotebook(u); + else logError("recoverNotebookFromResult: resources/read returned no url for", nbId); + }).catch(function(err) { + logError("recoverNotebookFromResult: resources/read failed for", nbId, "->", err); + }); + } + } + // ---- Incoming message handler ---- window.addEventListener("message", function(event) { if (event.source !== window.parent) return; @@ -302,6 +378,7 @@ var args = (msg.params && msg.params.arguments) || {}; var url = args.url; if (url) { + embedRequested = true; embedNotebook( url, args.allowInteract, @@ -317,11 +394,14 @@ break; case "ui/notifications/tool-result": - // Notebook is already embedding from tool-input + // Normally the notebook is already embedding from tool-input; recover from + // the result only if no embed has been initiated yet. + if (!embedRequested) recoverNotebookFromResult(msg.params); break; case "ui/notifications/tool-cancelled": loadingEl.style.display = "none"; + embedRequested = false; if (embedded) { embedded.detach(); embedded = null; @@ -333,6 +413,7 @@ break; case "ui/resource-teardown": + embedRequested = false; if (embedded) { embedded.detach(); embedded = null; diff --git a/Assets/Apps/wolframalpha-viewer.html b/Assets/Apps/wolframalpha-viewer.html index ac45aba..11c4517 100644 --- a/Assets/Apps/wolframalpha-viewer.html +++ b/Assets/Apps/wolframalpha-viewer.html @@ -223,6 +223,10 @@ // Bumped on every embedNotebook() call so a stale embed that resolves // after a newer result can be discarded instead of clobbering it. var embedGeneration = 0; + // Bumped whenever a new query, tool result, or input supersedes the current one, so a + // late-resolving notebook-id lookup (resources/read) can be discarded instead of + // embedding a stale notebook over a newer result. + var latestResultSeq = 0; // ---- Load WolframNotebookEmbedder dynamically ---- var embedderLoaded = new Promise(function(resolve, reject) { @@ -291,6 +295,54 @@ return null; } + // ---- Recover a notebook id from the opaque content marker ---- + // Workaround for hosts that drop _meta/structuredContent from tool results + // (ext-apps#696): the tool appends "HEXID" to the text content, where + // HEXID is the deployed notebook's base file name. Unlike _meta, content survives, so + // we can recover the id here and ask the server for the full URL (resolveNotebookUrl). + function findNotebookId(content) { + if (!content || !Array.isArray(content)) return null; + for (var i = 0; i < content.length; i++) { + var item = content[i]; + if (item && item.type === "text" && typeof item.text === "string") { + var m = item.text.match(/([0-9a-fA-F]+)<\/nbid>/); + if (m) return m[1]; + } + } + return null; + } + + // ---- Ask the server to resolve a notebook id to its full cloud URL ---- + // resources/read is forwarded to the server by the host; the spec lets apps read any + // resource URI, and a resource's text payload is not stripped the way _meta is. + function resolveNotebookUrl(id) { + return sendRequest("resources/read", { + uri: "ui://wolfram/notebook-url/" + id + }).then(function(result) { + var contents = result && result.contents; + if (Array.isArray(contents)) { + for (var i = 0; i < contents.length; i++) { + var c = contents[i]; + if (c && typeof c.text === "string" && c.text) return c.text; + } + } + return null; + }); + } + + // ---- Strip text intended only for the AI, not the user ---- + // The tool result may carry an opaque marker (see findNotebookId) meant purely + // for URL recovery; it must never be surfaced in the widget. The other patterns match + // hints some hosts inject, kept in sync with the evaluator viewer. + function stripAgentOnlyText(text) { + if (!text) return ""; + return text + .replace(/[\s\S]*?<\/system-reminder>/g, "") + .replace(/\[This tool call rendered an interactive widget[\s\S]*?\]/g, "") + .replace(/[\s\S]*?<\/nbid>/g, "") + .trim(); + } + // ---- Detach previous notebook ---- function detachNotebook() { if (embedded) { @@ -398,9 +450,11 @@ if (item._meta && (!item.text || item.text === "")) continue; if (item.type === "text" && item.text) { + var text = stripAgentOnlyText(item.text); + if (!text) continue; var div = document.createElement("div"); div.className = "result-text"; - div.textContent = item.text; + div.textContent = text; resultsEl.appendChild(div); } else if (item.type === "image" && item.data && item.mimeType) { var imgDiv = document.createElement("div"); @@ -416,15 +470,17 @@ function renderPlainText(text) { resultsEl.innerHTML = ""; - if (!text) return; + var clean = stripAgentOnlyText(text); + if (!clean) return; var div = document.createElement("div"); div.className = "result-text"; - div.textContent = text; + div.textContent = clean; resultsEl.appendChild(div); } // ---- Handle tool result: embed notebook, falling back to text/image ---- function handleToolResult(content, meta, structured) { + var seq = ++latestResultSeq; var notebookUrl = findNotebookUrl(content, meta, structured); log("handleToolResult: notebookUrl =", describeUrl(notebookUrl), "| meta =", describe(meta), @@ -440,16 +496,38 @@ // the notebook actually renders. renderContent(content); embedNotebook(notebookUrl); - } else { - // No notebookUrl (e.g. the host dropped _meta/structuredContent): - // fall back to the text/image content the result still carries, - // and only show an error when there is nothing renderable. + return; + } + + // No direct notebookUrl (e.g. the host dropped _meta/structuredContent). Try the + // workaround: recover the id from the content and resolve the URL via the + // server. Render the text/image fallback meanwhile so output is never blank. + var nbId = findNotebookId(content); + if (nbId) { + log("handleToolResult: no notebookUrl; resolving nbid", nbId, "via resources/read"); detachNotebook(); renderContent(content); - if (!resultsEl.children.length) { - logError("handleToolResult: no notebookUrl and no renderable content =", describe(content)); - showError("Unable to load notebook. Click Go to retry."); - } + resolveNotebookUrl(nbId).then(function(url) { + if (seq !== latestResultSeq) return; // superseded by a newer result + if (url) { + embedNotebook(url); + } else { + log("handleToolResult: resources/read returned no url for", nbId); + } + }).catch(function(err) { + logError("handleToolResult: resources/read failed for", nbId, "->", err); + // text/image fallback is already on screen; leave it in place + }); + return; + } + + // No notebookUrl and no nbid: fall back to the text/image content the result still + // carries, and only show an error when there is nothing renderable. + detachNotebook(); + renderContent(content); + if (!resultsEl.children.length) { + logError("handleToolResult: no notebookUrl and no renderable content =", describe(content)); + showError("Unable to load notebook. Click Go to retry."); } } @@ -532,6 +610,7 @@ queryInput.value = query; updateClearBtn(); + latestResultSeq++; // invalidate any pending notebook-id resolution clearError(); detachNotebook(); resultsEl.innerHTML = ""; @@ -600,6 +679,7 @@ switch (method) { case "ui/notifications/tool-input": + latestResultSeq++; // invalidate any pending notebook-id resolution var args = (msg.params && msg.params.arguments) || {}; var query = args.query || ""; currentQuery = query; diff --git a/Kernel/CommonSymbols.wl b/Kernel/CommonSymbols.wl index ba3c8c6..002329d 100644 --- a/Kernel/CommonSymbols.wl +++ b/Kernel/CommonSymbols.wl @@ -135,6 +135,7 @@ BeginPackage[ "Wolfram`AgentTools`Common`" ]; `initializeUIResources; `listUIResources; `loadUIResource; +`makeNotebookUIResult; `readUIResource; `toolUIMetadata; `withToolUIMetadata; diff --git a/Kernel/Tools/WolframAlpha.wl b/Kernel/Tools/WolframAlpha.wl index 36013e0..9933ef4 100644 --- a/Kernel/Tools/WolframAlpha.wl +++ b/Kernel/Tools/WolframAlpha.wl @@ -143,18 +143,10 @@ makeUIResult[ as_, KeyValuePattern[ { "Result" -> waResult_, "String" -> stringR "Deployed" ]; - If[ StringQ @ deployed, - (* Carry notebookUrl in _meta (per the MCP Apps spec) and in structuredContent. - Both are meant to reach the app without entering model context; some hosts - drop _meta but honor structuredContent. Claude Desktop currently drops both - (ext-apps#696) and falls back to text/image until that is fixed. *) - <| - "Content" -> textContent, - "_meta" -> <| "notebookUrl" -> deployed |>, - "StructuredContent" -> <| "notebookUrl" -> deployed |> - |>, - $Failed - ] + (* Build the UI result: notebookUrl in _meta/structuredContent (per the MCP Apps spec), + plus an opaque marker in the content as a fallback for hosts that drop both + (ext-apps#696). See makeNotebookUIResult. Returns $Failed if deployment failed. *) + makeNotebookUIResult[ textContent, deployed ] ], throwInternalFailure ]; diff --git a/Kernel/Tools/WolframLanguageEvaluator.wl b/Kernel/Tools/WolframLanguageEvaluator.wl index 5f5bd57..942b6b1 100644 --- a/Kernel/Tools/WolframLanguageEvaluator.wl +++ b/Kernel/Tools/WolframLanguageEvaluator.wl @@ -356,18 +356,10 @@ makeEvaluatorUIResult[ "Deployed" ]; - If[ StringQ @ deployed, - (* Carry notebookUrl in _meta (per the MCP Apps spec) and in structuredContent. - Both are meant to reach the app without entering model context; some hosts - drop _meta but honor structuredContent. Claude Desktop currently drops both - (ext-apps#696) and falls back to text/image until that is fixed. *) - <| - "Content" -> textContent, - "_meta" -> <| "notebookUrl" -> deployed |>, - "StructuredContent" -> <| "notebookUrl" -> deployed |> - |>, - $Failed - ] + (* Build the UI result: notebookUrl in _meta/structuredContent (per the MCP Apps spec), + plus an opaque marker in the content as a fallback for hosts that drop both + (ext-apps#696). See makeNotebookUIResult. Returns $Failed if deployment failed. *) + makeNotebookUIResult[ textContent, deployed ] ], throwInternalFailure ]; diff --git a/Kernel/UIResources.wl b/Kernel/UIResources.wl index 7c38d3d..552f19c 100644 --- a/Kernel/UIResources.wl +++ b/Kernel/UIResources.wl @@ -23,6 +23,12 @@ $includeAppearanceElements = False; $deployedNotebookRoot = "AgentTools/Notebooks"; $deployCloudNotebooks := $deployCloudNotebooks = $CloudConnected; (* must be connected to deploy notebooks *) +(* Resource URI prefix used by the "dropped _meta" workaround: apps that never received a + notebookUrl (because the host stripped _meta/structuredContent) read + "ui://wolfram/notebook-url/" to recover the full cloud URL. See makeNotebookUIResult + and readNotebookURLResource below, and the matching client code in the viewer apps. *) +$notebookURLResourcePrefix = "ui://wolfram/notebook-url/"; + (* Inline notebooks are not yet the default since there are still some issues to work out. These can be enabled via the following environment variable: *) $mcpAppsNotebookMethod := $mcpAppsNotebookMethod = Environment[ "MCP_APPS_NOTEBOOK_METHOD" ]; @@ -79,6 +85,53 @@ deployCloudNotebookForMCPApp[ nb_Notebook, identifier_ ] := Enclose[ deployCloudNotebookForMCPApp // endDefinition; +(* ::**************************************************************************************************************:: *) +(* ::Subsection::Closed:: *) +(*makeNotebookUIResult*) +(* Builds the UI-enhanced tool result for a deployed notebook. The notebookUrl is carried in + _meta and structuredContent (per the MCP Apps spec) so it reaches the app without entering + model context. Because some hosts drop both (ext-apps#696), we also append an opaque + "..." marker to the content: the app extracts it and recovers the URL via + resources/read (see readNotebookURLResource). The marker text is intentionally cryptic so + the model ignores it, and each viewer strips it before rendering. *) +makeNotebookUIResult // beginDefinition; + +makeNotebookUIResult[ textContent_List, deployed_String ] := <| + "Content" -> appendNotebookIDMarker[ textContent, deployed ], + "_meta" -> <| "notebookUrl" -> deployed |>, + "StructuredContent" -> <| "notebookUrl" -> deployed |> +|>; + +(* Deployment failed (deployCloudNotebookForMCPApp returned $Failed): no UI result. *) +makeNotebookUIResult[ _List, _ ] := $Failed; + +makeNotebookUIResult // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*appendNotebookIDMarker*) +appendNotebookIDMarker // beginDefinition; + +(* Only cloud URLs have a recoverable base name. Inline notebooks (MCP_APPS_NOTEBOOK_METHOD= + "Inline") carry the whole serialized notebook as the value, which cannot be reconstructed + from an id, so no marker is appended in that case. *) +appendNotebookIDMarker[ textContent_List, url_String ] /; StringStartsQ[ url, "http" ] := + Append[ textContent, <| "type" -> "text", "text" -> "" <> notebookIDFromURL[ url ] <> "" |> ]; + +appendNotebookIDMarker[ textContent_List, _ ] := textContent; + +appendNotebookIDMarker // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*notebookIDFromURL*) +(* The deployed notebook's id is the base name of its cloud URL, e.g. + ".../AgentTools/Notebooks/08aba9b360121fee.nb" -> "08aba9b360121fee". Uses plain string ops + (cloud URLs are always "/"-separated) so it is platform independent. *) +notebookIDFromURL // beginDefinition; +notebookIDFromURL[ url_String ] := StringDelete[ Last @ StringSplit[ url, "/" ], ".nb" ~~ EndOfString ]; +notebookIDFromURL // endDefinition; + (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) (*delayedDisplay*) @@ -260,8 +313,27 @@ listUIResources // endDefinition; readUIResource // beginDefinition; readUIResource[ msg_Association, req_ ] := Enclose[ - Module[ { uri, resource }, + Module[ { uri }, uri = ConfirmBy[ msg[[ "params", "uri" ]], StringQ, "URI" ]; + (* Notebook-url resolution requests (the "dropped _meta" workaround) are handled + separately; everything else is a registered HTML app resource. *) + If[ StringStartsQ[ uri, $notebookURLResourcePrefix ], + readNotebookURLResource @ uri, + readRegisteredUIResource @ uri + ] + ], + throwInternalFailure +]; + +readUIResource // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*readRegisteredUIResource*) +readRegisteredUIResource // beginDefinition; + +readRegisteredUIResource[ uri_String ] := + Module[ { resource }, resource = Lookup[ $uiResourceRegistry, uri, Missing[ "NotFound" ] ]; If[ MissingQ @ resource, throwFailure[ "UIResourceNotFound", uri ], @@ -274,11 +346,68 @@ readUIResource[ msg_Association, req_ ] := Enclose[ |> } |> ] + ]; + +readRegisteredUIResource // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*readNotebookURLResource*) +(* Reconstructs the full cloud notebook URL from the hex id embedded in the URI and returns it + as the resource text. The text payload of a resource is not stripped by hosts (unlike _meta), + so this is how the workaround gets the URL to the app. *) +readNotebookURLResource // beginDefinition; + +readNotebookURLResource[ uri_String ] := Enclose[ + Module[ { id, url }, + id = notebookIDFromResourceURI @ uri; + (* Restrict to hex ids: keeps arbitrary path segments out of the reconstructed + CloudObject and treats anything else as an unknown resource. *) + If[ notebookIDStringQ @ id, + url = ConfirmBy[ resolveNotebookURLFromID @ id, StringQ, "URL" ]; + <| "contents" -> { <| "uri" -> uri, "mimeType" -> "text/plain", "text" -> url |> } |>, + (* else: not a valid notebook id *) + throwFailure[ "UIResourceNotFound", uri ] + ] ], throwInternalFailure ]; -readUIResource // endDefinition; +readNotebookURLResource // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*notebookIDFromResourceURI*) +notebookIDFromResourceURI // beginDefinition; +notebookIDFromResourceURI[ uri_String ] := StringDelete[ uri, StartOfString ~~ $notebookURLResourcePrefix ]; +notebookIDFromResourceURI // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*notebookIDStringQ*) +notebookIDStringQ // beginDefinition; +notebookIDStringQ[ id_String ] := StringLength @ id > 0 && StringMatchQ[ id, HexadecimalCharacter.. ]; +notebookIDStringQ[ _ ] := False; +notebookIDStringQ // endDefinition; + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*resolveNotebookURLFromID*) +(* Deterministically rebuilds the deployed URL from its base name; this is the exact inverse of + notebookIDFromURL and matches what CloudDeploy returned for the same target (verified: the + CloudObject URL is constructed locally from the base name and the user's cloud path). *) +resolveNotebookURLFromID // beginDefinition; + +resolveNotebookURLFromID[ id_String ] := Enclose[ + ConfirmBy[ + First @ CloudObject @ FileNameJoin @ { CloudObject @ $deployedNotebookRoot, id <> ".nb" }, + StringQ, + "URL" + ], + throwInternalFailure +]; + +resolveNotebookURLFromID // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) diff --git a/Tests/MCPApps.wlt b/Tests/MCPApps.wlt index 94d3a73..45f6c5c 100644 --- a/Tests/MCPApps.wlt +++ b/Tests/MCPApps.wlt @@ -688,6 +688,52 @@ VerificationTest[ TestID -> "ReadUIResource-InvalidURIType@@Tests/MCPApps.wlt:675,1-689,2" ] +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*Notebook URL Resolution (dropped-_meta workaround)*) + +(* A non-hex id under the notebook-url prefix is rejected (guards against path segments). *) +VerificationTest[ + Quiet @ Block[ { + Wolfram`AgentTools`Common`$clientSupportsUI = True, + Wolfram`AgentTools`Common`$uiResourceRegistry + }, + Wolfram`AgentTools`Common`initializeUIResources[ ]; + Wolfram`AgentTools`Common`readUIResource[ + <| "params" -> <| "uri" -> "ui://wolfram/notebook-url/not-a-hex-id" |> |>, + <| "jsonrpc" -> "2.0", "id" -> 8 |> + ] + ], + _Failure, + SameTest -> MatchQ, + TestID -> "ReadUIResource-NotebookURLNonHex" +] + +(* A valid hex id reconstructs the full cloud URL, whose base name round-trips back to the id. + Reconstruction needs an active cloud connection; when there is none, the test is a no-op. *) +VerificationTest[ + Block[ { + Wolfram`AgentTools`Common`$clientSupportsUI = True, + Wolfram`AgentTools`Common`$uiResourceRegistry + }, + Wolfram`AgentTools`Common`initializeUIResources[ ]; + If[ TrueQ @ $CloudConnected, + Module[ { response, text }, + response = Wolfram`AgentTools`Common`readUIResource[ + <| "params" -> <| "uri" -> "ui://wolfram/notebook-url/08aba9b360121fee" |> |>, + <| "jsonrpc" -> "2.0", "id" -> 9 |> + ]; + text = response[[ "contents", 1, "text" ]]; + StringQ @ text && StringStartsQ[ text, "http" ] && StringEndsQ[ text, "/08aba9b360121fee.nb" ] + ], + True + ] + ], + True, + SameTest -> Equal, + TestID -> "ReadUIResource-NotebookURLResolves" +] + (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) (*handleResourceRead*) @@ -1262,6 +1308,72 @@ VerificationTest[ TestID -> "DeployCloudNotebookForMCPApp-InlineAssertsDeployEnabled@@Tests/MCPApps.wlt:1250,1-1263,2" ] +(* ::**************************************************************************************************************:: *) +(* ::Subsection::Closed:: *) +(*makeNotebookUIResult*) + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*Cloud URL Appends nbid Marker*) +VerificationTest[ + Module[ { url, result }, + url = "https://www.wolframcloud.com/obj/user/AgentTools/Notebooks/deadbeef12345678.nb"; + result = Wolfram`AgentTools`Common`makeNotebookUIResult[ + { <| "type" -> "text", "text" -> "1 + 1 = 2" |> }, + url + ]; + { + Length @ result[ "Content" ], + Last @ result[ "Content" ], + result[ "_meta", "notebookUrl" ], + result[ "StructuredContent", "notebookUrl" ] + } + ], + { + 2, + <| "type" -> "text", "text" -> "deadbeef12345678" |>, + "https://www.wolframcloud.com/obj/user/AgentTools/Notebooks/deadbeef12345678.nb", + "https://www.wolframcloud.com/obj/user/AgentTools/Notebooks/deadbeef12345678.nb" + }, + SameTest -> MatchQ, + TestID -> "MakeNotebookUIResult-CloudURLAppendsMarker" +] + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*Deployment Failure Returns $Failed*) +VerificationTest[ + Wolfram`AgentTools`Common`makeNotebookUIResult[ + { <| "type" -> "text", "text" -> "x" |> }, + $Failed + ], + $Failed, + SameTest -> MatchQ, + TestID -> "MakeNotebookUIResult-DeployFailed" +] + +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*Inline (Non-http) Value Omits Marker*) +(* Inline notebooks have no reconstructable id, so no marker is appended; the value is still + carried in _meta/structuredContent for spec-compliant hosts. *) +VerificationTest[ + Module[ { serialized, result }, + serialized = "Notebook[{Cell[\"1 + 1\", \"Input\"]}]"; + result = Wolfram`AgentTools`Common`makeNotebookUIResult[ + { <| "type" -> "text", "text" -> "x" |> }, + serialized + ]; + { result[ "Content" ], result[ "_meta", "notebookUrl" ] } + ], + { + { <| "type" -> "text", "text" -> "x" |> }, + "Notebook[{Cell[\"1 + 1\", \"Input\"]}]" + }, + SameTest -> MatchQ, + TestID -> "MakeNotebookUIResult-InlineNoMarker" +] + (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) (*delayedDisplay*) diff --git a/docs/mcp-apps.md b/docs/mcp-apps.md index f8f660d..4f61a9b 100644 --- a/docs/mcp-apps.md +++ b/docs/mcp-apps.md @@ -97,6 +97,22 @@ Inline embedding is **experimental and not yet the default**. Both methods curre When inline embedding is active, graphics can render empty in the embedded notebook. The `delayedDisplay` helper works around this for `WolframLanguageEvaluator` output: any output boxes containing `GraphicsBox`/`Graphics3DBox` are serialized and reconstructed asynchronously inside a `DynamicModule` (showing a progress indicator until ready). Outside inline mode, or for output without graphics, `delayedDisplay` returns the boxes unchanged. +### Recovering the Notebook URL When `_meta` Is Dropped + +The `notebookUrl` is delivered to the app through `_meta` and `structuredContent` (per the MCP Apps spec), both of which are meant to reach the app without entering model context. Some hosts, however, drop both from tool results ([ext-apps#696](https://github.com/modelcontextprotocol/ext-apps/issues/696)), so the app never receives the URL directly and can only render the text/image fallback. + +To recover in that case, `makeNotebookUIResult` also appends an opaque marker to the (non-dropped) text content of cloud-notebook results: + +``` +56a24661be6b3368 +``` + +The hex value is the deployed notebook's base file name. The marker is intentionally cryptic so the model ignores it, and every viewer strips it (via `stripAgentOnlyText`) before rendering. + +When a viewer receives a result with no `notebookUrl`, it extracts this id and issues a `resources/read` for `ui://wolfram/notebook-url/`. The host forwards the read to the server (the MCP Apps spec permits apps to read any resource URI), and `readNotebookURLResource` reconstructs the full cloud URL from the id — statelessly, since a `CloudObject` URL is derived locally from the base name and the user's cloud path. Unlike `_meta`, a resource's text payload is not stripped, so the URL reaches the app, which then embeds the notebook as usual. + +This path applies only to cloud delivery: inline notebooks (`MCP_APPS_NOTEBOOK_METHOD="Inline"`) have no reconstructable id, so no marker is appended and `_meta`/`structuredContent` remains their only route. The `notebook-viewer` app normally receives its URL through the tool **input** (`arguments.url`), which is unaffected by the dropped-`_meta` issue; it applies the same recovery only as a fallback when a result arrives without a prior embed. + ## Available UI Resources | URI | HTML Asset | Description | @@ -231,12 +247,13 @@ Add tests in `Tests/` for the new resource. See the existing test files (`Tests/ | `$toolUIAssociations` | `Common` | Mapping of tool names to UI resource URIs (entries may be `RuleDelayed` to gate on `$deployCloudNotebooks`) | | `$deployCloudNotebooks` | `Common` | Session flag gating cloud notebook deployment; initialized from `$CloudConnected` and set to `False` after a deployment failure | | `deployCloudNotebookForMCPApp` | `Common` | Shared helper that delivers a notebook for a UI-enhanced tool result — deploys to the cloud and returns a URL, or returns the serialized notebook when `MCP_APPS_NOTEBOOK_METHOD` is `"Inline"`; disables `$deployCloudNotebooks` on a deploy failure | +| `makeNotebookUIResult` | `Common` | Builds the UI-enhanced tool result from the text content and the delivered notebook value: carries `notebookUrl` in `_meta`/`structuredContent`, and appends the opaque `` marker for cloud URLs (the dropped-`_meta` workaround); returns `$Failed` when deployment failed | | `delayedDisplay` | `Common` | Wraps `WolframLanguageEvaluator` output boxes so graphics reconstruct asynchronously when notebooks are embedded inline; a no-op outside inline mode or for graphics-free output | | `clientSupportsUIQ` | `Common` | Checks if an `initialize` message advertises UI support | | `mcpAppsEnabledQ` | `Common` | Checks the `MCP_APPS_ENABLED` environment variable | | `initializeUIResources` | `Common` | Loads HTML assets into the resource registry | | `listUIResources` | `Common` | Returns the resource list for `resources/list` | -| `readUIResource` | `Common` | Handles `resources/read` requests | +| `readUIResource` | `Common` | Handles `resources/read` requests: registered HTML app resources, and `ui://wolfram/notebook-url/` lookups that reconstruct a cloud notebook URL (the dropped-`_meta` workaround) | | `toolUIMetadata` | `Common` | Returns `_meta.ui` for a tool name | | `withToolUIMetadata` | `Common` | Augments a tool list with UI metadata | From 60ab631c47c57de60226dd04a65c32805b8d1370 Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Wed, 8 Jul 2026 16:23:22 +0000 Subject: [PATCH 2/5] Embed notebook URL in content marker instead of resources/read The previous commit recovered the URL by having the app read ui://wolfram/notebook-url/ via resources/read. That does not work on the hosts we care about: a host that drops _meta/structuredContent also does not forward app-initiated resources/read, answering it with JSON-RPC -32601 "Method not found", so the id -> URL round-trip never completes. Switch to a self-contained approach. The one channel that survives is the tool result content, so makeNotebookUIResult now embeds the URL directly in the (non-dropped) text content inside an ...... marker. The wrapper text is addressed to the model (the notebook is already shown; do not use the URL); the viewers extract the URL locally via findNotebookUrl -> extractNotebookUrlMarker with no server round-trip, and strip the whole marker from the display. Removes the now-dead server resolver (readNotebookURLResource, resolveNotebookURLFromID, the hex-id helpers, the ui://wolfram/notebook-url routing) and reverts readUIResource to its registry-only form, along with the client resolveNotebookUrl/latestResultSeq machinery. Net -222 lines. Verified end-to-end against real tool output: the extracted URL matches _meta.notebookUrl, the marker is stripped from the user-facing display, and the real output is preserved. Updates tests and docs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015Y8TNLJ63zJor51rq99TqQ --- Assets/Apps/evaluator-viewer.html | 81 ++++------------ Assets/Apps/notebook-viewer.html | 52 +++------- Assets/Apps/wolframalpha-viewer.html | 91 +++++------------- Kernel/UIResources.wl | 136 ++++++--------------------- Tests/MCPApps.wlt | 76 +++++---------- docs/mcp-apps.md | 16 ++-- 6 files changed, 115 insertions(+), 337 deletions(-) diff --git a/Assets/Apps/evaluator-viewer.html b/Assets/Apps/evaluator-viewer.html index 7094be2..6f60a27 100644 --- a/Assets/Apps/evaluator-viewer.html +++ b/Assets/Apps/evaluator-viewer.html @@ -142,10 +142,6 @@ // Bumped on every embedNotebook() call so a stale embed that resolves // after a newer result can be discarded instead of clobbering it. var embedGeneration = 0; - // Bumped whenever a new tool result or input supersedes the current one, so a - // late-resolving notebook-id lookup (resources/read) can be discarded instead of - // embedding a stale notebook over a newer result. - var latestResultSeq = 0; // ---- Load WolframNotebookEmbedder dynamically ---- var embedderLoaded = new Promise(function(resolve, reject) { @@ -190,9 +186,12 @@ loadingEl.style.display = "none"; } - // ---- Find notebookUrl in structuredContent, top-level _meta, or content items ---- - // structuredContent is the spec's UI-only channel (kept out of model context); - // _meta is the fallback for spec-compliant hosts and direct tools/call responses. + // ---- Find notebookUrl in structuredContent, _meta, content items, or content marker ---- + // structuredContent is the spec's UI-only channel (kept out of model context); _meta is + // the fallback for spec-compliant hosts and direct tools/call responses. When a host + // drops both (ext-apps#696), the tool also embeds the URL in an + // ...... marker in the text content (see + // extractNotebookUrlMarker), which the host does not strip. function findNotebookUrl(content, meta, structured) { if (structured && structured.notebookUrl) return structured.notebookUrl; if (meta && meta.notebookUrl) return meta.notebookUrl; @@ -203,44 +202,25 @@ return item._meta.notebookUrl; } } - return null; + return extractNotebookUrlMarker(content); } - // ---- Recover a notebook id from the opaque content marker ---- - // Workaround for hosts that drop _meta/structuredContent from tool results - // (ext-apps#696): the tool appends "HEXID" to the text content, where - // HEXID is the deployed notebook's base file name. Unlike _meta, content survives, so - // we can recover the id here and ask the server for the full URL (resolveNotebookUrl). - function findNotebookId(content) { + // ---- Recover the URL from the ...... content marker ---- + // Fallback for hosts that drop _meta/structuredContent (ext-apps#696). The marker's + // wrapper text tells the model the notebook is already shown and the URL is not for it + // to use; stripAgentOnlyText removes the whole marker before anything reaches the user. + function extractNotebookUrlMarker(content) { if (!content || !Array.isArray(content)) return null; for (var i = 0; i < content.length; i++) { var item = content[i]; if (item && item.type === "text" && typeof item.text === "string") { - var m = item.text.match(/([0-9a-fA-F]+)<\/nbid>/); - if (m) return m[1]; + var m = item.text.match(/[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/); + if (m && m[1].trim()) return m[1].trim(); } } return null; } - // ---- Ask the server to resolve a notebook id to its full cloud URL ---- - // resources/read is forwarded to the server by the host; the spec lets apps read any - // resource URI, and a resource's text payload is not stripped the way _meta is. - function resolveNotebookUrl(id) { - return sendRequest("resources/read", { - uri: "ui://wolfram/notebook-url/" + id - }).then(function(result) { - var contents = result && result.contents; - if (Array.isArray(contents)) { - for (var i = 0; i < contents.length; i++) { - var c = contents[i]; - if (c && typeof c.text === "string" && c.text) return c.text; - } - } - return null; - }); - } - // ---- Detach previous notebook ---- function detachNotebook() { if (embedded) { @@ -339,13 +319,13 @@ // never be surfaced in the widget: // * the Wolfram session-ID appended to evaluator output // * the interactive-widget notice injected by the host (e.g. Claude Desktop) - // * the opaque notebook-id marker (see findNotebookId / resolveNotebookUrl) + // * the ... notebook-URL marker (see extractNotebookUrlMarker) function stripAgentOnlyText(text) { if (!text) return ""; return text .replace(/[\s\S]*?<\/system-reminder>/g, "") .replace(/\[This tool call rendered an interactive widget[\s\S]*?\]/g, "") - .replace(/[\s\S]*?<\/nbid>/g, "") + .replace(/[\s\S]*?<\/internal>/g, "") .trim(); } @@ -393,7 +373,6 @@ // ---- Handle tool result: try notebook embed, or fall back to text+image ---- function handleToolResult(content, meta, structured) { - var seq = ++latestResultSeq; var notebookUrl = findNotebookUrl(content, meta, structured); log("handleToolResult: notebookUrl =", describeUrl(notebookUrl), "| meta =", describe(meta), @@ -406,34 +385,11 @@ // the notebook actually renders. renderContent(content); embedNotebook(notebookUrl); - return; - } - - // No direct notebookUrl (the host dropped _meta/structuredContent). Try the - // workaround: recover the id from the content and resolve the URL via the - // server. Render the text/image fallback meanwhile so output is never blank. - var nbId = findNotebookId(content); - if (nbId) { - log("handleToolResult: no notebookUrl; resolving nbid", nbId, "via resources/read"); + } else { + log("handleToolResult: no notebookUrl; rendering text/image fallback"); detachNotebook(); renderContent(content); - resolveNotebookUrl(nbId).then(function(url) { - if (seq !== latestResultSeq) return; // superseded by a newer result - if (url) { - embedNotebook(url); - } else { - log("handleToolResult: resources/read returned no url for", nbId); - } - }).catch(function(err) { - logError("handleToolResult: resources/read failed for", nbId, "->", err); - // text/image fallback is already on screen; leave it in place - }); - return; } - - log("handleToolResult: no notebookUrl or nbid; rendering text/image fallback"); - detachNotebook(); - renderContent(content); } // ---- postMessage transport ---- @@ -532,7 +488,6 @@ switch (method) { case "ui/notifications/tool-input": - latestResultSeq++; // invalidate any pending notebook-id resolution clearError(); detachNotebook(); resultsEl.innerHTML = ""; diff --git a/Assets/Apps/notebook-viewer.html b/Assets/Apps/notebook-viewer.html index 0fe8c79..4da658a 100644 --- a/Assets/Apps/notebook-viewer.html +++ b/Assets/Apps/notebook-viewer.html @@ -277,7 +277,7 @@ }); } - // ---- Find notebookUrl in structuredContent, top-level _meta, or content items ---- + // ---- Find notebookUrl in structuredContent, _meta, content items, or content marker ---- function findNotebookUrl(content, meta, structured) { if (structured && structured.notebookUrl) return structured.notebookUrl; if (meta && meta.notebookUrl) return meta.notebookUrl; @@ -288,65 +288,35 @@ return item._meta.notebookUrl; } } - return null; + return extractNotebookUrlMarker(content); } - // ---- Recover a notebook id from the opaque content marker ---- - // Workaround for hosts that drop _meta/structuredContent from tool results - // (ext-apps#696): a "HEXID" marker in the text content names the deployed - // notebook's base file name, which resolveNotebookUrl turns back into a full URL. - function findNotebookId(content) { + // ---- Recover the URL from the ...... content marker ---- + // Fallback for hosts that drop _meta/structuredContent (ext-apps#696). The marker's + // wrapper text tells the model the notebook is already shown and the URL is not for it + // to use. (This viewer renders no text, so there is nothing to strip from the display.) + function extractNotebookUrlMarker(content) { if (!content || !Array.isArray(content)) return null; for (var i = 0; i < content.length; i++) { var item = content[i]; if (item && item.type === "text" && typeof item.text === "string") { - var m = item.text.match(/([0-9a-fA-F]+)<\/nbid>/); - if (m) return m[1]; + var m = item.text.match(/[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/); + if (m && m[1].trim()) return m[1].trim(); } } return null; } - // ---- Ask the server to resolve a notebook id to its full cloud URL ---- - // resources/read is forwarded to the server by the host; the spec lets apps read any - // resource URI, and a resource's text payload is not stripped the way _meta is. - function resolveNotebookUrl(id) { - return sendRequest("resources/read", { - uri: "ui://wolfram/notebook-url/" + id - }).then(function(result) { - var contents = result && result.contents; - if (Array.isArray(contents)) { - for (var i = 0; i < contents.length; i++) { - var c = contents[i]; - if (c && typeof c.text === "string" && c.text) return c.text; - } - } - return null; - }); - } - // ---- Recover and embed a notebook from a tool result ---- // This viewer normally embeds from tool-input (arguments.url). As a fallback for hosts - // that deliver a result but not the input, recover a URL from the result: directly from - // _meta/structuredContent, or via the workaround for hosts that drop those. + // that deliver a result but not the input, recover a URL from the result: from + // _meta/structuredContent, or from the content marker for hosts that drop those. function recoverNotebookFromResult(params) { if (!params) return; var url = findNotebookUrl(params.content, params._meta, params.structuredContent); if (url) { embedRequested = true; embedNotebook(url); - return; - } - var nbId = findNotebookId(params.content); - if (nbId) { - embedRequested = true; - log("recoverNotebookFromResult: resolving nbid", nbId, "via resources/read"); - resolveNotebookUrl(nbId).then(function(u) { - if (u) embedNotebook(u); - else logError("recoverNotebookFromResult: resources/read returned no url for", nbId); - }).catch(function(err) { - logError("recoverNotebookFromResult: resources/read failed for", nbId, "->", err); - }); } } diff --git a/Assets/Apps/wolframalpha-viewer.html b/Assets/Apps/wolframalpha-viewer.html index 11c4517..e4b2fe6 100644 --- a/Assets/Apps/wolframalpha-viewer.html +++ b/Assets/Apps/wolframalpha-viewer.html @@ -223,10 +223,6 @@ // Bumped on every embedNotebook() call so a stale embed that resolves // after a newer result can be discarded instead of clobbering it. var embedGeneration = 0; - // Bumped whenever a new query, tool result, or input supersedes the current one, so a - // late-resolving notebook-id lookup (resources/read) can be discarded instead of - // embedding a stale notebook over a newer result. - var latestResultSeq = 0; // ---- Load WolframNotebookEmbedder dynamically ---- var embedderLoaded = new Promise(function(resolve, reject) { @@ -279,7 +275,7 @@ clearBtn.style.display = queryInput.value ? "flex" : "none"; } - // ---- Find notebookUrl in structuredContent, top-level _meta, or content items ---- + // ---- Find notebookUrl in structuredContent, _meta, content items, or content marker ---- function findNotebookUrl(content, meta, structured) { // structuredContent is the spec's UI-only channel (kept out of model context) if (structured && structured.notebookUrl) return structured.notebookUrl; @@ -292,54 +288,36 @@ return item._meta.notebookUrl; } } - return null; + // Host dropped _meta/structuredContent (ext-apps#696): recover from the marker. + return extractNotebookUrlMarker(content); } - // ---- Recover a notebook id from the opaque content marker ---- - // Workaround for hosts that drop _meta/structuredContent from tool results - // (ext-apps#696): the tool appends "HEXID" to the text content, where - // HEXID is the deployed notebook's base file name. Unlike _meta, content survives, so - // we can recover the id here and ask the server for the full URL (resolveNotebookUrl). - function findNotebookId(content) { + // ---- Recover the URL from the ...... content marker ---- + // Fallback for hosts that drop _meta/structuredContent (ext-apps#696). The marker's + // wrapper text tells the model the notebook is already shown and the URL is not for it + // to use; stripAgentOnlyText removes the whole marker before anything reaches the user. + function extractNotebookUrlMarker(content) { if (!content || !Array.isArray(content)) return null; for (var i = 0; i < content.length; i++) { var item = content[i]; if (item && item.type === "text" && typeof item.text === "string") { - var m = item.text.match(/([0-9a-fA-F]+)<\/nbid>/); - if (m) return m[1]; + var m = item.text.match(/[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/); + if (m && m[1].trim()) return m[1].trim(); } } return null; } - // ---- Ask the server to resolve a notebook id to its full cloud URL ---- - // resources/read is forwarded to the server by the host; the spec lets apps read any - // resource URI, and a resource's text payload is not stripped the way _meta is. - function resolveNotebookUrl(id) { - return sendRequest("resources/read", { - uri: "ui://wolfram/notebook-url/" + id - }).then(function(result) { - var contents = result && result.contents; - if (Array.isArray(contents)) { - for (var i = 0; i < contents.length; i++) { - var c = contents[i]; - if (c && typeof c.text === "string" && c.text) return c.text; - } - } - return null; - }); - } - // ---- Strip text intended only for the AI, not the user ---- - // The tool result may carry an opaque marker (see findNotebookId) meant purely - // for URL recovery; it must never be surfaced in the widget. The other patterns match - // hints some hosts inject, kept in sync with the evaluator viewer. + // The tool result may carry an ... marker (see + // extractNotebookUrlMarker) that must never be surfaced in the widget. The other + // patterns match hints some hosts inject, kept in sync with the evaluator viewer. function stripAgentOnlyText(text) { if (!text) return ""; return text .replace(/[\s\S]*?<\/system-reminder>/g, "") .replace(/\[This tool call rendered an interactive widget[\s\S]*?\]/g, "") - .replace(/[\s\S]*?<\/nbid>/g, "") + .replace(/[\s\S]*?<\/internal>/g, "") .trim(); } @@ -480,7 +458,6 @@ // ---- Handle tool result: embed notebook, falling back to text/image ---- function handleToolResult(content, meta, structured) { - var seq = ++latestResultSeq; var notebookUrl = findNotebookUrl(content, meta, structured); log("handleToolResult: notebookUrl =", describeUrl(notebookUrl), "| meta =", describe(meta), @@ -496,38 +473,16 @@ // the notebook actually renders. renderContent(content); embedNotebook(notebookUrl); - return; - } - - // No direct notebookUrl (e.g. the host dropped _meta/structuredContent). Try the - // workaround: recover the id from the content and resolve the URL via the - // server. Render the text/image fallback meanwhile so output is never blank. - var nbId = findNotebookId(content); - if (nbId) { - log("handleToolResult: no notebookUrl; resolving nbid", nbId, "via resources/read"); + } else { + // No notebookUrl (e.g. the host dropped _meta/structuredContent and the result + // carries no marker): fall back to the text/image content the result still + // carries, and only show an error when there is nothing renderable. detachNotebook(); renderContent(content); - resolveNotebookUrl(nbId).then(function(url) { - if (seq !== latestResultSeq) return; // superseded by a newer result - if (url) { - embedNotebook(url); - } else { - log("handleToolResult: resources/read returned no url for", nbId); - } - }).catch(function(err) { - logError("handleToolResult: resources/read failed for", nbId, "->", err); - // text/image fallback is already on screen; leave it in place - }); - return; - } - - // No notebookUrl and no nbid: fall back to the text/image content the result still - // carries, and only show an error when there is nothing renderable. - detachNotebook(); - renderContent(content); - if (!resultsEl.children.length) { - logError("handleToolResult: no notebookUrl and no renderable content =", describe(content)); - showError("Unable to load notebook. Click Go to retry."); + if (!resultsEl.children.length) { + logError("handleToolResult: no notebookUrl and no renderable content =", describe(content)); + showError("Unable to load notebook. Click Go to retry."); + } } } @@ -610,7 +565,6 @@ queryInput.value = query; updateClearBtn(); - latestResultSeq++; // invalidate any pending notebook-id resolution clearError(); detachNotebook(); resultsEl.innerHTML = ""; @@ -679,7 +633,6 @@ switch (method) { case "ui/notifications/tool-input": - latestResultSeq++; // invalidate any pending notebook-id resolution var args = (msg.params && msg.params.arguments) || {}; var query = args.query || ""; currentQuery = query; diff --git a/Kernel/UIResources.wl b/Kernel/UIResources.wl index 552f19c..5b7b1e1 100644 --- a/Kernel/UIResources.wl +++ b/Kernel/UIResources.wl @@ -23,12 +23,6 @@ $includeAppearanceElements = False; $deployedNotebookRoot = "AgentTools/Notebooks"; $deployCloudNotebooks := $deployCloudNotebooks = $CloudConnected; (* must be connected to deploy notebooks *) -(* Resource URI prefix used by the "dropped _meta" workaround: apps that never received a - notebookUrl (because the host stripped _meta/structuredContent) read - "ui://wolfram/notebook-url/" to recover the full cloud URL. See makeNotebookUIResult - and readNotebookURLResource below, and the matching client code in the viewer apps. *) -$notebookURLResourcePrefix = "ui://wolfram/notebook-url/"; - (* Inline notebooks are not yet the default since there are still some issues to work out. These can be enabled via the following environment variable: *) $mcpAppsNotebookMethod := $mcpAppsNotebookMethod = Environment[ "MCP_APPS_NOTEBOOK_METHOD" ]; @@ -90,14 +84,15 @@ deployCloudNotebookForMCPApp // endDefinition; (*makeNotebookUIResult*) (* Builds the UI-enhanced tool result for a deployed notebook. The notebookUrl is carried in _meta and structuredContent (per the MCP Apps spec) so it reaches the app without entering - model context. Because some hosts drop both (ext-apps#696), we also append an opaque - "..." marker to the content: the app extracts it and recovers the URL via - resources/read (see readNotebookURLResource). The marker text is intentionally cryptic so - the model ignores it, and each viewer strips it before rendering. *) + model context. Because some hosts drop both (ext-apps#696) and also do not forward + app-initiated resources/read, we additionally append the URL to the (non-dropped) text + content inside an ...... marker. The wrapper text tells the + model the notebook is already shown and the URL is not for it to use; each viewer extracts + the URL and strips the whole marker before rendering. *) makeNotebookUIResult // beginDefinition; makeNotebookUIResult[ textContent_List, deployed_String ] := <| - "Content" -> appendNotebookIDMarker[ textContent, deployed ], + "Content" -> appendNotebookURLMarker[ textContent, deployed ], "_meta" -> <| "notebookUrl" -> deployed |>, "StructuredContent" -> <| "notebookUrl" -> deployed |> |>; @@ -109,28 +104,35 @@ makeNotebookUIResult // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) -(*appendNotebookIDMarker*) -appendNotebookIDMarker // beginDefinition; +(*appendNotebookURLMarker*) +appendNotebookURLMarker // beginDefinition; -(* Only cloud URLs have a recoverable base name. Inline notebooks (MCP_APPS_NOTEBOOK_METHOD= - "Inline") carry the whole serialized notebook as the value, which cannot be reconstructed - from an id, so no marker is appended in that case. *) -appendNotebookIDMarker[ textContent_List, url_String ] /; StringStartsQ[ url, "http" ] := - Append[ textContent, <| "type" -> "text", "text" -> "" <> notebookIDFromURL[ url ] <> "" |> ]; +(* Only cloud URLs are embedded this way. Inline notebooks (MCP_APPS_NOTEBOOK_METHOD="Inline") + carry the whole serialized notebook as the value and are delivered via _meta only. *) +appendNotebookURLMarker[ textContent_List, url_String ] /; StringStartsQ[ url, "http" ] := + Append[ textContent, <| "type" -> "text", "text" -> notebookURLMarkerText[ url ] |> ]; -appendNotebookIDMarker[ textContent_List, _ ] := textContent; +appendNotebookURLMarker[ textContent_List, _ ] := textContent; -appendNotebookIDMarker // endDefinition; +appendNotebookURLMarker // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) -(*notebookIDFromURL*) -(* The deployed notebook's id is the base name of its cloud URL, e.g. - ".../AgentTools/Notebooks/08aba9b360121fee.nb" -> "08aba9b360121fee". Uses plain string ops - (cloud URLs are always "/"-separated) so it is platform independent. *) -notebookIDFromURL // beginDefinition; -notebookIDFromURL[ url_String ] := StringDelete[ Last @ StringSplit[ url, "/" ], ".nb" ~~ EndOfString ]; -notebookIDFromURL // endDefinition; +(*notebookURLMarkerText*) +(* Wraps the URL in an instruction the model can read (so it ignores the URL) and in tags + the viewer matches. The format lives here on the WL side; the viewers' extraction and strip + regexes must stay in sync with these tags. *) +notebookURLMarkerText // beginDefinition; + +notebookURLMarkerText[ url_String ] := StringJoin[ + "", + "This tool call was displayed to the user as an interactive notebook, which they can already see. ", + "The URL below only renders that notebook; you do not need to read, repeat, visit, or otherwise use it. ", + "", url, "", + "" +]; + +notebookURLMarkerText // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) @@ -313,27 +315,8 @@ listUIResources // endDefinition; readUIResource // beginDefinition; readUIResource[ msg_Association, req_ ] := Enclose[ - Module[ { uri }, + Module[ { uri, resource }, uri = ConfirmBy[ msg[[ "params", "uri" ]], StringQ, "URI" ]; - (* Notebook-url resolution requests (the "dropped _meta" workaround) are handled - separately; everything else is a registered HTML app resource. *) - If[ StringStartsQ[ uri, $notebookURLResourcePrefix ], - readNotebookURLResource @ uri, - readRegisteredUIResource @ uri - ] - ], - throwInternalFailure -]; - -readUIResource // endDefinition; - -(* ::**************************************************************************************************************:: *) -(* ::Subsubsection::Closed:: *) -(*readRegisteredUIResource*) -readRegisteredUIResource // beginDefinition; - -readRegisteredUIResource[ uri_String ] := - Module[ { resource }, resource = Lookup[ $uiResourceRegistry, uri, Missing[ "NotFound" ] ]; If[ MissingQ @ resource, throwFailure[ "UIResourceNotFound", uri ], @@ -346,68 +329,11 @@ readRegisteredUIResource[ uri_String ] := |> } |> ] - ]; - -readRegisteredUIResource // endDefinition; - -(* ::**************************************************************************************************************:: *) -(* ::Subsubsection::Closed:: *) -(*readNotebookURLResource*) -(* Reconstructs the full cloud notebook URL from the hex id embedded in the URI and returns it - as the resource text. The text payload of a resource is not stripped by hosts (unlike _meta), - so this is how the workaround gets the URL to the app. *) -readNotebookURLResource // beginDefinition; - -readNotebookURLResource[ uri_String ] := Enclose[ - Module[ { id, url }, - id = notebookIDFromResourceURI @ uri; - (* Restrict to hex ids: keeps arbitrary path segments out of the reconstructed - CloudObject and treats anything else as an unknown resource. *) - If[ notebookIDStringQ @ id, - url = ConfirmBy[ resolveNotebookURLFromID @ id, StringQ, "URL" ]; - <| "contents" -> { <| "uri" -> uri, "mimeType" -> "text/plain", "text" -> url |> } |>, - (* else: not a valid notebook id *) - throwFailure[ "UIResourceNotFound", uri ] - ] - ], - throwInternalFailure -]; - -readNotebookURLResource // endDefinition; - -(* ::**************************************************************************************************************:: *) -(* ::Subsubsection::Closed:: *) -(*notebookIDFromResourceURI*) -notebookIDFromResourceURI // beginDefinition; -notebookIDFromResourceURI[ uri_String ] := StringDelete[ uri, StartOfString ~~ $notebookURLResourcePrefix ]; -notebookIDFromResourceURI // endDefinition; - -(* ::**************************************************************************************************************:: *) -(* ::Subsubsection::Closed:: *) -(*notebookIDStringQ*) -notebookIDStringQ // beginDefinition; -notebookIDStringQ[ id_String ] := StringLength @ id > 0 && StringMatchQ[ id, HexadecimalCharacter.. ]; -notebookIDStringQ[ _ ] := False; -notebookIDStringQ // endDefinition; - -(* ::**************************************************************************************************************:: *) -(* ::Subsubsection::Closed:: *) -(*resolveNotebookURLFromID*) -(* Deterministically rebuilds the deployed URL from its base name; this is the exact inverse of - notebookIDFromURL and matches what CloudDeploy returned for the same target (verified: the - CloudObject URL is constructed locally from the base name and the user's cloud path). *) -resolveNotebookURLFromID // beginDefinition; - -resolveNotebookURLFromID[ id_String ] := Enclose[ - ConfirmBy[ - First @ CloudObject @ FileNameJoin @ { CloudObject @ $deployedNotebookRoot, id <> ".nb" }, - StringQ, - "URL" ], throwInternalFailure ]; -resolveNotebookURLFromID // endDefinition; +readUIResource // endDefinition; (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) diff --git a/Tests/MCPApps.wlt b/Tests/MCPApps.wlt index 45f6c5c..5d92e1b 100644 --- a/Tests/MCPApps.wlt +++ b/Tests/MCPApps.wlt @@ -688,52 +688,6 @@ VerificationTest[ TestID -> "ReadUIResource-InvalidURIType@@Tests/MCPApps.wlt:675,1-689,2" ] -(* ::**************************************************************************************************************:: *) -(* ::Subsubsection::Closed:: *) -(*Notebook URL Resolution (dropped-_meta workaround)*) - -(* A non-hex id under the notebook-url prefix is rejected (guards against path segments). *) -VerificationTest[ - Quiet @ Block[ { - Wolfram`AgentTools`Common`$clientSupportsUI = True, - Wolfram`AgentTools`Common`$uiResourceRegistry - }, - Wolfram`AgentTools`Common`initializeUIResources[ ]; - Wolfram`AgentTools`Common`readUIResource[ - <| "params" -> <| "uri" -> "ui://wolfram/notebook-url/not-a-hex-id" |> |>, - <| "jsonrpc" -> "2.0", "id" -> 8 |> - ] - ], - _Failure, - SameTest -> MatchQ, - TestID -> "ReadUIResource-NotebookURLNonHex" -] - -(* A valid hex id reconstructs the full cloud URL, whose base name round-trips back to the id. - Reconstruction needs an active cloud connection; when there is none, the test is a no-op. *) -VerificationTest[ - Block[ { - Wolfram`AgentTools`Common`$clientSupportsUI = True, - Wolfram`AgentTools`Common`$uiResourceRegistry - }, - Wolfram`AgentTools`Common`initializeUIResources[ ]; - If[ TrueQ @ $CloudConnected, - Module[ { response, text }, - response = Wolfram`AgentTools`Common`readUIResource[ - <| "params" -> <| "uri" -> "ui://wolfram/notebook-url/08aba9b360121fee" |> |>, - <| "jsonrpc" -> "2.0", "id" -> 9 |> - ]; - text = response[[ "contents", 1, "text" ]]; - StringQ @ text && StringStartsQ[ text, "http" ] && StringEndsQ[ text, "/08aba9b360121fee.nb" ] - ], - True - ] - ], - True, - SameTest -> Equal, - TestID -> "ReadUIResource-NotebookURLResolves" -] - (* ::**************************************************************************************************************:: *) (* ::Subsection::Closed:: *) (*handleResourceRead*) @@ -1314,24 +1268,27 @@ VerificationTest[ (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) -(*Cloud URL Appends nbid Marker*) +(*Cloud URL Appends URL Marker*) VerificationTest[ - Module[ { url, result }, + Module[ { url, result, marker }, url = "https://www.wolframcloud.com/obj/user/AgentTools/Notebooks/deadbeef12345678.nb"; result = Wolfram`AgentTools`Common`makeNotebookUIResult[ { <| "type" -> "text", "text" -> "1 + 1 = 2" |> }, url ]; + marker = Last[ result[ "Content" ] ][ "text" ]; { Length @ result[ "Content" ], - Last @ result[ "Content" ], + StringContainsQ[ marker, "" ] && StringContainsQ[ marker, "" ], + StringContainsQ[ marker, "" <> url <> "" ], result[ "_meta", "notebookUrl" ], result[ "StructuredContent", "notebookUrl" ] } ], { 2, - <| "type" -> "text", "text" -> "deadbeef12345678" |>, + True, + True, "https://www.wolframcloud.com/obj/user/AgentTools/Notebooks/deadbeef12345678.nb", "https://www.wolframcloud.com/obj/user/AgentTools/Notebooks/deadbeef12345678.nb" }, @@ -1339,6 +1296,25 @@ VerificationTest[ TestID -> "MakeNotebookUIResult-CloudURLAppendsMarker" ] +(* ::**************************************************************************************************************:: *) +(* ::Subsubsection::Closed:: *) +(*Marker URL Is Extractable*) +(* Mirrors the viewers' extraction: the URL must sit inside ... within the marker so + the client regex ...(...)... can recover it. *) +VerificationTest[ + Module[ { url, marker }, + url = "https://www.wolframcloud.com/obj/u/AgentTools/Notebooks/deadbeef12345678.nb"; + marker = Wolfram`AgentTools`UIResources`Private`notebookURLMarkerText[ url ]; + First[ + StringCases[ marker, "" ~~ ___ ~~ "" ~~ u: Except[ "<" ].. ~~ "" ~~ ___ ~~ "" :> u ], + None + ] + ], + "https://www.wolframcloud.com/obj/u/AgentTools/Notebooks/deadbeef12345678.nb", + SameTest -> MatchQ, + TestID -> "NotebookURLMarkerText-URLIsExtractable" +] + (* ::**************************************************************************************************************:: *) (* ::Subsubsection::Closed:: *) (*Deployment Failure Returns $Failed*) diff --git a/docs/mcp-apps.md b/docs/mcp-apps.md index 4f61a9b..0c338ff 100644 --- a/docs/mcp-apps.md +++ b/docs/mcp-apps.md @@ -99,19 +99,17 @@ When inline embedding is active, graphics can render empty in the embedded noteb ### Recovering the Notebook URL When `_meta` Is Dropped -The `notebookUrl` is delivered to the app through `_meta` and `structuredContent` (per the MCP Apps spec), both of which are meant to reach the app without entering model context. Some hosts, however, drop both from tool results ([ext-apps#696](https://github.com/modelcontextprotocol/ext-apps/issues/696)), so the app never receives the URL directly and can only render the text/image fallback. +The `notebookUrl` is delivered to the app through `_meta` and `structuredContent` (per the MCP Apps spec), both of which are meant to reach the app without entering model context. Some hosts, however, drop both from tool results ([ext-apps#696](https://github.com/modelcontextprotocol/ext-apps/issues/696)), so the app never receives the URL directly and can only render the text/image fallback. Those same hosts also do not forward app-initiated `resources/read` (they answer it with JSON-RPC `-32601 "Method not found"`), so the app cannot ask the server for the URL either. -To recover in that case, `makeNotebookUIResult` also appends an opaque marker to the (non-dropped) text content of cloud-notebook results: +The one channel that does survive is the tool result's `content`. So for cloud-notebook results, `makeNotebookUIResult` appends the URL to the content inside a self-describing marker (a separate text item): ``` -56a24661be6b3368 +This tool call was displayed to the user as an interactive notebook, which they can already see. The URL below only renders that notebook; you do not need to read, repeat, visit, or otherwise use it. https://www.wolframcloud.com/obj/.../56a24661be6b3368.nb ``` -The hex value is the deployed notebook's base file name. The marker is intentionally cryptic so the model ignores it, and every viewer strips it (via `stripAgentOnlyText`) before rendering. +The wrapper text is addressed to the model — it explains that the notebook is already shown and the URL is not for it to act on — while the `` tags let the viewer extract it. When a viewer sees a result with no `notebookUrl` from `_meta`/`structuredContent`, `findNotebookUrl` falls back to `extractNotebookUrlMarker`, which pulls the URL straight out of the marker (no server round-trip). Each text-rendering viewer also strips the whole `` block (via `stripAgentOnlyText`) so it never reaches the user. -When a viewer receives a result with no `notebookUrl`, it extracts this id and issues a `resources/read` for `ui://wolfram/notebook-url/`. The host forwards the read to the server (the MCP Apps spec permits apps to read any resource URI), and `readNotebookURLResource` reconstructs the full cloud URL from the id — statelessly, since a `CloudObject` URL is derived locally from the base name and the user's cloud path. Unlike `_meta`, a resource's text payload is not stripped, so the URL reaches the app, which then embeds the notebook as usual. - -This path applies only to cloud delivery: inline notebooks (`MCP_APPS_NOTEBOOK_METHOD="Inline"`) have no reconstructable id, so no marker is appended and `_meta`/`structuredContent` remains their only route. The `notebook-viewer` app normally receives its URL through the tool **input** (`arguments.url`), which is unaffected by the dropped-`_meta` issue; it applies the same recovery only as a fallback when a result arrives without a prior embed. +This path applies only to cloud delivery: inline notebooks (`MCP_APPS_NOTEBOOK_METHOD="Inline"`) carry the whole serialized notebook, which is delivered via `_meta` only, so no marker is appended. The `notebook-viewer` app normally receives its URL through the tool **input** (`arguments.url`), which is unaffected by the dropped-`_meta` issue; it applies the same marker recovery only as a fallback when a result arrives without a prior embed. ## Available UI Resources @@ -247,13 +245,13 @@ Add tests in `Tests/` for the new resource. See the existing test files (`Tests/ | `$toolUIAssociations` | `Common` | Mapping of tool names to UI resource URIs (entries may be `RuleDelayed` to gate on `$deployCloudNotebooks`) | | `$deployCloudNotebooks` | `Common` | Session flag gating cloud notebook deployment; initialized from `$CloudConnected` and set to `False` after a deployment failure | | `deployCloudNotebookForMCPApp` | `Common` | Shared helper that delivers a notebook for a UI-enhanced tool result — deploys to the cloud and returns a URL, or returns the serialized notebook when `MCP_APPS_NOTEBOOK_METHOD` is `"Inline"`; disables `$deployCloudNotebooks` on a deploy failure | -| `makeNotebookUIResult` | `Common` | Builds the UI-enhanced tool result from the text content and the delivered notebook value: carries `notebookUrl` in `_meta`/`structuredContent`, and appends the opaque `` marker for cloud URLs (the dropped-`_meta` workaround); returns `$Failed` when deployment failed | +| `makeNotebookUIResult` | `Common` | Builds the UI-enhanced tool result from the text content and the delivered notebook value: carries `notebookUrl` in `_meta`/`structuredContent`, and for cloud URLs appends the `` content marker (the dropped-`_meta` workaround); returns `$Failed` when deployment failed | | `delayedDisplay` | `Common` | Wraps `WolframLanguageEvaluator` output boxes so graphics reconstruct asynchronously when notebooks are embedded inline; a no-op outside inline mode or for graphics-free output | | `clientSupportsUIQ` | `Common` | Checks if an `initialize` message advertises UI support | | `mcpAppsEnabledQ` | `Common` | Checks the `MCP_APPS_ENABLED` environment variable | | `initializeUIResources` | `Common` | Loads HTML assets into the resource registry | | `listUIResources` | `Common` | Returns the resource list for `resources/list` | -| `readUIResource` | `Common` | Handles `resources/read` requests: registered HTML app resources, and `ui://wolfram/notebook-url/` lookups that reconstruct a cloud notebook URL (the dropped-`_meta` workaround) | +| `readUIResource` | `Common` | Handles `resources/read` requests | | `toolUIMetadata` | `Common` | Returns `_meta.ui` for a tool name | | `withToolUIMetadata` | `Common` | Augments a tool list with UI metadata | From 44ee635bc2376ef392a5a366b1bae6fccdaa1c6d Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Wed, 8 Jul 2026 16:41:12 +0000 Subject: [PATCH 3/5] Fix stale reference in MCP Apps UI result comments The tool-result comments described an opaque content marker, but the implementation embeds the URL in an ...... marker (see notebookURLMarkerText in UIResources.wl). Align the comments in both tool files with the actual marker format. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UTmWMZ5thpvab5pgqUX4LJ --- Kernel/Tools/WolframAlpha.wl | 5 +++-- Kernel/Tools/WolframLanguageEvaluator.wl | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Kernel/Tools/WolframAlpha.wl b/Kernel/Tools/WolframAlpha.wl index 9933ef4..d2718ec 100644 --- a/Kernel/Tools/WolframAlpha.wl +++ b/Kernel/Tools/WolframAlpha.wl @@ -144,8 +144,9 @@ makeUIResult[ as_, KeyValuePattern[ { "Result" -> waResult_, "String" -> stringR ]; (* Build the UI result: notebookUrl in _meta/structuredContent (per the MCP Apps spec), - plus an opaque marker in the content as a fallback for hosts that drop both - (ext-apps#696). See makeNotebookUIResult. Returns $Failed if deployment failed. *) + plus the URL inside an ...... marker in the content as + a fallback for hosts that drop both (ext-apps#696). See makeNotebookUIResult. Returns + $Failed if deployment failed. *) makeNotebookUIResult[ textContent, deployed ] ], throwInternalFailure diff --git a/Kernel/Tools/WolframLanguageEvaluator.wl b/Kernel/Tools/WolframLanguageEvaluator.wl index 942b6b1..b289ecd 100644 --- a/Kernel/Tools/WolframLanguageEvaluator.wl +++ b/Kernel/Tools/WolframLanguageEvaluator.wl @@ -357,8 +357,9 @@ makeEvaluatorUIResult[ ]; (* Build the UI result: notebookUrl in _meta/structuredContent (per the MCP Apps spec), - plus an opaque marker in the content as a fallback for hosts that drop both - (ext-apps#696). See makeNotebookUIResult. Returns $Failed if deployment failed. *) + plus the URL inside an ...... marker in the content as + a fallback for hosts that drop both (ext-apps#696). See makeNotebookUIResult. Returns + $Failed if deployment failed. *) makeNotebookUIResult[ textContent, deployed ] ], throwInternalFailure From 2fb24fffec3876d678cd248148b149cc17647137 Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Wed, 8 Jul 2026 16:41:20 +0000 Subject: [PATCH 4/5] Clarify inline notebook delivery in appendNotebookURLMarker comment makeNotebookUIResult populates both _meta and structuredContent for every deployed value, so 'delivered via _meta only' was inaccurate. Note that inline notebooks go through _meta/structuredContent and are never embedded in the content. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UTmWMZ5thpvab5pgqUX4LJ --- Kernel/UIResources.wl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Kernel/UIResources.wl b/Kernel/UIResources.wl index 5b7b1e1..7b6b87b 100644 --- a/Kernel/UIResources.wl +++ b/Kernel/UIResources.wl @@ -108,7 +108,8 @@ makeNotebookUIResult // endDefinition; appendNotebookURLMarker // beginDefinition; (* Only cloud URLs are embedded this way. Inline notebooks (MCP_APPS_NOTEBOOK_METHOD="Inline") - carry the whole serialized notebook as the value and are delivered via _meta only. *) + carry the whole serialized notebook as the value and are delivered via _meta/structuredContent + only, never embedded in the content. *) appendNotebookURLMarker[ textContent_List, url_String ] /; StringStartsQ[ url, "http" ] := Append[ textContent, <| "type" -> "text", "text" -> notebookURLMarkerText[ url ] |> ]; From 608a3a87dcc12844f8269da05f9c4c4c324e87b2 Mon Sep 17 00:00:00 2001 From: Rick Hennigan Date: Wed, 8 Jul 2026 16:57:35 +0000 Subject: [PATCH 5/5] Prefer last notebook-URL marker to resist injection extractNotebookUrlMarker returned the FIRST ...... marker in the content. Because the text content is built from tool output (stringResult) that can carry externally-sourced text, a crafted marker injected earlier in the content could override the server-appended URL when a host drops _meta/structuredContent, causing the viewer to load an attacker-chosen notebook. The server always appends its marker as the final content item (appendNotebookURLMarker), so scan for the LAST marker in document order instead. This also holds if a host concatenates the text items, since the trusted marker remains last. Legitimate single-marker results are unchanged. Applied identically to all three viewers (evaluator, notebook, wolframalpha). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014YoEwcu8kbrESPvQFsXyxp --- Assets/Apps/evaluator-viewer.html | 15 ++++++++++++--- Assets/Apps/notebook-viewer.html | 15 ++++++++++++--- Assets/Apps/wolframalpha-viewer.html | 15 ++++++++++++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Assets/Apps/evaluator-viewer.html b/Assets/Apps/evaluator-viewer.html index 6f60a27..c496ed6 100644 --- a/Assets/Apps/evaluator-viewer.html +++ b/Assets/Apps/evaluator-viewer.html @@ -211,14 +211,23 @@ // to use; stripAgentOnlyText removes the whole marker before anything reaches the user. function extractNotebookUrlMarker(content) { if (!content || !Array.isArray(content)) return null; + // Prefer the LAST marker in document order. The server appends its marker as the + // final content item (see appendNotebookURLMarker), so the last marker is the + // trusted one; scanning for the last match rather than the first keeps a marker + // injected earlier via user-controlled tool output text from overriding the + // server-appended URL (even if a host concatenates the text items). + var re = /[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/g; + var url = null; for (var i = 0; i < content.length; i++) { var item = content[i]; if (item && item.type === "text" && typeof item.text === "string") { - var m = item.text.match(/[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/); - if (m && m[1].trim()) return m[1].trim(); + var m; + while ((m = re.exec(item.text)) !== null) { + if (m[1].trim()) url = m[1].trim(); + } } } - return null; + return url; } // ---- Detach previous notebook ---- diff --git a/Assets/Apps/notebook-viewer.html b/Assets/Apps/notebook-viewer.html index 4da658a..6face68 100644 --- a/Assets/Apps/notebook-viewer.html +++ b/Assets/Apps/notebook-viewer.html @@ -297,14 +297,23 @@ // to use. (This viewer renders no text, so there is nothing to strip from the display.) function extractNotebookUrlMarker(content) { if (!content || !Array.isArray(content)) return null; + // Prefer the LAST marker in document order. The server appends its marker as the + // final content item (see appendNotebookURLMarker), so the last marker is the + // trusted one; scanning for the last match rather than the first keeps a marker + // injected earlier via user-controlled tool output text from overriding the + // server-appended URL (even if a host concatenates the text items). + var re = /[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/g; + var url = null; for (var i = 0; i < content.length; i++) { var item = content[i]; if (item && item.type === "text" && typeof item.text === "string") { - var m = item.text.match(/[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/); - if (m && m[1].trim()) return m[1].trim(); + var m; + while ((m = re.exec(item.text)) !== null) { + if (m[1].trim()) url = m[1].trim(); + } } } - return null; + return url; } // ---- Recover and embed a notebook from a tool result ---- diff --git a/Assets/Apps/wolframalpha-viewer.html b/Assets/Apps/wolframalpha-viewer.html index e4b2fe6..7420f55 100644 --- a/Assets/Apps/wolframalpha-viewer.html +++ b/Assets/Apps/wolframalpha-viewer.html @@ -298,14 +298,23 @@ // to use; stripAgentOnlyText removes the whole marker before anything reaches the user. function extractNotebookUrlMarker(content) { if (!content || !Array.isArray(content)) return null; + // Prefer the LAST marker in document order. The server appends its marker as the + // final content item (see appendNotebookURLMarker), so the last marker is the + // trusted one; scanning for the last match rather than the first keeps a marker + // injected earlier via user-controlled tool output text from overriding the + // server-appended URL (even if a host concatenates the text items). + var re = /[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/g; + var url = null; for (var i = 0; i < content.length; i++) { var item = content[i]; if (item && item.type === "text" && typeof item.text === "string") { - var m = item.text.match(/[\s\S]*?([\s\S]*?)<\/url>[\s\S]*?<\/internal>/); - if (m && m[1].trim()) return m[1].trim(); + var m; + while ((m = re.exec(item.text)) !== null) { + if (m[1].trim()) url = m[1].trim(); + } } } - return null; + return url; } // ---- Strip text intended only for the AI, not the user ----