Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions Assets/Apps/evaluator-viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -186,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
// <internal>...<url>...</url></internal> 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;
Expand All @@ -199,7 +202,32 @@
return item._meta.notebookUrl;
}
}
return null;
return extractNotebookUrlMarker(content);
}

// ---- Recover the URL from the <internal>...<url>...</url></internal> 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;
// 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 = /<internal>[\s\S]*?<url>([\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;
while ((m = re.exec(item.text)) !== null) {
if (m[1].trim()) url = m[1].trim();
}
}
Comment thread
rhennigan marked this conversation as resolved.
}
return url;
}

// ---- Detach previous notebook ----
Expand Down Expand Up @@ -300,11 +328,13 @@
// never be surfaced in the widget:
// * the Wolfram session-ID <system-reminder> appended to evaluator output
// * the interactive-widget notice injected by the host (e.g. Claude Desktop)
// * the <internal>...</internal> notebook-URL marker (see extractNotebookUrlMarker)
function stripAgentOnlyText(text) {
if (!text) return "";
return text
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
.replace(/\[This tool call rendered an interactive widget[\s\S]*?\]/g, "")
.replace(/<internal>[\s\S]*?<\/internal>/g, "")
.trim();
}

Expand Down
62 changes: 61 additions & 1 deletion Assets/Apps/notebook-viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -274,6 +277,58 @@
});
}

// ---- 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;
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 extractNotebookUrlMarker(content);
}

// ---- Recover the URL from the <internal>...<url>...</url></internal> 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;
// 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 = /<internal>[\s\S]*?<url>([\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;
while ((m = re.exec(item.text)) !== null) {
if (m[1].trim()) url = m[1].trim();
}
}
Comment thread
rhennigan marked this conversation as resolved.
}
return url;
}

// ---- 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: 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);
}
}

// ---- Incoming message handler ----
window.addEventListener("message", function(event) {
if (event.source !== window.parent) return;
Expand Down Expand Up @@ -302,6 +357,7 @@
var args = (msg.params && msg.params.arguments) || {};
var url = args.url;
if (url) {
embedRequested = true;
embedNotebook(
url,
args.allowInteract,
Expand All @@ -317,11 +373,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;
Expand All @@ -333,6 +392,7 @@
break;

case "ui/resource-teardown":
embedRequested = false;
if (embedded) {
embedded.detach();
embedded = null;
Expand Down
58 changes: 50 additions & 8 deletions Assets/Apps/wolframalpha-viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -275,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;
Expand All @@ -288,7 +288,46 @@
return item._meta.notebookUrl;
}
}
return null;
// Host dropped _meta/structuredContent (ext-apps#696): recover from the marker.
return extractNotebookUrlMarker(content);
}

// ---- Recover the URL from the <internal>...<url>...</url></internal> 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;
// 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 = /<internal>[\s\S]*?<url>([\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;
while ((m = re.exec(item.text)) !== null) {
if (m[1].trim()) url = m[1].trim();
}
}
Comment thread
rhennigan marked this conversation as resolved.
}
return url;
}

// ---- Strip text intended only for the AI, not the user ----
// The tool result may carry an <internal>...</internal> 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(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
.replace(/\[This tool call rendered an interactive widget[\s\S]*?\]/g, "")
.replace(/<internal>[\s\S]*?<\/internal>/g, "")
.trim();
}

// ---- Detach previous notebook ----
Expand Down Expand Up @@ -398,9 +437,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");
Expand All @@ -416,10 +457,11 @@

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);
}

Expand All @@ -441,9 +483,9 @@
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.
// 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);
if (!resultsEl.children.length) {
Expand Down
1 change: 1 addition & 0 deletions Kernel/CommonSymbols.wl
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ BeginPackage[ "Wolfram`AgentTools`Common`" ];
`initializeUIResources;
`listUIResources;
`loadUIResource;
`makeNotebookUIResult;
`readUIResource;
`toolUIMetadata;
`withToolUIMetadata;
Expand Down
17 changes: 5 additions & 12 deletions Kernel/Tools/WolframAlpha.wl
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,11 @@ 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 the URL inside an <internal>...<url>...</url></internal> 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
];
Expand Down
17 changes: 5 additions & 12 deletions Kernel/Tools/WolframLanguageEvaluator.wl
Original file line number Diff line number Diff line change
Expand Up @@ -356,18 +356,11 @@
"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 the URL inside an <internal>...<url>...</url></internal> 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
];
Expand Down Expand Up @@ -862,7 +855,7 @@
sizes = AssociationMap[ FileByteCount, files ]; (* oldest-first order preserved *)
acc = Total @ Values @ sizes;
drop = { };
If[ acc <= max, Return[ { }, Module ] ];

Check notice on line 858 in Kernel/Tools/WolframLanguageEvaluator.wl

View workflow job for this annotation

GitHub Actions / Build

CodeInspector/ReturnAmbiguous

The return point of `Return` is ambiguous, consider using `Catch`/`Throw` instead (L858,C25) (CodeInspectionFileIssue/ReturnAmbiguous:5480-5517)
Do[
If[ acc <= max, Break[ ] ];
AppendTo[ drop, f ];
Expand Down
56 changes: 56 additions & 0 deletions Kernel/UIResources.wl
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,62 @@ 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) and also do not forward
app-initiated resources/read, we additionally append the URL to the (non-dropped) text
content inside an <internal>...<url>...</url></internal> 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" -> appendNotebookURLMarker[ textContent, deployed ],
"_meta" -> <| "notebookUrl" -> deployed |>,
"StructuredContent" -> <| "notebookUrl" -> deployed |>
|>;

(* Deployment failed (deployCloudNotebookForMCPApp returned $Failed): no UI result. *)
makeNotebookUIResult[ _List, _ ] := $Failed;

makeNotebookUIResult // endDefinition;
Comment thread
rhennigan marked this conversation as resolved.

(* ::**************************************************************************************************************:: *)
(* ::Subsubsection::Closed:: *)
(*appendNotebookURLMarker*)
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/structuredContent
only, never embedded in the content. *)
appendNotebookURLMarker[ textContent_List, url_String ] /; StringStartsQ[ url, "http" ] :=
Append[ textContent, <| "type" -> "text", "text" -> notebookURLMarkerText[ url ] |> ];

appendNotebookURLMarker[ textContent_List, _ ] := textContent;

appendNotebookURLMarker // endDefinition;

(* ::**************************************************************************************************************:: *)
(* ::Subsubsection::Closed:: *)
(*notebookURLMarkerText*)
(* Wraps the URL in an instruction the model can read (so it ignores the URL) and in <url> 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[
"<internal>",
"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>", url, "</url>",
"</internal>"
];

notebookURLMarkerText // endDefinition;

(* ::**************************************************************************************************************:: *)
(* ::Subsection::Closed:: *)
(*delayedDisplay*)
Expand Down
Loading
Loading