Skip to content

Commit 8c4b952

Browse files
committed
feat: freeze goose2 mcp apps reference implementation
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
1 parent 39eff42 commit 8c4b952

30 files changed

Lines changed: 4253 additions & 51 deletions

crates/goose-acp/src/server.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,16 +1039,14 @@ impl GooseAcpAgent {
10391039
.ok()
10401040
.and_then(|tc| tc.arguments.as_ref())
10411041
.map(|a| serde_json::Value::Object(a.clone()));
1042+
let formatted_name = format_tool_name(&tool_name);
10421043
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());
10431044

10441045
cx.send_notification(SessionNotification::new(
10451046
session_id.clone(),
10461047
SessionUpdate::ToolCall(
1047-
ToolCall::new(
1048-
ToolCallId::new(tool_request.id.clone()),
1049-
fallback_title.clone(),
1050-
)
1051-
.status(ToolCallStatus::Pending),
1048+
ToolCall::new(ToolCallId::new(tool_request.id.clone()), formatted_name)
1049+
.status(ToolCallStatus::Pending),
10521050
),
10531051
))?;
10541052

ui/goose2/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dependencies": {
3030
"@aaif/goose-acp": "^0.16.0",
3131
"@agentclientprotocol/sdk": "^0.19.0",
32+
"@mcp-ui/client": "^7.0.0",
3233
"@radix-ui/react-accordion": "^1.2.12",
3334
"@radix-ui/react-alert-dialog": "^1.1.15",
3435
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -65,7 +66,7 @@
6566
"@tailwindcss/typography": "^0.5.19",
6667
"@tanstack/react-query": "^5.90.21",
6768
"@tauri-apps/api": "^2",
68-
"@tauri-apps/plugin-dialog": "^2.6.0",
69+
"@tauri-apps/plugin-dialog": "2.6.0",
6970
"@tauri-apps/plugin-opener": "^2.5.3",
7071
"@xyflow/react": "^12.10.2",
7172
"ai": "^6.0.142",
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="referrer" content="no-referrer" />
6+
<meta name="color-scheme" content="light dark" />
7+
<meta
8+
name="viewport"
9+
content="width=device-width, initial-scale=1, viewport-fit=cover"
10+
/>
11+
<title>MCP App Sandbox</title>
12+
<style>
13+
html,
14+
body {
15+
margin: 0;
16+
padding: 0;
17+
width: 100%;
18+
height: 100%;
19+
overflow: hidden;
20+
background: transparent;
21+
color-scheme: light dark;
22+
}
23+
24+
iframe {
25+
width: 100%;
26+
height: 100%;
27+
border: none;
28+
display: block;
29+
background: transparent;
30+
color-scheme: light dark;
31+
}
32+
</style>
33+
</head>
34+
<body>
35+
<script>
36+
const DEFAULT_CSP = {
37+
defaultSrc: "'none'",
38+
scriptSrc: ["'self'", "'unsafe-inline'"],
39+
styleSrc: ["'self'", "'unsafe-inline'"],
40+
imgSrc: ["'self'", "data:"],
41+
fontSrc: ["'self'", "data:"],
42+
mediaSrc: ["'self'", "data:"],
43+
connectSrc: ["'none'"],
44+
frameSrc: ["'none'"],
45+
baseUri: ["'self'"],
46+
formAction: ["'none'"],
47+
objectSrc: ["'none'"],
48+
};
49+
50+
let innerFrame = null;
51+
52+
function logProxy(message, details) {
53+
if (details === undefined) {
54+
console.debug(`[MCP sandbox proxy] ${message}`);
55+
return;
56+
}
57+
58+
console.debug(`[MCP sandbox proxy] ${message}`, details);
59+
}
60+
61+
function uniqueValues(values) {
62+
return [...new Set(values.filter(Boolean))];
63+
}
64+
65+
function getInitialCsp() {
66+
const params = new URLSearchParams(window.location.search);
67+
const raw = params.get("csp");
68+
if (!raw) {
69+
return null;
70+
}
71+
72+
try {
73+
return JSON.parse(raw);
74+
} catch {
75+
return null;
76+
}
77+
}
78+
79+
function buildCspContent(csp) {
80+
const resolved = csp && typeof csp === "object" ? csp : {};
81+
const resourceDomains = Array.isArray(resolved.resourceDomains)
82+
? resolved.resourceDomains.filter((value) => typeof value === "string")
83+
: [];
84+
const connectDomains = Array.isArray(resolved.connectDomains)
85+
? resolved.connectDomains.filter((value) => typeof value === "string")
86+
: [];
87+
const frameDomains = Array.isArray(resolved.frameDomains)
88+
? resolved.frameDomains.filter((value) => typeof value === "string")
89+
: [];
90+
const baseUriDomains = Array.isArray(resolved.baseUriDomains)
91+
? resolved.baseUriDomains.filter((value) => typeof value === "string")
92+
: [];
93+
94+
const directives = [
95+
`default-src ${DEFAULT_CSP.defaultSrc}`,
96+
`script-src ${uniqueValues([
97+
...DEFAULT_CSP.scriptSrc,
98+
...resourceDomains,
99+
]).join(" ")}`,
100+
`style-src ${uniqueValues([
101+
...DEFAULT_CSP.styleSrc,
102+
...resourceDomains,
103+
]).join(" ")}`,
104+
`img-src ${uniqueValues([...DEFAULT_CSP.imgSrc, ...resourceDomains]).join(
105+
" ",
106+
)}`,
107+
`font-src ${uniqueValues([
108+
...DEFAULT_CSP.fontSrc,
109+
...resourceDomains,
110+
]).join(" ")}`,
111+
`media-src ${uniqueValues([
112+
...DEFAULT_CSP.mediaSrc,
113+
...resourceDomains,
114+
]).join(" ")}`,
115+
`connect-src ${
116+
connectDomains.length > 0
117+
? uniqueValues(connectDomains).join(" ")
118+
: DEFAULT_CSP.connectSrc.join(" ")
119+
}`,
120+
`frame-src ${
121+
frameDomains.length > 0
122+
? uniqueValues(frameDomains).join(" ")
123+
: DEFAULT_CSP.frameSrc.join(" ")
124+
}`,
125+
`base-uri ${
126+
baseUriDomains.length > 0
127+
? uniqueValues(baseUriDomains).join(" ")
128+
: DEFAULT_CSP.baseUri.join(" ")
129+
}`,
130+
`form-action ${DEFAULT_CSP.formAction}`,
131+
`object-src ${DEFAULT_CSP.objectSrc}`,
132+
];
133+
134+
return directives.join("; ");
135+
}
136+
137+
function buildAllowAttribute(permissions) {
138+
if (!permissions || typeof permissions !== "object") {
139+
return "";
140+
}
141+
142+
const granted = [];
143+
144+
if (permissions.camera) {
145+
granted.push("camera");
146+
}
147+
if (permissions.microphone) {
148+
granted.push("microphone");
149+
}
150+
if (permissions.geolocation) {
151+
granted.push("geolocation");
152+
}
153+
if (permissions.clipboardWrite) {
154+
granted.push("clipboard-write");
155+
}
156+
157+
return granted.join("; ");
158+
}
159+
160+
function isJsonRpcMessage(value) {
161+
return (
162+
value &&
163+
typeof value === "object" &&
164+
value.jsonrpc === "2.0" &&
165+
("method" in value || "result" in value || "error" in value)
166+
);
167+
}
168+
169+
function injectCspMeta(html, csp) {
170+
const meta = `<meta http-equiv="Content-Security-Policy" content="${buildCspContent(
171+
csp,
172+
).replace(/"/g, "&quot;")}" />`;
173+
174+
if (/<head[^>]*>/i.test(html)) {
175+
return html.replace(/<head([^>]*)>/i, `<head$1>${meta}`);
176+
}
177+
178+
if (/<html[^>]*>/i.test(html)) {
179+
return html.replace(/<html([^>]*)>/i, `<html$1><head>${meta}</head>`);
180+
}
181+
182+
return `<!doctype html><html><head>${meta}</head><body>${html}</body></html>`;
183+
}
184+
185+
function injectMetaIfMissing(html, matcher, metaTag) {
186+
if (matcher.test(html)) {
187+
return html;
188+
}
189+
190+
if (/<head[^>]*>/i.test(html)) {
191+
return html.replace(/<head([^>]*)>/i, `<head$1>${metaTag}`);
192+
}
193+
194+
if (/<html[^>]*>/i.test(html)) {
195+
return html.replace(/<html([^>]*)>/i, `<html$1><head>${metaTag}</head>`);
196+
}
197+
198+
return `<!doctype html><html><head>${metaTag}</head><body>${html}</body></html>`;
199+
}
200+
201+
function injectHostMeta(html) {
202+
let updated = injectMetaIfMissing(
203+
html,
204+
/<meta[^>]+name=["']referrer["']/i,
205+
'<meta name="referrer" content="no-referrer" />',
206+
);
207+
updated = injectMetaIfMissing(
208+
updated,
209+
/<meta[^>]+name=["']color-scheme["']/i,
210+
'<meta name="color-scheme" content="light dark" />',
211+
);
212+
return updated;
213+
}
214+
215+
function buildInnerFrame() {
216+
if (innerFrame) {
217+
return innerFrame;
218+
}
219+
220+
innerFrame = document.createElement("iframe");
221+
innerFrame.style.width = "100%";
222+
innerFrame.style.height = "100%";
223+
innerFrame.style.border = "0";
224+
innerFrame.style.display = "block";
225+
innerFrame.style.background = "transparent";
226+
innerFrame.style.colorScheme = "light dark";
227+
innerFrame.setAttribute(
228+
"sandbox",
229+
"allow-scripts allow-same-origin allow-forms",
230+
);
231+
document.body.replaceChildren(innerFrame);
232+
233+
logProxy("created inner iframe");
234+
return innerFrame;
235+
}
236+
237+
function createInnerFrame(params) {
238+
const frame = buildInnerFrame();
239+
240+
frame.dataset.resourceLoaded = "true";
241+
242+
frame.setAttribute(
243+
"sandbox",
244+
params.sandbox || "allow-scripts allow-same-origin allow-forms",
245+
);
246+
247+
const allowAttribute = buildAllowAttribute(params.permissions);
248+
if (allowAttribute) {
249+
frame.setAttribute("allow", allowAttribute);
250+
logProxy("set inner iframe allow attribute", allowAttribute);
251+
} else {
252+
frame.removeAttribute("allow");
253+
}
254+
255+
const html = injectHostMeta(
256+
injectCspMeta(params.html, params.csp || getInitialCsp()),
257+
);
258+
const innerDocument = frame.contentDocument || frame.contentWindow?.document;
259+
260+
logProxy("received sandbox resource ready", {
261+
sandbox: params.sandbox,
262+
hasPermissions: Boolean(params.permissions),
263+
htmlLength: params.html.length,
264+
});
265+
266+
if (innerDocument) {
267+
innerDocument.open();
268+
innerDocument.write(html);
269+
innerDocument.close();
270+
logProxy("wrote app HTML into inner iframe document");
271+
return;
272+
}
273+
274+
frame.srcdoc = html;
275+
logProxy("fallback to inner iframe srcdoc");
276+
}
277+
278+
window.addEventListener("message", (event) => {
279+
const data = event.data;
280+
if (!data || typeof data !== "object") {
281+
return;
282+
}
283+
284+
if (event.source === window.parent) {
285+
if (data.method === "ui/notifications/sandbox-resource-ready") {
286+
const params =
287+
data.params && typeof data.params === "object" ? data.params : null;
288+
if (!params || typeof params.html !== "string") {
289+
logProxy("ignored malformed sandbox resource ready", data);
290+
return;
291+
}
292+
293+
createInnerFrame(params);
294+
return;
295+
}
296+
297+
if (
298+
buildInnerFrame().contentWindow &&
299+
isJsonRpcMessage(data) &&
300+
!(
301+
typeof data.method === "string" &&
302+
data.method.startsWith("ui/notifications/sandbox-")
303+
)
304+
) {
305+
logProxy("forwarding host message to inner iframe", {
306+
method: data.method,
307+
hasResult: "result" in data,
308+
hasError: "error" in data,
309+
});
310+
innerFrame.contentWindow.postMessage(data, "*");
311+
}
312+
return;
313+
}
314+
315+
if (
316+
innerFrame?.contentWindow &&
317+
event.source === innerFrame.contentWindow &&
318+
isJsonRpcMessage(data) &&
319+
!(
320+
typeof data.method === "string" &&
321+
data.method.startsWith("ui/notifications/sandbox-")
322+
)
323+
) {
324+
logProxy("forwarding inner iframe message to host", {
325+
method: data.method,
326+
hasResult: "result" in data,
327+
hasError: "error" in data,
328+
});
329+
window.parent.postMessage(data, "*");
330+
}
331+
});
332+
333+
buildInnerFrame();
334+
</script>
335+
</body>
336+
</html>

0 commit comments

Comments
 (0)