Merge branch 'main' into claude/issue-1036-20260204-1236
This commit is contained in:
commit
8e4ab635fa
3 changed files with 2293 additions and 278 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Server-side application for the Model Context Protocol inspector",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||
|
|
@ -15,14 +15,14 @@
|
|||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && shx cp -R static build",
|
||||
"start": "node build/index.js",
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
|
|
@ -33,8 +33,10 @@
|
|||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shx": "^0.3.4",
|
||||
"spawn-rx": "^5.1.2",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^3.25.76",
|
||||
"express-rate-limit": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,21 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { findActualExecutable } from "spawn-rx";
|
||||
import mcpProxy from "./mcpProxy.js";
|
||||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||
|
||||
const sandboxRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 /sandbox requests per windowMs
|
||||
});
|
||||
|
||||
const defaultEnvironment = {
|
||||
...getDefaultEnvironment(),
|
||||
...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
|
||||
|
|
@ -787,6 +796,26 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/sandbox",
|
||||
sandboxRateLimiter as express.RequestHandler,
|
||||
(req, res) => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const filePath = join(__dirname, "..", "static", "sandbox_proxy.html");
|
||||
let sandboxHtml;
|
||||
|
||||
try {
|
||||
sandboxHtml = readFileSync(filePath, "utf-8");
|
||||
} catch (e) {
|
||||
sandboxHtml = "MCP Apps sandbox not loaded: " + e;
|
||||
}
|
||||
|
||||
res.set("Cache-Control", "no-cache, no-store, max-age=0");
|
||||
res.send(sandboxHtml);
|
||||
},
|
||||
);
|
||||
|
||||
const PORT = parseInt(
|
||||
process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT,
|
||||
10,
|
||||
|
|
|
|||
197
server/static/sandbox_proxy.html
Normal file
197
server/static/sandbox_proxy.html
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!-- CSP is set via HTTP headers by serve.ts (based on ?csp= query param).
|
||||
The inner iframe inherits this CSP since we use document.write(). -->
|
||||
<title>MCP-UI Proxy</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
/* Transparent background allows parent page to show through */
|
||||
background-color: transparent;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
iframe {
|
||||
background-color: transparent;
|
||||
border: 0px none transparent;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
/* Inherit color scheme from parent for consistent transparency */
|
||||
color-scheme: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
function buildAllowAttribute(permissions) {
|
||||
if (!permissions) return "";
|
||||
|
||||
const allowList = [];
|
||||
if (permissions["camera"]) allowList.push("camera");
|
||||
if (permissions["microphone"]) allowList.push("microphone");
|
||||
if (permissions["geolocation"]) allowList.push("geolocation");
|
||||
if (permissions["clipboardWrite"]) allowList.push("clipboard-write");
|
||||
|
||||
return allowList.join("; ");
|
||||
}
|
||||
|
||||
const ALLOWED_REFERRER_PATTERN =
|
||||
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
|
||||
|
||||
if (window.self === window.top) {
|
||||
throw new Error("This file is only to be used in an iframe sandbox.");
|
||||
}
|
||||
|
||||
if (!document.referrer) {
|
||||
throw new Error("No referrer, cannot validate embedding site.");
|
||||
}
|
||||
|
||||
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
|
||||
throw new Error(
|
||||
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the expected host origin from the referrer for origin validation.
|
||||
// This is the origin we expect all parent messages to come from.
|
||||
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
|
||||
|
||||
const OWN_ORIGIN = new URL(window.location.href).origin;
|
||||
|
||||
// Security self-test: verify iframe isolation is working correctly.
|
||||
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
|
||||
// configuration is dangerously broken and untrusted content could escape.
|
||||
try {
|
||||
window.top?.alert(
|
||||
"If you see this, the sandbox is not setup securely.",
|
||||
);
|
||||
throw "FAIL";
|
||||
} catch (e) {
|
||||
if (e === "FAIL") {
|
||||
throw new Error("The sandbox is not setup securely.");
|
||||
}
|
||||
|
||||
// Expected: SecurityError confirms proper sandboxing.
|
||||
}
|
||||
|
||||
// Double-iframe sandbox architecture: THIS file is the outer sandbox proxy
|
||||
// iframe on a separate origin. It creates an inner iframe for untrusted HTML
|
||||
// content. Per the specification, the Host and the Sandbox MUST have different
|
||||
// origins.
|
||||
const inner = document.createElement("iframe");
|
||||
inner.style = "width:100%; height:100%; border:none;";
|
||||
inner.setAttribute(
|
||||
"sandbox",
|
||||
"allow-scripts allow-same-origin allow-forms",
|
||||
);
|
||||
// Note: allow attribute is set later when receiving sandbox-resource-ready notification
|
||||
// based on the permissions requested by the app
|
||||
document.body.appendChild(inner);
|
||||
|
||||
const RESOURCE_READY_NOTIFICATION =
|
||||
"ui/notifications/sandbox-resource-ready";
|
||||
const PROXY_READY_NOTIFICATION = "ui/notifications/sandbox-proxy-ready";
|
||||
|
||||
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
||||
// forwarding messages between:
|
||||
//
|
||||
// Host (parent window) ↔ Sandbox (outer frame) ↔ View (inner iframe)
|
||||
//
|
||||
// Reason: the parent window and inner iframe have different origins and can't
|
||||
// communicate directly, so the outer iframe forwards messages in both
|
||||
// directions to connect them.
|
||||
//
|
||||
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
|
||||
// intercepted here (not relayed) because the Sandbox uses it to configure and
|
||||
// load the inner iframe with the view HTML content.
|
||||
//
|
||||
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
|
||||
// based on ?csp= query param). This is tamper-proof unlike meta tags.
|
||||
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.source === window.parent) {
|
||||
// Validate that messages from parent come from the expected host origin.
|
||||
// This prevents malicious pages from sending messages to this sandbox.
|
||||
if (event.origin !== EXPECTED_HOST_ORIGIN) {
|
||||
console.error(
|
||||
"[Sandbox] Rejecting message from unexpected origin:",
|
||||
event.origin,
|
||||
"expected:",
|
||||
EXPECTED_HOST_ORIGIN,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
|
||||
const { html, sandbox, permissions } = event.data.params;
|
||||
if (typeof sandbox === "string") {
|
||||
inner.setAttribute("sandbox", sandbox);
|
||||
}
|
||||
// Set Permission Policy allow attribute if permissions are requested
|
||||
const allowAttribute = buildAllowAttribute(permissions);
|
||||
if (allowAttribute) {
|
||||
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
|
||||
inner.setAttribute("allow", allowAttribute);
|
||||
}
|
||||
if (typeof html === "string") {
|
||||
// Use document.write instead of srcdoc (which the CesiumJS Map won't work with)
|
||||
const doc =
|
||||
inner.contentDocument || inner.contentWindow?.document;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
} else {
|
||||
// Fallback to srcdoc if document is not accessible
|
||||
console.warn(
|
||||
"[Sandbox] document.write not available, falling back to srcdoc",
|
||||
);
|
||||
inner.srcdoc = html;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (inner && inner.contentWindow) {
|
||||
inner.contentWindow.postMessage(event.data, "*");
|
||||
}
|
||||
}
|
||||
} else if (event.source === inner.contentWindow) {
|
||||
if (event.origin !== OWN_ORIGIN) {
|
||||
console.error(
|
||||
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
|
||||
event.origin,
|
||||
"expected:",
|
||||
OWN_ORIGIN,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Relay messages from inner frame to parent window.
|
||||
// Use specific origin instead of "*" to prevent message interception.
|
||||
window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
|
||||
}
|
||||
});
|
||||
|
||||
// Notify the Host that the Sandbox is ready to receive view HTML.
|
||||
// Use specific origin instead of "*" to ensure only the expected host receives this.
|
||||
window.parent.postMessage(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: PROXY_READY_NOTIFICATION,
|
||||
params: {},
|
||||
},
|
||||
EXPECTED_HOST_ORIGIN,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue