Add MCP Apps support to Inspector (#1044)
* Fix MCP Apps rendering issue and add comprehensive logging
- Fixed AppRenderer useEffect dependency array to include resourceContent
This ensures the component re-evaluates when resource content arrives
- Added detailed console logging throughout the app lifecycle:
* Resource fetch and response tracking in App.tsx
* Setup conditions and AppBridge creation in AppRenderer.tsx
* HTML parsing and iframe rendering steps
* PostMessageTransport and AppBridge connection status
* App tool filtering and selection in AppsTab.tsx
- Refactored AppsTab selectedTool rendering for better tracking
The issue was that resourceContent prop updates weren't triggering
the AppRenderer setup effect. Now the effect properly responds to
both resourceUri and resourceContent changes.
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
* Fix MCP Apps iframe rendering issue
The app was getting stuck on 'Loading MCP App...' because the iframe was
hidden (display: none) until the oninitialized event fired. However, the
PostMessage handshake requires the iframe to be visible to complete.
This fix:
- Sets loading to false immediately after writing HTML to the iframe
- Makes the iframe visible before establishing PostMessage transport
- Allows the AppBridge initialization handshake to complete successfully
- Removes redundant setLoading(false) from oninitialized callback
The iframe is now visible and ready for PostMessage communication before
the AppBridge connect() call, enabling proper initialization.
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
* Fix MCP Apps HTML extraction to match spec
The AppRenderer was incorrectly checking for a 'type' field in resource
contents, but TextResourceContents objects only have uri, mimeType, and
text fields according to the MCP specification.
Fixed by checking for the presence of the 'text' field directly instead
of checking a non-existent 'type' field. This allows the HTML content
to be properly extracted and rendered in the iframe.
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
* Add comprehensive tests for MCP Apps support
- Add tests for AppsTab component (13 tests)
- Add tests for AppRenderer component (17 tests)
- Update jest.config.cjs to handle ES modules from @modelcontextprotocol/ext-apps
- All 478 tests pass successfully
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
* Add MCP Apps support to Inspector
- Add AppsTab component for detecting and listing MCP apps
- Add AppRenderer component with full AppBridge integration
- Implement UI resource fetching and sandboxed iframe rendering
- Add PostMessage transport for bidirectional JSON-RPC communication
- Include comprehensive test coverage (30 new tests)
- Auto-populate apps when tab becomes active
- Support theme awareness and configurable permissions
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
* Prefer structuredContent over content field in tool responses
Some MCP servers return different data in structuredContent vs content fields.
This change ensures that when structuredContent is present, it is used
exclusively for display instead of showing both fields.
Changes:
- Modified ToolResults.tsx to only show content when structuredContent is absent
- Removed unused checkContentCompatibility function
- Updated test cases to reflect new behavior
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
* Revert "Prefer structuredContent over content field in tool responses"
This reverts commit 3b054b4da08517cce53ef23e60c07bb32b0fa8f5.
Claude did not do the right thing.
* feat: integrate @mcp-ui/client for MCP application rendering
This commit implements the integration of `@mcp-ui/client` to handle the
rendering of Model Context Protocol (MCP) applications within the inspector.
It includes refactoring the rendering logic, improving resource fetching
in the main application state, and setting up a secure sandbox environment.
Changes per file:
- client/package.json:
- Added `@mcp-ui/client` as a dependency.
- client/src/components/AppRenderer.tsx:
- Refactored to use `McpUiAppRenderer` from `@mcp-ui/client`.
- Implemented HTML parsing logic for MCP resource responses.
- Configured host context (theme) and sandbox URL for the renderer.
- Replaced iframe-based manual rendering with the official component.
- client/src/App.tsx:
- Added `fetchingResources` state to prevent duplicate concurrent resource requests.
- Enhanced `readResource` with better error handling and state tracking.
- Optimized resource content mapping to better support application state.
- client/src/components/AppsTab.tsx:
- Updated to use `getToolUiResourceUri` utility from `@modelcontextprotocol/ext-apps`
instead of manual metadata property access.
- Simplified tool filtering and selection logic.
- client/public/sandbox_proxy.html:
- Added a sandbox proxy page to facilitate secure communication between
the inspector and the rendered MCP applications.
- client/bin/client.js:
- Added server rewrites to ensure `sandbox_proxy.html` is served correctly.
- Implemented specific `Cache-Control` headers for `sandbox_proxy.html` to
prevent stale cached versions.
- client/vite.config.ts:
- Explicitly configured `publicDir: "public"` to ensure the sandbox proxy
is included in the build output.
- client/src/components/__tests__/AppRenderer.test.tsx:
- Updated tests to mock the new `@mcp-ui/client` component.
- Adjusted assertions to verify correct props are passed to the renderer.
- Enhanced mock MCP client to include required methods like `getServerCapabilities`.
* Potential fix for code scanning alert no. 37: Client-side cross-site scripting
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Update package-lock.json
- npm install
- npm audit fix
* Potential fix for code scanning alert no. 38: Client-side cross-site scripting
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Potential fix for code scanning alert no. 39: Client-side cross-site scripting
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* prettier
* Update client/src/components/AppsTab.tsx
Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
* prettier
* In AppsTab.test.tsx, fetch button by aria-label
* relative imports
* Remove sanitization logic from sandbox_proxy.html. It's breaking the app rendering. Will sort out what best practices for this HTML is later
* In sandbox_proxy.html,formatting
In AppsTab.tsx
- Add app window
- when an app is selected,
- show input form for apps that have an input schema
- button shows app
- if app is shown, show maximize button
- if maximized,
- show minimize button
- hide app list
* In AppsTab.test.tsx
- test new layout and controls
* In AppRenderer.tsx
- accept toolInput
- pass to McpUiAppRenderer
* In AppsTab.tsx, if an app has no inputSchema, just show it, otherwise show the form and the "back to form" button when showing the app.
* In AppsTab.test.tsx
- test new behavior
* In ListPane.tsx
- remove the Clear button
* In ListPane.test.tsx
- remove test for clear button
* In AppRenderer.tsx,
- remove onReadResource and resourceContent, not needed since mcpClient is being passed
* In App.tsx and AppsTab.tsx,
- remove the passing of these variables
* In AppRenderer.test.tsx, and AppsTab.test.tsx
- remove tests that fail after having removed onReadResource and resourceContent
* Serving the sandbox_proxy.html from a separate port, using the proxy instead of the webclient server.
* In App.tsx
- pass the path to the sandbox endpoint on the proxy to the AppsTab
* In AppsTab.tsx
- accept the sandboxPath property and pass it to the AppRenderer
* In AppRenderer.tsx
- accept the sandboxPath property and pass it to the McpUiAppRenderer
* In client.js
- remove the rewrite and header config for sandbox_proxy.html
* In server/src/index.ts
- add a /sandbox endpoint that reads and returns the sandbox_proxy.html file with no-cache header
* In server/package.json
- add shx for cross-platform copy function
- in build script, copy static folder to build folder
* Moved sandbox_proxy.html from client/public to server/static
* In client.js
- return to original state
* In vite.config.ts
- return to original state
* Potential fix for code scanning alert no. 41: Missing rate limiting
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* In AppRenderer.tsx
- add open link handler that handles link requests from the UI. Makes sure that the URL starts with http or https
* In sandbox_proxy.html
- refactor to match [basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example
- creates an nested iframe for security.
- in this implementation, for simplicity the sandbox.ts is inlined in sandbox_proxy.html as javascript
* prettier
* In AppRenderer.tsx
- add support for handling McpUiMessageRequest messages from the UI
- show message in a toast
* Add logging message handler to AppRenderer
In App.tsx
- pass an onNotification function to the AppsTab that adds a notification to the notification array
In AppsTab.tsx
- Accept the onNotification function and pass it to the AppRenderer
In AppRenderer.tsx
- Accept the onNotification function
- added an handleLoggingMessage function that takes a LoggingMessageNotificationParams object, wraps it with a method and params prop to make it a ServerNotification shape, and pass it to the onNotification function
- pass handleLoggingMessage to McpUiAppRenderer
* In ResourcesTab.test.tsx
- fix @ import that wasn't resolving
* In AppRenderer.tsx
- remove fetch and evaluation of resourceUri, as the mcpClient handles that (review request)
* In AppRenderer.test.tsx
- remove test for no resourceUri found
* Merge branch 'main' into full-apps-support-final
Resolved Conflicts:
- package-lock.json
* Fixed mismatch with sanboxRateLimiter and the express version
---------
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Cliff Hall <cliffhall@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
This commit is contained in:
parent
98f058765d
commit
2af3410b8e
16 changed files with 2090 additions and 196 deletions
|
|
@ -25,7 +25,7 @@
|
|||
"test:cli-metadata": "vitest run metadata.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,8 +13,15 @@ module.exports = {
|
|||
tsconfig: "tsconfig.jest.json",
|
||||
},
|
||||
],
|
||||
"^.+\\.m?js$": [
|
||||
"ts-jest",
|
||||
{
|
||||
tsconfig: "tsconfig.jest.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||
transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol)/)"],
|
||||
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
|
||||
// Exclude directories and files that don't need to be tested
|
||||
testPathIgnorePatterns: [
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-ui/client": "^6.0.0",
|
||||
"@modelcontextprotocol/ext-apps": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AppWindow,
|
||||
Bell,
|
||||
Files,
|
||||
FolderTree,
|
||||
|
|
@ -75,6 +76,7 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
|||
import Sidebar from "./components/Sidebar";
|
||||
import ToolsTab from "./components/ToolsTab";
|
||||
import TasksTab from "./components/TasksTab";
|
||||
import AppsTab from "./components/AppsTab";
|
||||
import { InspectorConfig } from "./lib/configurationTypes";
|
||||
import {
|
||||
getMCPProxyAddress,
|
||||
|
|
@ -126,6 +128,9 @@ const App = () => {
|
|||
const [resourceContentMap, setResourceContentMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||
const [promptContent, setPromptContent] = useState<string>("");
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
|
|
@ -308,11 +313,13 @@ const App = () => {
|
|||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
||||
"apps",
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
if (!validTabs.includes(originatingTab)) return;
|
||||
|
|
@ -440,11 +447,13 @@ const App = () => {
|
|||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
||||
"apps",
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
const isValidTab = validTabs.includes(hash);
|
||||
|
|
@ -473,6 +482,13 @@ const App = () => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mcpClient, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) {
|
||||
void listTools();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mcpClient, activeTab, serverCapabilities?.tools]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastCommand", command);
|
||||
}, [command]);
|
||||
|
|
@ -757,11 +773,13 @@ const App = () => {
|
|||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
||||
"apps",
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
if (validTabs.includes(originatingTab)) {
|
||||
|
|
@ -851,22 +869,48 @@ const App = () => {
|
|||
};
|
||||
|
||||
const readResource = async (uri: string) => {
|
||||
if (fetchingResources.has(uri) || resourceContentMap[uri]) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[App] Reading resource:", uri);
|
||||
setFetchingResources((prev) => new Set(prev).add(uri));
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/read" as const,
|
||||
params: { uri },
|
||||
},
|
||||
ReadResourceResultSchema,
|
||||
"resources",
|
||||
);
|
||||
const content = JSON.stringify(response, null, 2);
|
||||
setResourceContent(content);
|
||||
setResourceContentMap((prev) => ({
|
||||
...prev,
|
||||
[uri]: content,
|
||||
}));
|
||||
try {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/read" as const,
|
||||
params: { uri },
|
||||
},
|
||||
ReadResourceResultSchema,
|
||||
"resources",
|
||||
);
|
||||
console.log("[App] Resource read response:", {
|
||||
uri,
|
||||
responseLength: JSON.stringify(response).length,
|
||||
hasContents: !!(response as { contents?: unknown[] }).contents,
|
||||
});
|
||||
const content = JSON.stringify(response, null, 2);
|
||||
setResourceContent(content);
|
||||
setResourceContentMap((prev) => ({
|
||||
...prev,
|
||||
[uri]: content,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[App] Failed to read resource ${uri}:`, error);
|
||||
const errorString = (error as Error).message ?? String(error);
|
||||
setResourceContentMap((prev) => ({
|
||||
...prev,
|
||||
[uri]: JSON.stringify({ error: errorString }),
|
||||
}));
|
||||
} finally {
|
||||
setFetchingResources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(uri);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToResource = async (uri: string) => {
|
||||
|
|
@ -1308,6 +1352,10 @@ const App = () => {
|
|||
<ListTodo className="w-4 h-4 mr-2" />
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="apps">
|
||||
<AppWindow className="w-4 h-4 mr-2" />
|
||||
Apps
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ping">
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Ping
|
||||
|
|
@ -1497,6 +1545,19 @@ const App = () => {
|
|||
error={errors.tasks}
|
||||
nextCursor={nextTaskCursor}
|
||||
/>
|
||||
<AppsTab
|
||||
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
|
||||
tools={tools}
|
||||
listTools={() => {
|
||||
clearError("tools");
|
||||
listTools();
|
||||
}}
|
||||
error={errors.tools}
|
||||
mcpClient={mcpClient}
|
||||
onNotification={(notification) => {
|
||||
setNotifications((prev) => [...prev, notification]);
|
||||
}}
|
||||
/>
|
||||
<ConsoleTab />
|
||||
<PingTab
|
||||
onPingClick={() => {
|
||||
|
|
|
|||
128
client/src/components/AppRenderer.tsx
Normal file
128
client/src/components/AppRenderer.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import {
|
||||
Tool,
|
||||
ContentBlock,
|
||||
ServerNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
AppRenderer as McpUiAppRenderer,
|
||||
type McpUiHostContext,
|
||||
type RequestHandlerExtra,
|
||||
} from "@mcp-ui/client";
|
||||
import {
|
||||
type McpUiMessageRequest,
|
||||
type McpUiMessageResult,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
|
||||
interface AppRendererProps {
|
||||
sandboxPath: string;
|
||||
tool: Tool;
|
||||
mcpClient: Client | null;
|
||||
toolInput?: Record<string, unknown>;
|
||||
onNotification?: (notification: ServerNotification) => void;
|
||||
}
|
||||
|
||||
const AppRenderer = ({
|
||||
sandboxPath,
|
||||
tool,
|
||||
mcpClient,
|
||||
toolInput,
|
||||
onNotification,
|
||||
}: AppRendererProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const hostContext: McpUiHostContext = useMemo(
|
||||
() => ({
|
||||
theme: document.documentElement.classList.contains("dark")
|
||||
? "dark"
|
||||
: "light",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenLink = async ({ url }: { url: string }) => {
|
||||
let isError = true;
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||
window.open(url, "_blank");
|
||||
isError = false;
|
||||
}
|
||||
return { isError };
|
||||
};
|
||||
|
||||
const handleMessage = async (
|
||||
params: McpUiMessageRequest["params"],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_extra: RequestHandlerExtra,
|
||||
): Promise<McpUiMessageResult> => {
|
||||
const message = params.content
|
||||
.filter((block): block is ContentBlock & { type: "text" } =>
|
||||
Boolean(block.type === "text"),
|
||||
)
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
|
||||
if (message) {
|
||||
toast({
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleLoggingMessage = (params: LoggingMessageNotificationParams) => {
|
||||
if (onNotification) {
|
||||
onNotification({
|
||||
method: "notifications/message",
|
||||
params,
|
||||
} as ServerNotification);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mcpClient) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Waiting for MCP client...</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex-1 border rounded overflow-hidden"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<McpUiAppRenderer
|
||||
client={mcpClient}
|
||||
onOpenLink={handleOpenLink}
|
||||
onMessage={handleMessage}
|
||||
onLoggingMessage={handleLoggingMessage}
|
||||
toolName={tool.name}
|
||||
hostContext={hostContext}
|
||||
toolInput={toolInput}
|
||||
sandbox={{
|
||||
url: new URL(sandboxPath, window.location.origin),
|
||||
}}
|
||||
onError={(err) => setError(err.message)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRenderer;
|
||||
530
client/src/components/AppsTab.tsx
Normal file
530
client/src/components/AppsTab.tsx
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertCircle,
|
||||
X,
|
||||
Play,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { Tool, ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import AppRenderer from "./AppRenderer";
|
||||
import ListPane from "./ListPane";
|
||||
import IconDisplay, { WithIcons } from "./IconDisplay";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
||||
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
|
||||
import {
|
||||
generateDefaultValue,
|
||||
isPropertyRequired,
|
||||
normalizeUnionType,
|
||||
resolveRef,
|
||||
} from "@/utils/schemaUtils";
|
||||
|
||||
interface AppsTabProps {
|
||||
sandboxPath: string;
|
||||
tools: Tool[];
|
||||
listTools: () => void;
|
||||
error: string | null;
|
||||
mcpClient: Client | null;
|
||||
onNotification?: (notification: ServerNotification) => void;
|
||||
}
|
||||
|
||||
// Type guard to check if a tool has UI metadata
|
||||
const hasUIMetadata = (tool: Tool): boolean => {
|
||||
return !!getToolUiResourceUri(tool);
|
||||
};
|
||||
|
||||
const AppsTab = ({
|
||||
sandboxPath,
|
||||
tools,
|
||||
listTools,
|
||||
error,
|
||||
mcpClient,
|
||||
onNotification,
|
||||
}: AppsTabProps) => {
|
||||
const [appTools, setAppTools] = useState<Tool[]>([]);
|
||||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isAppOpen, setIsAppOpen] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||
|
||||
// Function to check if any form has validation errors
|
||||
const checkValidationErrors = useCallback(() => {
|
||||
const errors = Object.values(formRefs.current).some(
|
||||
(ref) => ref && !ref.validateJson().isValid,
|
||||
);
|
||||
setHasValidationErrors(errors);
|
||||
return errors;
|
||||
}, []);
|
||||
|
||||
// Filter tools that have UI metadata
|
||||
useEffect(() => {
|
||||
const filtered = tools.filter(hasUIMetadata);
|
||||
console.log("[AppsTab] Filtered app tools:", {
|
||||
totalTools: tools.length,
|
||||
appTools: filtered.length,
|
||||
appToolNames: filtered.map((t) => t.name),
|
||||
});
|
||||
setAppTools(filtered);
|
||||
|
||||
// If current selected tool is no longer available, reset selection
|
||||
if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) {
|
||||
setSelectedTool(null);
|
||||
setIsAppOpen(false);
|
||||
}
|
||||
}, [tools, selectedTool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTool) {
|
||||
const initialParams = Object.entries(
|
||||
selectedTool.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => {
|
||||
// First resolve any $ref references
|
||||
const resolvedValue = resolveRef(
|
||||
value as JsonSchemaType,
|
||||
selectedTool.inputSchema as JsonSchemaType,
|
||||
);
|
||||
return [
|
||||
key,
|
||||
generateDefaultValue(
|
||||
resolvedValue,
|
||||
key,
|
||||
selectedTool.inputSchema as JsonSchemaType,
|
||||
),
|
||||
];
|
||||
});
|
||||
setParams(Object.fromEntries(initialParams));
|
||||
setHasValidationErrors(false);
|
||||
formRefs.current = {};
|
||||
} else {
|
||||
setParams({});
|
||||
setIsAppOpen(false);
|
||||
}
|
||||
}, [selectedTool]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
listTools();
|
||||
}, [listTools]);
|
||||
|
||||
const handleCloseApp = useCallback(() => {
|
||||
setIsAppOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenApp = useCallback(() => {
|
||||
if (!checkValidationErrors()) {
|
||||
setIsAppOpen(true);
|
||||
}
|
||||
}, [checkValidationErrors]);
|
||||
|
||||
const handleSelectTool = useCallback((tool: Tool) => {
|
||||
setSelectedTool(tool);
|
||||
const hasFields =
|
||||
tool.inputSchema.properties &&
|
||||
Object.keys(tool.inputSchema.properties).length > 0;
|
||||
setIsAppOpen(!hasFields);
|
||||
}, []);
|
||||
|
||||
const handleDeselectTool = useCallback(() => {
|
||||
setSelectedTool(null);
|
||||
setIsAppOpen(false);
|
||||
setIsMaximized(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TabsContent value="apps" className="space-y-4">
|
||||
<div
|
||||
className={
|
||||
isMaximized
|
||||
? "grid grid-cols-1 gap-4"
|
||||
: "grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
}
|
||||
>
|
||||
{!isMaximized && (
|
||||
<ListPane
|
||||
items={appTools}
|
||||
listItems={handleRefresh}
|
||||
setSelectedItem={handleSelectTool}
|
||||
renderItem={(tool) => {
|
||||
return (
|
||||
<div className="flex items-start w-full gap-2">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<IconDisplay icons={(tool as WithIcons).icons} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="truncate font-semibold">{tool.name}</span>
|
||||
{tool.description && (
|
||||
<span className="text-sm text-gray-500 text-left line-clamp-2">
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400 mt-1" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
title="MCP Apps"
|
||||
buttonText="Refresh Apps"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-card border border-border rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTool && (
|
||||
<IconDisplay
|
||||
icons={(selectedTool as WithIcons).icons}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-semibold">
|
||||
{selectedTool ? selectedTool.name : "Select an app"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedTool && isAppOpen && (
|
||||
<Button
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={isMaximized ? "Minimize" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedTool && (
|
||||
<Button
|
||||
onClick={handleDeselectTool}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Close app"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedTool ? (
|
||||
(() => {
|
||||
const hasFields =
|
||||
selectedTool.inputSchema.properties &&
|
||||
Object.keys(selectedTool.inputSchema.properties).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!isAppOpen ? (
|
||||
<div className="space-y-4">
|
||||
{selectedTool.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 border rounded-lg p-4 bg-muted/30">
|
||||
<h4 className="font-medium text-sm">App Input</h4>
|
||||
{Object.entries(
|
||||
selectedTool.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => {
|
||||
// First resolve any $ref references
|
||||
const resolvedValue = resolveRef(
|
||||
value as JsonSchemaType,
|
||||
selectedTool.inputSchema as JsonSchemaType,
|
||||
);
|
||||
const prop = normalizeUnionType(resolvedValue);
|
||||
const inputSchema =
|
||||
selectedTool.inputSchema as JsonSchemaType;
|
||||
const required = isPropertyRequired(
|
||||
key,
|
||||
inputSchema,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{key}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{prop.nullable ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${key}-null`}
|
||||
checked={params[key] === null}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked
|
||||
? null
|
||||
: prop.type === "array"
|
||||
? undefined
|
||||
: prop.default !== null
|
||||
? prop.default
|
||||
: prop.type === "boolean"
|
||||
? false
|
||||
: prop.type === "string"
|
||||
? ""
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${key}-null`}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
null
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
|
||||
>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
checked={!!params[key]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{prop.description ||
|
||||
"Toggle this option"}
|
||||
</label>
|
||||
</div>
|
||||
) : prop.type === "string" && prop.enum ? (
|
||||
<Select
|
||||
value={
|
||||
params[key] === undefined
|
||||
? ""
|
||||
: String(params[key])
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
value === "" ? undefined : value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={key}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
prop.description ||
|
||||
"Select an option"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{prop.enum.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : prop.type === "string" ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
placeholder={prop.description}
|
||||
value={
|
||||
params[key] === undefined
|
||||
? ""
|
||||
: String(params[key])
|
||||
}
|
||||
onChange={(e) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : prop.type === "object" ||
|
||||
prop.type === "array" ? (
|
||||
<DynamicJsonForm
|
||||
ref={(ref) =>
|
||||
(formRefs.current[key] = ref)
|
||||
}
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={
|
||||
(params[key] as JsonValue) ??
|
||||
generateDefaultValue(prop)
|
||||
}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
setTimeout(checkValidationErrors, 100);
|
||||
}}
|
||||
/>
|
||||
) : prop.type === "number" ||
|
||||
prop.type === "integer" ? (
|
||||
<Input
|
||||
type="number"
|
||||
id={key}
|
||||
placeholder={prop.description}
|
||||
value={
|
||||
params[key] === undefined
|
||||
? ""
|
||||
: String(params[key])
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: undefined,
|
||||
});
|
||||
} else {
|
||||
const num = Number(value);
|
||||
setParams({
|
||||
...params,
|
||||
[key]: isNaN(num) ? value : num,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DynamicJsonForm
|
||||
ref={(ref) =>
|
||||
(formRefs.current[key] = ref)
|
||||
}
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={params[key] as JsonValue}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
setTimeout(checkValidationErrors, 100);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
onClick={handleOpenApp}
|
||||
className="w-full"
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Open App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{hasFields && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleCloseApp}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Back to Input
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[600px]">
|
||||
<AppRenderer
|
||||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
mcpClient={mcpClient}
|
||||
toolInput={params}
|
||||
onNotification={onNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-4">
|
||||
<AlertCircle className="w-12 h-12 opacity-20" />
|
||||
<p>Select an app from the list to get started</p>
|
||||
{appTools.length === 0 && (
|
||||
<p className="text-xs text-center max-w-[200px]">
|
||||
No MCP Apps available. Apps are tools that include a{" "}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
_meta.ui.resourceUri
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppsTab;
|
||||
|
|
@ -6,7 +6,7 @@ import { useState, useMemo, useRef } from "react";
|
|||
type ListPaneProps<T> = {
|
||||
items: T[];
|
||||
listItems: () => void;
|
||||
clearItems: () => void;
|
||||
clearItems?: () => void;
|
||||
setSelectedItem: (item: T) => void;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
title: string;
|
||||
|
|
@ -101,14 +101,16 @@ const ListPane = <T extends object>({
|
|||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-4"
|
||||
onClick={clearItems}
|
||||
disabled={items.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
{clearItems && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mb-4"
|
||||
onClick={clearItems}
|
||||
disabled={items.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
|
|
|
|||
115
client/src/components/__tests__/AppRenderer.test.tsx
Normal file
115
client/src/components/__tests__/AppRenderer.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||
import AppRenderer from "../AppRenderer";
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { RequestHandlerExtra } from "@mcp-ui/client";
|
||||
import { McpUiMessageResult } from "@modelcontextprotocol/ext-apps";
|
||||
|
||||
// Mock the ext-apps module
|
||||
jest.mock("@modelcontextprotocol/ext-apps/app-bridge", () => ({
|
||||
getToolUiResourceUri: (tool: Tool) => {
|
||||
const meta = (tool as Tool & { _meta?: { ui?: { resourceUri?: string } } })
|
||||
._meta;
|
||||
return meta?.ui?.resourceUri || null;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock toast hook
|
||||
const mockToast = jest.fn();
|
||||
jest.mock("@/lib/hooks/useToast", () => ({
|
||||
useToast: () => ({
|
||||
toast: mockToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @mcp-ui/client
|
||||
jest.mock("@mcp-ui/client", () => ({
|
||||
AppRenderer: ({
|
||||
toolName,
|
||||
onMessage,
|
||||
}: {
|
||||
toolName: string;
|
||||
onMessage?: (
|
||||
params: { role: "user"; content: { type: "text"; text: string }[] },
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiMessageResult>;
|
||||
}) => (
|
||||
<div data-testid="mcp-ui-app-renderer">
|
||||
<div data-testid="tool-name">{toolName}</div>
|
||||
<button
|
||||
data-testid="trigger-message"
|
||||
onClick={() =>
|
||||
onMessage?.(
|
||||
{ role: "user", content: [{ type: "text", text: "Test message" }] },
|
||||
{} as RequestHandlerExtra,
|
||||
)
|
||||
}
|
||||
>
|
||||
Trigger Message
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("AppRenderer", () => {
|
||||
const mockTool: Tool = {
|
||||
name: "testApp",
|
||||
description: "Test app with UI",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: "ui://test-app",
|
||||
},
|
||||
},
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
const mockMcpClient = {
|
||||
request: jest.fn(),
|
||||
getServerCapabilities: jest.fn().mockReturnValue({}),
|
||||
setNotificationHandler: jest.fn(),
|
||||
} as unknown as Client;
|
||||
|
||||
const defaultProps = {
|
||||
sandboxPath: "/sandbox",
|
||||
tool: mockTool,
|
||||
mcpClient: mockMcpClient,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should display waiting state when mcpClient is null", () => {
|
||||
render(<AppRenderer {...defaultProps} mcpClient={null} />);
|
||||
expect(screen.getByText(/Waiting for MCP client/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render McpUiAppRenderer when client is ready", () => {
|
||||
render(<AppRenderer {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("mcp-ui-app-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tool-name")).toHaveTextContent("testApp");
|
||||
});
|
||||
|
||||
it("should set minimum height on container", () => {
|
||||
render(<AppRenderer {...defaultProps} />);
|
||||
|
||||
const container = screen.getByTestId("mcp-ui-app-renderer").parentElement;
|
||||
expect(container).toHaveStyle({ minHeight: "400px" });
|
||||
});
|
||||
|
||||
it("should show toast when onMessage is triggered", () => {
|
||||
render(<AppRenderer {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("trigger-message"));
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
description: "Test message",
|
||||
});
|
||||
});
|
||||
});
|
||||
481
client/src/components/__tests__/AppsTab.test.tsx
Normal file
481
client/src/components/__tests__/AppsTab.test.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||
import AppsTab from "../AppsTab";
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Tabs } from "../ui/tabs";
|
||||
|
||||
// Mock AppRenderer component
|
||||
jest.mock("../AppRenderer", () => {
|
||||
return function MockAppRenderer({
|
||||
tool,
|
||||
toolInput,
|
||||
}: {
|
||||
tool: Tool;
|
||||
toolInput?: Record<string, unknown>;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="app-renderer">
|
||||
<div>Tool: {tool.name}</div>
|
||||
<div data-testid="tool-input">{JSON.stringify(toolInput)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
describe("AppsTab", () => {
|
||||
const mockAppTool: Tool = {
|
||||
name: "weatherApp",
|
||||
description: "Weather app with UI",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: "ui://weather-app",
|
||||
},
|
||||
},
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
const mockRegularTool: Tool = {
|
||||
name: "regularTool",
|
||||
description: "Regular tool without UI",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
tools: [],
|
||||
listTools: jest.fn(),
|
||||
error: null,
|
||||
mcpClient: null,
|
||||
};
|
||||
|
||||
const renderAppsTab = (props = {}) => {
|
||||
return render(
|
||||
<Tabs defaultValue="apps">
|
||||
<AppsTab {...defaultProps} {...props} />
|
||||
</Tabs>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should display message when no apps are available", () => {
|
||||
renderAppsTab();
|
||||
|
||||
expect(screen.getByText(/No MCP Apps available/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/_meta\.ui\.resourceUri/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter and display only tools with UI metadata", () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool, mockRegularTool],
|
||||
});
|
||||
|
||||
// Should show the app tool
|
||||
expect(screen.getByText("weatherApp")).toBeInTheDocument();
|
||||
expect(screen.getByText("Weather app with UI")).toBeInTheDocument();
|
||||
|
||||
// Should not show the regular tool
|
||||
expect(screen.queryByText("regularTool")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display multiple app tools in a grid", () => {
|
||||
const mockAppTool2: Tool = {
|
||||
name: "calendarApp",
|
||||
description: "Calendar app with UI",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: "ui://calendar-app",
|
||||
},
|
||||
},
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool, mockAppTool2],
|
||||
});
|
||||
|
||||
expect(screen.getByText("weatherApp")).toBeInTheDocument();
|
||||
expect(screen.getByText("calendarApp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call listTools when refresh button is clicked", () => {
|
||||
const mockListTools = jest.fn();
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
listTools: mockListTools,
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByRole("button", { name: /refresh/i });
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
expect(mockListTools).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display error message when error prop is provided", () => {
|
||||
const errorMessage = "Failed to fetch tools";
|
||||
renderAppsTab({
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "fieldsApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
field1: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://fields" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
});
|
||||
|
||||
const appCard = screen.getByText("fieldsApp").closest("div");
|
||||
expect(appCard).toBeTruthy();
|
||||
fireEvent.click(appCard!);
|
||||
|
||||
// Should see the input form now, not the renderer yet
|
||||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("App Input")).toBeInTheDocument();
|
||||
|
||||
// Click Open App button
|
||||
const openAppButton = screen.getByRole("button", { name: /open app/i });
|
||||
fireEvent.click(openAppButton);
|
||||
|
||||
// AppRenderer should be rendered
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close app renderer when close button is clicked", () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
// Open the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
|
||||
// Close the app (Deselect tool)
|
||||
const closeButton = screen.getByRole("button", { name: /close app/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// AppRenderer should be removed
|
||||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tool without description", () => {
|
||||
const toolWithoutDescription: Tool = {
|
||||
name: "noDescriptionApp",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: "ui://no-description-app",
|
||||
},
|
||||
},
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithoutDescription],
|
||||
});
|
||||
|
||||
expect(screen.getByText("noDescriptionApp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset selected tool when tools list changes and selected tool is removed", () => {
|
||||
const { rerender } = renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
|
||||
// Update tools list to remove the selected tool
|
||||
rerender(
|
||||
<Tabs defaultValue="apps">
|
||||
<AppsTab {...defaultProps} tools={[]} />
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
// AppRenderer should be removed
|
||||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain selected tool when tools list updates but includes the same tool", () => {
|
||||
const { rerender } = renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
|
||||
// Update tools list with the same tool
|
||||
rerender(
|
||||
<Tabs defaultValue="apps">
|
||||
<AppsTab {...defaultProps} tools={[mockAppTool]} />
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
// AppRenderer should still be rendered
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maximize and minimize the app window", () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
|
||||
// Now Maximize button should be visible
|
||||
expect(
|
||||
screen.getByRole("button", { name: /maximize/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Initially, ListPane should be visible
|
||||
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
|
||||
|
||||
// Click Maximize
|
||||
const maximizeButton = screen.getByRole("button", { name: /maximize/i });
|
||||
fireEvent.click(maximizeButton);
|
||||
|
||||
// ListPane should be hidden
|
||||
expect(screen.queryByText("MCP Apps")).not.toBeInTheDocument();
|
||||
|
||||
// Click Minimize
|
||||
const minimizeButton = screen.getByRole("button", { name: /minimize/i });
|
||||
fireEvent.click(minimizeButton);
|
||||
|
||||
// ListPane should be visible again
|
||||
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset maximized state when app is closed", () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
|
||||
// Maximize
|
||||
fireEvent.click(screen.getByRole("button", { name: /maximize/i }));
|
||||
expect(screen.queryByText("MCP Apps")).not.toBeInTheDocument();
|
||||
|
||||
// Close app (deselect tool)
|
||||
fireEvent.click(screen.getByRole("button", { name: /close app/i }));
|
||||
|
||||
// ListPane should be visible again
|
||||
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display app cards in the list", () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
const appItem = screen.getByText("weatherApp").closest("div");
|
||||
expect(appItem).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle empty resourceContentMap", () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
||||
// Open the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
|
||||
// AppRenderer should still be rendered
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle various input types and pass them to AppRenderer", () => {
|
||||
const toolWithComplexSchema: Tool = {
|
||||
name: "complexApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
toggle: { type: "boolean" },
|
||||
number: { type: "number" },
|
||||
choice: { type: "string", enum: ["a", "b"] },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://complex" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithComplexSchema],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("complexApp"));
|
||||
|
||||
// Fill the form
|
||||
fireEvent.change(screen.getByLabelText("text"), {
|
||||
target: { value: "hello" },
|
||||
});
|
||||
// Checkboxes are often rendered with a label next to them, let's look for the text
|
||||
const toggleLabel = screen.getByText(/Toggle this option/i);
|
||||
fireEvent.click(toggleLabel);
|
||||
fireEvent.change(screen.getByLabelText("number"), {
|
||||
target: { value: "42" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
const toolInput = JSON.parse(
|
||||
screen.getByTestId("tool-input").textContent || "{}",
|
||||
);
|
||||
expect(toolInput.text).toBe("hello");
|
||||
expect(toolInput.toggle).toBe(true);
|
||||
expect(toolInput.number).toBe(42);
|
||||
});
|
||||
|
||||
it("should handle nullable fields", () => {
|
||||
const toolWithNullable: Tool = {
|
||||
name: "nullableApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nullableField: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://nullable" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithNullable],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("nullableApp"));
|
||||
|
||||
// Check the 'null' checkbox
|
||||
const nullCheckbox = screen.getByLabelText("null");
|
||||
fireEvent.click(nullCheckbox);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
const toolInput = JSON.parse(
|
||||
screen.getByTestId("tool-input").textContent || "{}",
|
||||
);
|
||||
expect(toolInput.nullableField).toBe(null);
|
||||
});
|
||||
|
||||
it("should allow going back to input form from app renderer", () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "fieldsApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
field1: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://fields" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("fieldsApp"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
|
||||
const backButton = screen.queryByRole("button", { name: /back to input/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should skip input form if tool has no input fields", () => {
|
||||
const toolNoFields: Tool = {
|
||||
name: "noFieldsApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://no-fields" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolNoFields],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("noFieldsApp"));
|
||||
|
||||
// Should see the renderer immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tool: noFieldsApp")).toBeInTheDocument();
|
||||
|
||||
// Should NOT see the back button
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /back to input/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow going back to input form from app renderer if fields exist", () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "fieldsApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
field1: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://fields" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("fieldsApp"));
|
||||
|
||||
// Should see input form first
|
||||
expect(screen.getByText("App Input")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back to input/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("App Input")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,8 +8,8 @@ import {
|
|||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { SESSION_KEYS } from "@/lib/constants";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
import { SESSION_KEYS } from "../../lib/constants";
|
||||
|
||||
const mockOAuthTokens = {
|
||||
access_token: "test_access_token",
|
||||
|
|
@ -55,7 +55,7 @@ import {
|
|||
discoverOAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
||||
import { EMPTY_DEBUGGER_STATE } from "../../lib/auth-types";
|
||||
|
||||
// Mock local auth module
|
||||
jest.mock("@/lib/auth", () => ({
|
||||
|
|
@ -106,7 +106,7 @@ jest.mock("@/lib/auth", () => ({
|
|||
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
|
||||
}));
|
||||
|
||||
import { discoverScopes } from "@/lib/auth";
|
||||
import { discoverScopes } from "../../lib/auth";
|
||||
|
||||
// Type the mocked functions properly
|
||||
const mockDiscoverAuthorizationServerMetadata =
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ describe("ListPane", () => {
|
|||
const defaultProps = {
|
||||
items: mockItems,
|
||||
listItems: jest.fn(),
|
||||
clearItems: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
|
||||
title: "List tools",
|
||||
|
|
@ -36,6 +35,13 @@ describe("ListPane", () => {
|
|||
expect(
|
||||
screen.getByRole("button", { name: "Load Tools" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Clear button when clearItems prop is provided", () => {
|
||||
renderListPane({ clearItems: jest.fn() });
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Tabs } from "../ui/tabs";
|
||||
import ResourcesTab from "../ResourcesTab";
|
||||
import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
|
|
|
|||
660
package-lock.json
generated
660
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,14 +14,14 @@
|
|||
"build"
|
||||
],
|
||||
"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",
|
||||
|
|
@ -32,8 +32,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