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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["Bash(git remote prune:*)"]
}
}
17 changes: 16 additions & 1 deletion apps/mesh/src/api/routes/decopilot/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,25 @@ export async function toolsFromMCP(
};
}
if ("structuredContent" in output) {
return {
// Include _meta if present in the output
const result: { type: "json"; value: JSONValue } = {
type: "json",
value: output.structuredContent as JSONValue,
};
if ("_meta" in output && output._meta) {
(result.value as Record<string, unknown>)._meta = output._meta;
}
return result;
}
// For content type, wrap in an object that includes _meta
if ("_meta" in output && output._meta) {
return {
type: "json",
value: {
content: output.content,
_meta: output._meta,
} as JSONValue,
};
}
// Convert MCP content parts to text for the model output.
// "content" is not a valid AI SDK output type — using it causes
Expand Down
22 changes: 22 additions & 0 deletions apps/mesh/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,25 @@

/** MCP Mesh metadata key in tool _meta */
export const MCP_MESH_KEY = "mcp.mesh";

/**
* MCP Apps feature flag
*
* When enabled, Mesh will render interactive UIs for tools
* that declare UI resources via _meta["ui/resourceUri"].
*/
export const MCP_APPS_ENABLED = true;

/**
* MCP Apps configuration
*/
export const MCP_APPS_CONFIG = {
/** Minimum height for MCP App iframes in pixels */
minHeight: 100,
/** Maximum height for MCP App iframes in pixels */
maxHeight: 600,
/** Default height for MCP App iframes in pixels */
defaultHeight: 300,
/** Whether to show raw JSON output alongside MCP Apps in developer mode */
showRawOutputInDevMode: true,
} as const;
177 changes: 177 additions & 0 deletions apps/mesh/src/mcp-apps/app-preview-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* App Preview Dialog
*
* Dialog component for previewing MCP Apps in the connection detail page.
* Shows the app in a sandboxed iframe with full interactive capabilities.
*/

import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@deco/ui/components/dialog.tsx";
import { useState, useRef } from "react";
import { MCPAppRenderer } from "./mcp-app-renderer.tsx";
import type { UIResourcesReadResult, UIToolsCallResult } from "./types.ts";
import { UIResourceLoader, UIResourceLoadError } from "./resource-loader.ts";

// ============================================================================
// Types
// ============================================================================

export interface AppPreviewDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog should close */
onOpenChange: (open: boolean) => void;
/** The URI of the resource to preview */
uri: string;
/** The name of the resource */
name?: string;
/** Connection ID for the MCP server */
connectionId: string;
/** Function to read resources from the MCP server */
readResource: (uri: string) => Promise<{
contents: Array<{
uri: string;
mimeType?: string;
text?: string;
blob?: string;
}>;
}>;
/** Function to call tools on the MCP server */
callTool: (
name: string,
args: Record<string, unknown>,
) => Promise<UIToolsCallResult>;
}

// ============================================================================
// Component
// ============================================================================

/**
* Dialog for previewing MCP Apps
*
* Fetches the UI resource content and renders it in the MCPAppRenderer.
*/
/**
* Component that triggers loading on mount (used to avoid render-time side effects)
*/
function LoadTrigger({ onLoad }: { onLoad: () => void }) {
const loadedRef = useRef(false);
if (!loadedRef.current) {
loadedRef.current = true;
queueMicrotask(onLoad);
}
return null;
}

export function AppPreviewDialog({
open,
onOpenChange,
uri,
name,
connectionId,
readResource,
callTool,
}: AppPreviewDialogProps) {
const [html, setHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Load resource content
const loadResource = () => {
setLoading(true);
setError(null);
(async () => {
try {
const loader = new UIResourceLoader();
const content = await loader.load(uri, readResource);
setHtml(content.html);
} catch (err) {
console.error("Failed to load UI resource:", err);
if (err instanceof UIResourceLoadError) {
setError(err.message);
} else {
setError(
err instanceof Error ? err.message : "Failed to load resource",
);
}
} finally {
setLoading(false);
}
})();
};

// Handle dialog close - resets state
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Reset state after close animation
setTimeout(() => {
setHtml(null);
setError(null);
}, 200);
}
onOpenChange(newOpen);
};

// Determine if we need to trigger a load
const needsLoad = open && !html && !loading && !error;

// Wrapper for readResource to match the expected interface
const handleReadResource = async (
resourceUri: string,
): Promise<UIResourcesReadResult> => {
const result = await readResource(resourceUri);
return { contents: result.contents };
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{name || uri}</DialogTitle>
</DialogHeader>

{/* Trigger load when dialog is open and content not loaded */}
{needsLoad && <LoadTrigger onLoad={loadResource} />}

<div className="flex-1 min-h-0 overflow-auto">
{loading && (
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-2 text-muted-foreground">
<div className="size-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
<span>Loading app...</span>
</div>
</div>
)}

{error && (
<div className="flex items-center justify-center h-64">
<div className="text-destructive text-center">
<p className="font-medium">Failed to load app</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
</div>
</div>
)}

{html && !loading && !error && (
<MCPAppRenderer
html={html}
uri={uri}
connectionId={connectionId}
displayMode="fullscreen"
minHeight={300}
maxHeight={600}
callTool={callTool}
readResource={handleReadResource}
className="border border-border"
/>
)}
</div>
</DialogContent>
</Dialog>
);
}
112 changes: 112 additions & 0 deletions apps/mesh/src/mcp-apps/csp-injector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* CSP Injector Tests
*/

import { describe, expect, it } from "bun:test";
import { injectCSP, DEFAULT_CSP } from "./csp-injector";

describe("CSP Injector", () => {
describe("DEFAULT_CSP", () => {
it("should have default-src 'none'", () => {
expect(DEFAULT_CSP).toContain("default-src 'none'");
});

it("should allow inline scripts and styles", () => {
expect(DEFAULT_CSP).toContain("script-src 'unsafe-inline'");
expect(DEFAULT_CSP).toContain("style-src 'unsafe-inline'");
});

it("should block external connections by default", () => {
expect(DEFAULT_CSP).toContain("connect-src 'none'");
});

it("should prevent framing", () => {
expect(DEFAULT_CSP).toContain("frame-ancestors 'none'");
});
});

describe("injectCSP", () => {
it("should inject CSP into existing <head>", () => {
const html = "<html><head><title>Test</title></head><body></body></html>";
const result = injectCSP(html);

expect(result).toContain('<meta http-equiv="Content-Security-Policy"');
expect(result).toContain(DEFAULT_CSP);
// Should be after <head>
expect(result.indexOf("<head>")).toBeLessThan(
result.indexOf("Content-Security-Policy"),
);
});

it("should create <head> if missing", () => {
const html = "<html><body>Content</body></html>";
const result = injectCSP(html);

expect(result).toContain("<head>");
expect(result).toContain("Content-Security-Policy");
});

it("should work with <!DOCTYPE html>", () => {
const html = "<!DOCTYPE html><html><body>Test</body></html>";
const result = injectCSP(html);

expect(result).toContain("Content-Security-Policy");
expect(result).toContain("<!DOCTYPE html>");
});

it("should handle uppercase HEAD tag", () => {
const html = "<html><HEAD><title>Test</title></HEAD><body></body></html>";
const result = injectCSP(html);

expect(result).toContain("Content-Security-Policy");
});

it("should use custom CSP if provided", () => {
const customCSP = "default-src 'self'";
const html = "<html><head></head></html>";
const result = injectCSP(html, { csp: customCSP });

expect(result).toContain(customCSP);
expect(result).not.toContain(DEFAULT_CSP);
});

describe("external connections", () => {
it("should allow all hosts when allowExternalConnections is true without allowedHosts", () => {
const html = "<html><head></head></html>";
const result = injectCSP(html, { allowExternalConnections: true });

expect(result).toContain("connect-src *");
expect(result).not.toContain("connect-src 'none'");
});

it("should use specified hosts when allowedHosts is provided", () => {
const html = "<html><head></head></html>";
const result = injectCSP(html, {
allowExternalConnections: true,
allowedHosts: ["https://api.example.com", "https://cdn.example.com"],
});

expect(result).toContain(
"connect-src https://api.example.com https://cdn.example.com",
);
});

it("should treat empty allowedHosts array as wildcard", () => {
const html = "<html><head></head></html>";
const result = injectCSP(html, {
allowExternalConnections: true,
allowedHosts: [],
});

expect(result).toContain("connect-src *");
});

it("should not modify connect-src when allowExternalConnections is false", () => {
const html = "<html><head></head></html>";
const result = injectCSP(html, { allowExternalConnections: false });

expect(result).toContain("connect-src 'none'");
});
});
});
});
Loading
Loading