@grackle-ai/web-components 0.110.1 → 0.110.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/{f2b8b611fc00c7b912256986db4cc966d6560387.tar.log → 6bc9700f6b9938b330a84bd0f8c7ef5a7e3dbfb6.tar.log} +2 -2
- package/.rush/temp/{3629cc83b3804ac8bcea27905e83162210fb5dd6.untar.log → 6bc9700f6b9938b330a84bd0f8c7ef5a7e3dbfb6.untar.log} +2 -2
- package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +5 -5
- package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +26 -24
- package/.rush/temp/{3629cc83b3804ac8bcea27905e83162210fb5dd6.tar.log → de557b666a8980e74bc5c775dc53097f779f8d53.tar.log} +50 -55
- package/.rush/temp/{f2b8b611fc00c7b912256986db4cc966d6560387.untar.log → de557b666a8980e74bc5c775dc53097f779f8d53.untar.log} +2 -2
- package/.rush/temp/operation/_phase_build/all.log +5 -5
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +5 -5
- package/.rush/temp/operation/_phase_build/state.json +1 -1
- package/.rush/temp/operation/_phase_test/all.log +26 -24
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +26 -24
- package/.rush/temp/operation/_phase_test/state.json +1 -1
- package/.rush/temp/shrinkwrap-deps.json +58 -3
- package/dist/index.js +18005 -12131
- package/mcp-app-sandbox/sample-widget.js +74 -0
- package/mcp-app-sandbox/sandbox-relay.js +114 -0
- package/mcp-app-sandbox/sandbox.html +37 -0
- package/mcp-app-sandbox/serve.mjs +145 -0
- package/package.json +10 -2
- package/rush-logs/web-components._phase_build.cache.log +1 -1
- package/rush-logs/web-components._phase_build.log +5 -5
- package/rush-logs/web-components._phase_test.cache.log +1 -1
- package/rush-logs/web-components._phase_test.log +26 -24
- package/src/components/display/McpAppWidget.stories.tsx +60 -0
- package/src/components/display/McpAppWidget.test.tsx +46 -0
- package/src/components/display/McpAppWidget.tsx +358 -0
- package/src/components/display/index.ts +2 -0
- package/src/index.ts +3 -1
- package/src/utils/grackleHostStyleVariables.test.ts +25 -0
- package/src/utils/grackleHostStyleVariables.ts +134 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +24 -4
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Sample MCP App (app-side) for the McpAppWidget Storybook story.
|
|
2
|
+
//
|
|
3
|
+
// Uses the REAL @modelcontextprotocol/ext-apps `App` class — bundled with its
|
|
4
|
+
// dependencies inlined in app-with-deps.js (served by serve.mjs from the sandbox
|
|
5
|
+
// origin) — so the ui/initialize -> initialized -> tool-input/tool-result
|
|
6
|
+
// handshake is exactly spec-conformant. The previous hand-rolled handshake
|
|
7
|
+
// rendered but never received data because its messages failed AppBridge's Zod
|
|
8
|
+
// validation; the real App produces schema-correct messages.
|
|
9
|
+
//
|
|
10
|
+
// Loaded by the inner sandboxed iframe via `<script type="module">`. Relative
|
|
11
|
+
// imports resolve against this module's URL (the sandbox origin), so
|
|
12
|
+
// `./app-with-deps.js` is fetched from the same sidecar.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
App,
|
|
16
|
+
PostMessageTransport,
|
|
17
|
+
applyHostStyleVariables,
|
|
18
|
+
applyHostFonts,
|
|
19
|
+
applyDocumentTheme,
|
|
20
|
+
} from "./app-with-deps.js";
|
|
21
|
+
|
|
22
|
+
const inEl = document.getElementById("in");
|
|
23
|
+
const outEl = document.getElementById("out");
|
|
24
|
+
const callBtn = document.getElementById("call");
|
|
25
|
+
|
|
26
|
+
/** Render a CallToolResult `content` array into the result element. */
|
|
27
|
+
function renderResult(content) {
|
|
28
|
+
const blocks = content ?? [];
|
|
29
|
+
outEl.textContent =
|
|
30
|
+
blocks
|
|
31
|
+
.map((block) => (block.type === "text" ? block.text : "<" + block.type + ">"))
|
|
32
|
+
.join(" ") || "(empty)";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const app = new App({ name: "SampleWeather", version: "1.0.0" }, {});
|
|
36
|
+
|
|
37
|
+
// Register handlers BEFORE connect so the one-shot tool-input/result the host
|
|
38
|
+
// sends right after the handshake are not missed.
|
|
39
|
+
app.ontoolinput = (params) => {
|
|
40
|
+
inEl.textContent = JSON.stringify(params.arguments ?? {});
|
|
41
|
+
};
|
|
42
|
+
app.ontoolresult = (params) => {
|
|
43
|
+
renderResult(params.content);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Apply the host's theme + style variables to this document. */
|
|
47
|
+
function applyHostStyles() {
|
|
48
|
+
const ctx = app.getHostContext();
|
|
49
|
+
if (ctx?.styles?.variables) {
|
|
50
|
+
applyHostStyleVariables(ctx.styles.variables);
|
|
51
|
+
}
|
|
52
|
+
if (ctx?.styles?.css?.fonts) {
|
|
53
|
+
applyHostFonts(ctx.styles.css.fonts);
|
|
54
|
+
}
|
|
55
|
+
if (ctx?.theme) {
|
|
56
|
+
applyDocumentTheme(ctx.theme);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
app.onhostcontextchanged = () => applyHostStyles();
|
|
60
|
+
|
|
61
|
+
// The Refresh button asks the host to run a tool on the App's behalf
|
|
62
|
+
// (proxied to McpAppWidget's onCallTool prop in the story).
|
|
63
|
+
callBtn.addEventListener("click", () => {
|
|
64
|
+
outEl.textContent = "(refreshing...)";
|
|
65
|
+
app
|
|
66
|
+
.callServerTool({ name: "refresh_weather", arguments: {} })
|
|
67
|
+
.then((result) => renderResult(result.content))
|
|
68
|
+
.catch((err) => {
|
|
69
|
+
outEl.textContent = "error: " + (err && err.message ? err.message : String(err));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await app.connect(new PostMessageTransport(window.parent, window.parent));
|
|
74
|
+
applyHostStyles();
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Externalized relay for the MCP Apps outer sandbox proxy (loaded by sandbox.html).
|
|
2
|
+
//
|
|
3
|
+
// Kept as a separate module — served from the sandbox origin and loaded as
|
|
4
|
+
// `script-src 'self'` — so the proxy CSP does NOT need `script-src 'unsafe-inline'`.
|
|
5
|
+
// Implements the double-iframe relay between the host and the inner untrusted
|
|
6
|
+
// widget per the MCP Apps spec (SEP-1865). Adapted from
|
|
7
|
+
// modelcontextprotocol/ext-apps examples/basic-host/src/sandbox.ts (commit 9a37ad7).
|
|
8
|
+
|
|
9
|
+
const RESOURCE_READY = "ui/notifications/sandbox-resource-ready";
|
|
10
|
+
const PROXY_READY = "ui/notifications/sandbox-proxy-ready";
|
|
11
|
+
const DEFAULT_SANDBOX = "allow-scripts allow-same-origin allow-forms";
|
|
12
|
+
|
|
13
|
+
// Must run inside an iframe on a different origin than the host.
|
|
14
|
+
if (window.self === window.top) {
|
|
15
|
+
throw new Error("sandbox.html must be loaded inside an iframe.");
|
|
16
|
+
}
|
|
17
|
+
if (!document.referrer) {
|
|
18
|
+
throw new Error("No referrer; cannot validate the embedding host origin.");
|
|
19
|
+
}
|
|
20
|
+
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
|
|
21
|
+
const OWN_ORIGIN = new URL(window.location.href).origin;
|
|
22
|
+
|
|
23
|
+
// Security self-test: confirm we cannot reach the top window. Reading a property
|
|
24
|
+
// on a cross-origin window.top throws a SecurityError without any visible side
|
|
25
|
+
// effect (unlike alert()). If it does NOT throw, isolation is broken and we must
|
|
26
|
+
// refuse to run.
|
|
27
|
+
try {
|
|
28
|
+
const probe = window.top.location.href;
|
|
29
|
+
void probe;
|
|
30
|
+
throw "FAIL";
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (e === "FAIL") {
|
|
33
|
+
throw new Error("Sandbox is not isolated (host and sandbox share an origin?).");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Minimal copy of ext-apps buildAllowAttribute: maps requested permissions to an
|
|
38
|
+
// iframe Permissions-Policy `allow` attribute.
|
|
39
|
+
function buildAllowAttribute(permissions) {
|
|
40
|
+
if (!permissions || typeof permissions !== "object") {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const map = {
|
|
44
|
+
camera: "camera",
|
|
45
|
+
microphone: "microphone",
|
|
46
|
+
geolocation: "geolocation",
|
|
47
|
+
clipboardWrite: "clipboard-write",
|
|
48
|
+
clipboardRead: "clipboard-read",
|
|
49
|
+
displayCapture: "display-capture",
|
|
50
|
+
};
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const key of Object.keys(permissions)) {
|
|
53
|
+
if (map[key]) {
|
|
54
|
+
out.push(`${map[key]} 'self'`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out.join("; ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Target origin for host -> inner messages. When the inner iframe keeps
|
|
61
|
+
// `allow-same-origin`, its document shares OWN_ORIGIN, so we post to that exact
|
|
62
|
+
// origin — if the widget navigates the frame elsewhere, delivery stops, which
|
|
63
|
+
// prevents it from continuing to receive host-sent tool data. Opaque frames (no
|
|
64
|
+
// `allow-same-origin`) have an unnameable null origin, so "*" is the only option.
|
|
65
|
+
function innerTargetOrigin(sandboxValue) {
|
|
66
|
+
return /\ballow-same-origin\b/.test(sandboxValue) ? OWN_ORIGIN : "*";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const inner = document.createElement("iframe");
|
|
70
|
+
inner.setAttribute("sandbox", DEFAULT_SANDBOX);
|
|
71
|
+
let innerOrigin = innerTargetOrigin(DEFAULT_SANDBOX);
|
|
72
|
+
document.body.appendChild(inner);
|
|
73
|
+
|
|
74
|
+
window.addEventListener("message", (event) => {
|
|
75
|
+
if (event.source === window.parent) {
|
|
76
|
+
if (event.origin !== EXPECTED_HOST_ORIGIN) {
|
|
77
|
+
console.error("[sandbox] dropping parent message from", event.origin);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (event.data && event.data.method === RESOURCE_READY) {
|
|
81
|
+
const { html, sandbox, permissions } = event.data.params ?? {};
|
|
82
|
+
if (typeof sandbox === "string") {
|
|
83
|
+
inner.setAttribute("sandbox", sandbox);
|
|
84
|
+
innerOrigin = innerTargetOrigin(sandbox);
|
|
85
|
+
}
|
|
86
|
+
const allow = buildAllowAttribute(permissions);
|
|
87
|
+
if (allow) {
|
|
88
|
+
inner.setAttribute("allow", allow);
|
|
89
|
+
}
|
|
90
|
+
if (typeof html === "string") {
|
|
91
|
+
const doc = inner.contentDocument || inner.contentWindow?.document;
|
|
92
|
+
if (doc) {
|
|
93
|
+
doc.open();
|
|
94
|
+
doc.write(html);
|
|
95
|
+
doc.close();
|
|
96
|
+
} else {
|
|
97
|
+
inner.srcdoc = html;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Relay everything else host -> inner widget, scoped to the inner origin.
|
|
103
|
+
inner.contentWindow?.postMessage(event.data, innerOrigin);
|
|
104
|
+
} else if (event.source === inner.contentWindow) {
|
|
105
|
+
if (event.origin !== OWN_ORIGIN && event.origin !== "null") {
|
|
106
|
+
console.error("[sandbox] dropping inner message from", event.origin);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Relay inner widget -> host.
|
|
110
|
+
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
window.parent.postMessage({ jsonrpc: "2.0", method: PROXY_READY, params: {} }, EXPECTED_HOST_ORIGIN);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="color-scheme" content="light dark" />
|
|
6
|
+
<!--
|
|
7
|
+
MCP Apps outer sandbox proxy.
|
|
8
|
+
|
|
9
|
+
Double-iframe pattern per the MCP Apps spec (SEP-1865). This page is the
|
|
10
|
+
OUTER proxy and MUST be served from a different origin than the host (see
|
|
11
|
+
serve.mjs / GRACKLE_SANDBOX_PORT). It creates an INNER iframe for the
|
|
12
|
+
untrusted widget HTML and relays postMessage between host and widget.
|
|
13
|
+
|
|
14
|
+
CSP for the proxy + the inner widget is applied by serve.mjs via the HTTP
|
|
15
|
+
Content-Security-Policy header (built from the ?csp= query param) — it is
|
|
16
|
+
tamper-proof, unlike a <meta> tag. The relay is loaded as an EXTERNAL module
|
|
17
|
+
(sandbox-relay.js) from the sandbox origin so the CSP can keep
|
|
18
|
+
`script-src 'self'` (no 'unsafe-inline').
|
|
19
|
+
|
|
20
|
+
Adapted from modelcontextprotocol/ext-apps examples/basic-host/src/sandbox.ts
|
|
21
|
+
(commit 9a37ad7).
|
|
22
|
+
-->
|
|
23
|
+
<title>Grackle MCP App Sandbox</title>
|
|
24
|
+
<style>
|
|
25
|
+
html, body { margin: 0; height: 100vh; width: 100vw; background: transparent; }
|
|
26
|
+
body { display: flex; flex-direction: column; }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
iframe {
|
|
29
|
+
background: transparent; border: 0 none transparent; padding: 0;
|
|
30
|
+
overflow: hidden; flex-grow: 1; color-scheme: inherit;
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<script type="module" src="./sandbox-relay.js"></script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Sidecar static server for the MCP Apps sandbox proxy.
|
|
2
|
+
//
|
|
3
|
+
// Serves sandbox.html from a DIFFERENT ORIGIN than the host (Storybook), with a
|
|
4
|
+
// per-request Content-Security-Policy set via HTTP header (built from the ?csp=
|
|
5
|
+
// query param) — tamper-proof, unlike a <meta> tag. The CSP governs the inner
|
|
6
|
+
// widget written into sandbox.html's inner iframe.
|
|
7
|
+
//
|
|
8
|
+
// Used in T1 (#1236) for Storybook; the production equivalent is the
|
|
9
|
+
// GRACKLE_SANDBOX_PORT server in #1238. CSP-building logic mirrors
|
|
10
|
+
// modelcontextprotocol/ext-apps examples/basic-host/serve.ts (commit 9a37ad7).
|
|
11
|
+
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
const PORT = parseInt(process.env.MCP_SANDBOX_PORT ?? "6007", 10);
|
|
21
|
+
const HOST = process.env.MCP_SANDBOX_HOST ?? "127.0.0.1";
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const SANDBOX_FILE = join(HERE, "sandbox.html");
|
|
24
|
+
// The thin app-side widget module loaded by the inner iframe.
|
|
25
|
+
const SAMPLE_WIDGET_FILE = join(HERE, "sample-widget.js");
|
|
26
|
+
// The REAL @modelcontextprotocol/ext-apps App, pre-bundled with deps inlined,
|
|
27
|
+
// resolved from node_modules so it stays in sync with the installed version.
|
|
28
|
+
let APP_WITH_DEPS_FILE;
|
|
29
|
+
try {
|
|
30
|
+
APP_WITH_DEPS_FILE = require.resolve("@modelcontextprotocol/ext-apps/app-with-deps");
|
|
31
|
+
} catch {
|
|
32
|
+
APP_WITH_DEPS_FILE = undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The externalized proxy relay (loaded by sandbox.html as `script-src 'self'`).
|
|
36
|
+
const SANDBOX_RELAY_FILE = join(HERE, "sandbox-relay.js");
|
|
37
|
+
|
|
38
|
+
// Static JS files served (as ES modules). The relay runs in the proxy page; the
|
|
39
|
+
// widget modules run in the inner sandboxed iframe (both as same-origin 'self').
|
|
40
|
+
const JS_ROUTES = {
|
|
41
|
+
"/sandbox-relay.js": () => SANDBOX_RELAY_FILE,
|
|
42
|
+
"/sample-widget.js": () => SAMPLE_WIDGET_FILE,
|
|
43
|
+
"/app-with-deps.js": () => APP_WITH_DEPS_FILE,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Reject CSP domain entries with characters that could break out of a directive. */
|
|
47
|
+
function sanitizeCspDomains(domains) {
|
|
48
|
+
if (!Array.isArray(domains)) return [];
|
|
49
|
+
return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build the Content-Security-Policy header value from an optional McpUiResourceCsp. */
|
|
53
|
+
function buildCspHeader(csp) {
|
|
54
|
+
const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
|
|
55
|
+
const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" ");
|
|
56
|
+
const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ");
|
|
57
|
+
const baseUriDomains = sanitizeCspDomains(csp?.baseUriDomains).join(" ");
|
|
58
|
+
return [
|
|
59
|
+
"default-src 'self'",
|
|
60
|
+
// Scripts: only same-origin (the relay + widget modules are served from this
|
|
61
|
+
// origin) plus blob: for widgets that spawn workers. No 'unsafe-inline' (the
|
|
62
|
+
// relay is an external module), no 'unsafe-eval' (the ext-apps App runs zod in
|
|
63
|
+
// jitless mode), no data: scripts.
|
|
64
|
+
`script-src 'self' blob: ${resourceDomains}`.trim(),
|
|
65
|
+
// Styles still allow 'unsafe-inline' — widgets routinely use <style> blocks
|
|
66
|
+
// and inline style attributes, which is far lower-risk than inline scripts.
|
|
67
|
+
`style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
|
|
68
|
+
`img-src 'self' data: blob: ${resourceDomains}`.trim(),
|
|
69
|
+
`font-src 'self' data: blob: ${resourceDomains}`.trim(),
|
|
70
|
+
`media-src 'self' data: blob: ${resourceDomains}`.trim(),
|
|
71
|
+
`connect-src 'self' ${connectDomains}`.trim(),
|
|
72
|
+
`worker-src 'self' blob: ${resourceDomains}`.trim(),
|
|
73
|
+
frameDomains ? `frame-src ${frameDomains}` : "frame-src 'none'",
|
|
74
|
+
"object-src 'none'",
|
|
75
|
+
baseUriDomains ? `base-uri ${baseUriDomains}` : "base-uri 'none'",
|
|
76
|
+
].join("; ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const server = createServer((req, res) => {
|
|
80
|
+
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? HOST}`);
|
|
81
|
+
const path = requestUrl.pathname;
|
|
82
|
+
|
|
83
|
+
// Serve the app-side JS modules (the bundled App + the sample widget) that the
|
|
84
|
+
// inner widget loads. `script-src 'self'` permits these because the inner
|
|
85
|
+
// iframe shares the sandbox origin (allow-same-origin).
|
|
86
|
+
const jsResolver = JS_ROUTES[path];
|
|
87
|
+
if (jsResolver) {
|
|
88
|
+
const file = jsResolver();
|
|
89
|
+
let js;
|
|
90
|
+
try {
|
|
91
|
+
js = file ? readFileSync(file, "utf-8") : undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
js = undefined;
|
|
94
|
+
}
|
|
95
|
+
if (!js) {
|
|
96
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
97
|
+
res.end(`Not found: ${path}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
res.writeHead(200, {
|
|
101
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
102
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
103
|
+
"Access-Control-Allow-Origin": "*",
|
|
104
|
+
});
|
|
105
|
+
res.end(js);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (path !== "/" && path !== "/sandbox.html") {
|
|
110
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
111
|
+
res.end("Only sandbox.html and the widget JS modules are served on this port.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let csp;
|
|
116
|
+
const cspParam = requestUrl.searchParams.get("csp");
|
|
117
|
+
if (cspParam) {
|
|
118
|
+
try {
|
|
119
|
+
csp = JSON.parse(cspParam);
|
|
120
|
+
} catch {
|
|
121
|
+
// Ignore an unparseable csp param and fall back to the locked-down default.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let html;
|
|
126
|
+
try {
|
|
127
|
+
html = readFileSync(SANDBOX_FILE, "utf-8");
|
|
128
|
+
} catch {
|
|
129
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
130
|
+
res.end("sandbox.html not found");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
res.writeHead(200, {
|
|
135
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
136
|
+
"Content-Security-Policy": buildCspHeader(csp),
|
|
137
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
138
|
+
"Access-Control-Allow-Origin": "*",
|
|
139
|
+
});
|
|
140
|
+
res.end(html);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
server.listen(PORT, HOST, () => {
|
|
144
|
+
console.log(`[mcp-sandbox] serving sandbox.html at http://${HOST}:${PORT}/sandbox.html`);
|
|
145
|
+
});
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grackle-ai/web-components",
|
|
3
|
-
"version": "0.110.
|
|
3
|
+
"version": "0.110.3",
|
|
4
4
|
"description": "Presentational React component library for the Grackle web UI",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"**/*.css",
|
|
8
|
+
"**/*.scss"
|
|
9
|
+
],
|
|
6
10
|
"repository": {
|
|
7
11
|
"type": "git",
|
|
8
12
|
"url": "https://github.com/nick-pape/grackle.git",
|
|
@@ -25,6 +29,8 @@
|
|
|
25
29
|
"main": "src/index.ts",
|
|
26
30
|
"dependencies": {
|
|
27
31
|
"@bufbuild/protobuf": "^2.5.0",
|
|
32
|
+
"@modelcontextprotocol/ext-apps": "1.7.2",
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
34
|
"@dagrejs/dagre": "^2.0.4",
|
|
29
35
|
"@xyflow/react": "^12.10.0",
|
|
30
36
|
"motion": "^11.18.0",
|
|
@@ -40,7 +46,7 @@
|
|
|
40
46
|
"remark-gfm": "^4.0.0",
|
|
41
47
|
"lucide-react": "~0.474.0",
|
|
42
48
|
"react-router": "^7.0.0",
|
|
43
|
-
"@grackle-ai/common": "0.110.
|
|
49
|
+
"@grackle-ai/common": "0.110.3"
|
|
44
50
|
},
|
|
45
51
|
"devDependencies": {
|
|
46
52
|
"@rushstack/heft": "1.2.7",
|
|
@@ -77,6 +83,8 @@
|
|
|
77
83
|
"storybook": "storybook dev -p 6006",
|
|
78
84
|
"build-storybook": "storybook build",
|
|
79
85
|
"test-storybook": "test-storybook",
|
|
86
|
+
"mcp-sandbox": "node mcp-app-sandbox/serve.mjs",
|
|
87
|
+
"storybook:mcp": "concurrently -k -n sandbox,storybook \"node mcp-app-sandbox/serve.mjs\" \"storybook dev -p 6006\"",
|
|
80
88
|
"_phase:build": "heft run --only build -- --clean",
|
|
81
89
|
"_phase:test": "heft run --only test"
|
|
82
90
|
}
|
|
@@ -7,12 +7,12 @@ Invoking: heft run --only build -- --clean
|
|
|
7
7
|
[build:lint] Using ESLint version 9.39.4
|
|
8
8
|
vite v6.4.2 building for production...
|
|
9
9
|
transforming...
|
|
10
|
-
✓
|
|
10
|
+
✓ 2715 modules transformed.
|
|
11
11
|
rendering chunks...
|
|
12
12
|
computing gzip size...
|
|
13
13
|
dist/index.css 156.09 kB │ gzip: 20.37 kB
|
|
14
|
-
dist/index.js 1,
|
|
15
|
-
✓ built in 5.
|
|
14
|
+
dist/index.js 1,589.58 kB │ gzip: 401.03 kB
|
|
15
|
+
✓ built in 5.83s
|
|
16
16
|
[build:vite-build] Vite build completed.
|
|
17
|
-
---- build finished (
|
|
18
|
-
-------------------- Finished (
|
|
17
|
+
---- build finished (39.698s) ----
|
|
18
|
+
-------------------- Finished (39.701s) --------------------
|
|
@@ -5,26 +5,28 @@ The provided list of phases does not contain all phase dependencies. You may nee
|
|
|
5
5
|
|
|
6
6
|
RUN v3.2.4 /home/runner/work/grackle/grackle/packages/web-components
|
|
7
7
|
|
|
8
|
-
✓ src/utils/sessionEvents.test.ts (14 tests) 55ms
|
|
9
8
|
✓ src/utils/eventContent.test.ts (38 tests) 103ms
|
|
10
|
-
✓ src/utils/
|
|
11
|
-
✓ src/utils/
|
|
12
|
-
✓ src/utils/
|
|
13
|
-
✓ src/utils/scrollUtils.test.ts (11 tests)
|
|
14
|
-
✓ src/
|
|
15
|
-
✓ src/components/tools/
|
|
16
|
-
✓ src/components/
|
|
17
|
-
✓ src/components/
|
|
18
|
-
✓ src/
|
|
19
|
-
✓ src/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
9
|
+
✓ src/utils/sessionEvents.test.ts (14 tests) 54ms
|
|
10
|
+
✓ src/utils/dashboard.test.ts (4 tests) 28ms
|
|
11
|
+
✓ src/utils/route-config.test.ts (23 tests) 45ms
|
|
12
|
+
✓ src/utils/scrollUtils.test.ts (11 tests) 19ms
|
|
13
|
+
✓ src/utils/breadcrumbs.test.ts (18 tests) 108ms
|
|
14
|
+
✓ src/components/tools/classifyTool.test.ts (6 tests) 17ms
|
|
15
|
+
✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 21ms
|
|
16
|
+
✓ src/components/display/extractText.test.tsx (8 tests) 6ms
|
|
17
|
+
✓ src/components/editable/useEditableField.test.tsx (17 tests) 154ms
|
|
18
|
+
✓ src/hooks/useEventSelection.test.ts (13 tests) 114ms
|
|
19
|
+
✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 73ms
|
|
20
|
+
✓ src/components/display/McpAppWidget.test.tsx (3 tests) 251ms
|
|
21
|
+
✓ src/utils/grackleHostStyleVariables.test.ts (2 tests) 229ms
|
|
22
|
+
|
|
23
|
+
Test Files 14 passed (14)
|
|
24
|
+
Tests 171 passed (171)
|
|
25
|
+
Start at 14:51:21
|
|
26
|
+
Duration 13.26s (transform 2.17s, setup 0ms, collect 16.02s, tests 1.22s, environment 8.39s, prepare 5.24s)
|
|
25
27
|
|
|
26
28
|
[test:vitest] Vitest completed.
|
|
27
|
-
[test:storybook-test] Starting Storybook static server on port
|
|
29
|
+
[test:storybook-test] Starting Storybook static server on port 45689...
|
|
28
30
|
[test:storybook-test] Storybook server ready. Running interaction tests...
|
|
29
31
|
jest-haste-map: duplicate manual mock found: adapter-manager
|
|
30
32
|
The following files share their name; please delete one of them:
|
|
@@ -101,21 +103,21 @@ jest-haste-map: duplicate manual mock found: token-push
|
|
|
101
103
|
* <rootDir>/packages/server/dist/__mocks__/token-push.js
|
|
102
104
|
* <rootDir>/packages/server/src/__mocks__/token-push.ts
|
|
103
105
|
|
|
104
|
-
jest-haste-map: duplicate manual mock found: utils/exec
|
|
105
|
-
The following files share their name; please delete one of them:
|
|
106
|
-
* <rootDir>/packages/server/dist/__mocks__/utils/exec.js
|
|
107
|
-
* <rootDir>/packages/server/src/__mocks__/utils/exec.ts
|
|
108
|
-
|
|
109
106
|
jest-haste-map: duplicate manual mock found: utils/format-gh-error
|
|
110
107
|
The following files share their name; please delete one of them:
|
|
111
108
|
* <rootDir>/packages/server/dist/__mocks__/utils/format-gh-error.js
|
|
112
109
|
* <rootDir>/packages/server/src/__mocks__/utils/format-gh-error.ts
|
|
113
110
|
|
|
111
|
+
jest-haste-map: duplicate manual mock found: utils/exec
|
|
112
|
+
The following files share their name; please delete one of them:
|
|
113
|
+
* <rootDir>/packages/server/dist/__mocks__/utils/exec.js
|
|
114
|
+
* <rootDir>/packages/server/src/__mocks__/utils/exec.ts
|
|
115
|
+
|
|
114
116
|
jest-haste-map: duplicate manual mock found: utils/network
|
|
115
117
|
The following files share their name; please delete one of them:
|
|
116
118
|
* <rootDir>/packages/server/dist/__mocks__/utils/network.js
|
|
117
119
|
* <rootDir>/packages/server/src/__mocks__/utils/network.ts
|
|
118
120
|
|
|
119
121
|
[test:storybook-test] Storybook interaction tests completed.
|
|
120
|
-
---- test finished (
|
|
121
|
-
-------------------- Finished (
|
|
122
|
+
---- test finished (68.057s) ----
|
|
123
|
+
-------------------- Finished (68.063s) --------------------
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect, fn } from "@storybook/test";
|
|
3
|
+
import { McpAppWidget } from "./McpAppWidget.js";
|
|
4
|
+
|
|
5
|
+
// The sandbox proxy MUST be served from a different origin than Storybook.
|
|
6
|
+
// Run `npm run storybook:mcp` (Storybook + the sidecar CSP server) so this is up.
|
|
7
|
+
const SANDBOX_ORIGIN: string = "http://localhost:6007";
|
|
8
|
+
const SANDBOX_URL: string = SANDBOX_ORIGIN + "/sandbox.html";
|
|
9
|
+
|
|
10
|
+
// A sample widget that loads the REAL @modelcontextprotocol/ext-apps `App` (the
|
|
11
|
+
// dependency-inlined bundle served by serve.mjs) so the MCP Apps handshake is
|
|
12
|
+
// fully spec-conformant. The widget logic lives in sample-widget.js; this is just
|
|
13
|
+
// the DOM skeleton plus the module script tag. ASCII-only (the Storybook acorn
|
|
14
|
+
// indexer breaks on non-ASCII in .stories.tsx).
|
|
15
|
+
const SAMPLE_WIDGET_HTML: string = [
|
|
16
|
+
"<!doctype html><html><head><meta charset=\"utf-8\"><style>",
|
|
17
|
+
"body{margin:0;font-family:var(--font-sans,sans-serif);color:var(--color-text-primary,#111);",
|
|
18
|
+
"background:var(--color-background-secondary,#f5f5f5);padding:16px}",
|
|
19
|
+
".card{background:var(--color-background-primary,#fff);border:1px solid var(--color-border-primary,#ddd);",
|
|
20
|
+
"border-radius:var(--border-radius-md,6px);padding:16px}",
|
|
21
|
+
"code{font-family:var(--font-mono,monospace)} button{margin-top:12px}",
|
|
22
|
+
"</style></head><body><div class=\"card\">",
|
|
23
|
+
"<h2 id=\"title\">Weather</h2>",
|
|
24
|
+
"<div>Input: <code id=\"in\">(none)</code></div>",
|
|
25
|
+
"<div>Result: <code id=\"out\">(pending)</code></div>",
|
|
26
|
+
"<button id=\"call\">Refresh</button></div>",
|
|
27
|
+
"<script type=\"module\" src=\"" + SANDBOX_ORIGIN + "/sample-widget.js\"></script>",
|
|
28
|
+
"</body></html>",
|
|
29
|
+
].join("");
|
|
30
|
+
|
|
31
|
+
const meta: Meta<typeof McpAppWidget> = {
|
|
32
|
+
title: "Grackle/Display/McpAppWidget",
|
|
33
|
+
component: McpAppWidget,
|
|
34
|
+
parameters: { layout: "padded" },
|
|
35
|
+
};
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof meta>;
|
|
39
|
+
|
|
40
|
+
export const Default: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
widgetHtml: SAMPLE_WIDGET_HTML,
|
|
43
|
+
sandboxProxyUrl: SANDBOX_URL,
|
|
44
|
+
toolInput: { location: "Seattle" },
|
|
45
|
+
toolResult: { content: [{ type: "text", text: "72F and clear" }] },
|
|
46
|
+
onCallTool: fn(async () => ({ content: [{ type: "text", text: "refreshed: 70F" }] })),
|
|
47
|
+
onOpenLink: fn(),
|
|
48
|
+
onSendMessage: fn(),
|
|
49
|
+
onSizeChange: fn(),
|
|
50
|
+
},
|
|
51
|
+
// Sidecar-independent smoke check: the host iframe mounts. The full handshake
|
|
52
|
+
// (proxy ready -> initialize -> tool-input/result -> size-changed) is exercised
|
|
53
|
+
// visually via `npm run storybook:mcp` and the PR screenshot, since it requires
|
|
54
|
+
// the cross-origin sidecar which is not part of the headless test phase.
|
|
55
|
+
play: async ({ canvas }) => {
|
|
56
|
+
const iframe = await canvas.findByTestId("mcp-app-widget");
|
|
57
|
+
await expect(iframe).toBeInTheDocument();
|
|
58
|
+
await expect(iframe.tagName.toLowerCase()).toBe("iframe");
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
|
+
import { render, cleanup, screen } from "@testing-library/react";
|
|
4
|
+
import { McpAppWidget } from "./McpAppWidget.js";
|
|
5
|
+
|
|
6
|
+
describe("McpAppWidget", () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
cleanup();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("mounts an iframe host for the widget", () => {
|
|
12
|
+
render(
|
|
13
|
+
<McpAppWidget
|
|
14
|
+
widgetHtml="<!doctype html><html><body>hi</body></html>"
|
|
15
|
+
sandboxProxyUrl="http://localhost:6007/sandbox.html"
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
const iframe = screen.getByTestId("mcp-app-widget");
|
|
19
|
+
expect(iframe.tagName.toLowerCase()).toBe("iframe");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("points the iframe at the sandbox proxy origin", () => {
|
|
23
|
+
render(
|
|
24
|
+
<McpAppWidget
|
|
25
|
+
widgetHtml="<!doctype html><html><body>hi</body></html>"
|
|
26
|
+
sandboxProxyUrl="http://localhost:6007/sandbox.html"
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
const iframe = screen.getByTestId("mcp-app-widget") as HTMLIFrameElement;
|
|
30
|
+
expect(iframe.getAttribute("src") ?? "").toContain("localhost:6007");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fails fast when the sandbox proxy shares the host origin", () => {
|
|
34
|
+
// A same-origin proxy breaks the double-iframe isolation guarantee, so the
|
|
35
|
+
// component throws rather than silently rendering an insecure widget.
|
|
36
|
+
const sameOrigin = `${window.location.origin}/sandbox.html`;
|
|
37
|
+
expect(() =>
|
|
38
|
+
render(
|
|
39
|
+
<McpAppWidget
|
|
40
|
+
widgetHtml="<!doctype html><html><body>hi</body></html>"
|
|
41
|
+
sandboxProxyUrl={sameOrigin}
|
|
42
|
+
/>,
|
|
43
|
+
),
|
|
44
|
+
).toThrow(/different origin/i);
|
|
45
|
+
});
|
|
46
|
+
});
|