✨ feat: MCP Apps Extension Support#11799
✨ feat: MCP Apps Extension Support#11799KyleKincer wants to merge 5 commits intodanny-avila:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements the MCP Apps extension, enabling MCP servers to render interactive HTML interfaces (forms, dashboards, visualizations) inline in the chat. The implementation delivers a complete end-to-end solution with backend proxy endpoints, metadata pipeline, sandboxed iframe rendering, and config-driven security controls.
Changes:
- Adds backend controller (
mcpApps.js) with resource/tool-call proxy endpoints, domain filtering, tool visibility enforcement, and config-gating - Extends MCP package with metadata pipeline (
parsers.ts), tool visibility filtering (MCPServerInspector.ts), and manager methods for resource/tool operations - Implements frontend MCPApp module with dual bridge architecture (custom JSON-RPC + optional SDK adapter), two-layer sandboxed iframe rendering, fullscreen support, and state preservation
Reviewed changes
Copilot reviewed 35 out of 36 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
packages/data-provider/src/types/assistants.ts |
Adds mcp_app tool type enum |
packages/data-provider/src/schemas.ts |
Defines MCPAppArtifact type and attachment metadata |
packages/data-provider/src/config.ts |
Adds mcpSettings.apps and appSettings configuration schema |
packages/api/src/mcp/types/index.ts |
Adds MCPAppArtifact import and _meta field to LCFunctionTool |
packages/api/src/mcp/registry/__tests__/MCPServerInspector.test.ts |
Tests tool visibility filtering (model vs app-only tools) |
packages/api/src/mcp/registry/MCPServerInspector.ts |
Implements getAllToolFunctions() returning separate modelTools and allTools |
packages/api/src/mcp/parsers.ts |
Creates mcp_app artifacts when toolUiMeta.resourceUri is present |
packages/api/src/mcp/connection.ts |
Advertises io.modelcontextprotocol/ui capability during handshake |
packages/api/src/mcp/__tests__/parsers.test.ts |
Tests MCP app artifact creation and merging with other artifacts |
packages/api/src/mcp/__tests__/MCPManager.test.ts |
Tests metadata fallback tiers and tools/list optimization |
packages/api/src/mcp/MCPManager.ts |
Adds readResource(), appToolCall(), getAllToolsForServer() with three-tier metadata fallback |
package-lock.json |
Adds @modelcontextprotocol/ext-apps and platform-specific dependencies |
librechat.example.yaml |
Documents MCP Apps configuration options |
client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx |
Tests MCP app attachment stability across re-renders |
client/src/components/Chat/Messages/Content/ToolCall.tsx |
Integrates MCPAppInline with stable ref to preserve app state |
client/src/components/Chat/Messages/Content/MCPApp/mcpAppUtils.ts |
Implements CSP/permissions builders and backend proxy fetch functions |
client/src/components/Chat/Messages/Content/MCPApp/mcpAppTheme.ts |
Provides host context with theme, display mode, and container dimensions |
client/src/components/Chat/Messages/Content/MCPApp/index.ts |
Exports MCPApp components and bridge |
client/src/components/Chat/Messages/Content/MCPApp/createMCPAppBridge.ts |
Factory for custom bridge or SDK adapter based on config |
client/src/components/Chat/Messages/Content/MCPApp/__tests__/MCPAppContainer.test.tsx |
Tests fullscreen lifecycle, state preservation, and geometry tracking |
client/src/components/Chat/Messages/Content/MCPApp/__tests__/MCPAppBridge.test.ts |
Tests JSON-RPC protocol, display modes, and context updates |
client/src/components/Chat/Messages/Content/MCPApp/MCPAppInline.tsx |
Fetches HTML resource and delegates to MCPAppContainer |
client/src/components/Chat/Messages/Content/MCPApp/MCPAppFullscreen.tsx |
Renders fullscreen portal with close button and escape key handler |
client/src/components/Chat/Messages/Content/MCPApp/MCPAppContainer.tsx |
Manages iframe lifecycle, bridge, portal rendering, and display modes |
client/src/components/Chat/Messages/Content/MCPApp/MCPAppBridgeSDKAdapter.ts |
Wraps @modelcontextprotocol/ext-apps SDK with LibreChat backend proxies |
client/src/components/Chat/Messages/Content/MCPApp/MCPAppBridge.ts |
Custom JSON-RPC bridge with tool/resource proxying and display mode handling |
client/src/components/Chat/Messages/Content/ContentParts.tsx |
Uses stable keys for tool call parts to prevent unnecessary re-renders |
client/src/components/Chat/Input/ChatForm.tsx |
Adds z-50 to ensure composer stays above inline MCP apps |
client/src/components/Chat/Header.tsx |
Changes z-index from z-10 to z-30 for proper layering |
client/src/components/Chat/ChatView.tsx |
Adds data-chat-view-root attribute for portal targeting |
client/public/mcp-sandbox.html |
Two-layer sandbox proxy with CSP injection and message validation |
client/package.json |
Adds @modelcontextprotocol/ext-apps dependency |
api/server/routes/mcp.js |
Wires /resources/read, /app-tool-call, and /sandbox endpoints |
api/server/controllers/mcpApps.test.js |
Tests domain filtering, visibility enforcement, and config gating |
api/server/controllers/mcpApps.js |
Implements resource read, tool call proxy, and sandbox serving with security controls |
api/server/controllers/agents/callbacks.js |
Gates mcp_app artifact processing on mcpSettings.apps config |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Implements MCP Apps extension support, enabling MCP servers to render interactive HTML interfaces (forms, dashboards, visualizations) inline in the chat via sandboxed iframes with a bidirectional JSON-RPC bridge. Closes danny-avila#10641 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
914254e to
1050679
Compare
|
Thanks for the PR @KyleKincer I may not get to reviewing this week but I would greatly appreciate if you could point me to a few MCP servers and their respective configs to test with when I get the chance, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 44 out of 45 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch (error) { | ||
| console.error('[MCPAppContainer] Failed to bootstrap opaque sandbox source:', error); | ||
| if (active) { | ||
| setSandboxSrc(SANDBOX_ENDPOINT); |
There was a problem hiding this comment.
The sandboxSrc initialization falls back to SANDBOX_ENDPOINT (direct iframe src) on error, which bypasses the opaque-origin blob URL security mechanism. An attacker who can intercept or poison the sandbox HTML could gain same-origin access. Consider failing closed (showing error UI) rather than falling back to a less secure loading method.
| setSandboxSrc(SANDBOX_ENDPOINT); | |
| // Fail closed: do not fall back to direct SANDBOX_ENDPOINT iframe src. | |
| setSandboxSrc('about:blank'); |
| try { | ||
| if (document.referrer) { | ||
| return new URL(document.referrer).origin; | ||
| } | ||
| } catch { | ||
| // Fall back to wildcard when referrer is unavailable. | ||
| } | ||
| return '*'; |
There was a problem hiding this comment.
The wildcard origin '*' is used when document.referrer is unavailable. While this is documented as a fallback, it bypasses origin validation entirely. Consider requiring explicit configuration of allowed origins rather than falling back to wildcard, or at minimum add a warning log when wildcard is used so administrators are aware of the reduced security posture.
| try { | |
| if (document.referrer) { | |
| return new URL(document.referrer).origin; | |
| } | |
| } catch { | |
| // Fall back to wildcard when referrer is unavailable. | |
| } | |
| return '*'; | |
| let origin = '*'; | |
| try { | |
| if (document.referrer) { | |
| origin = new URL(document.referrer).origin; | |
| } else { | |
| console.warn( | |
| '[MCP Sandbox] document.referrer is empty; using wildcard parent origin "*". ' + | |
| 'This reduces origin validation strictness.' | |
| ); | |
| } | |
| } catch (err) { | |
| console.warn( | |
| '[MCP Sandbox] Failed to parse document.referrer; using wildcard parent origin "*". ' + | |
| 'This reduces origin validation strictness.', | |
| err | |
| ); | |
| } | |
| return origin; |
| return `part-${messageId}-tool-${toolCallId}`; | ||
| } | ||
| } | ||
|
|
||
| if (part.type === ContentTypes.TEXT && part.tool_call_ids != null && part.tool_call_ids.length > 0) { | ||
| return `part-${messageId}-tool-links-${part.tool_call_ids.join(',')}`; |
There was a problem hiding this comment.
The getPartKey function generates stable keys based on toolCallId, which is good for preventing remounts. However, if two tool calls somehow have the same ID within a message (edge case), they would share the same key causing React reconciliation issues. Consider adding the part index as a secondary key component to guarantee uniqueness: part-${messageId}-tool-${toolCallId}-${idx}
| return `part-${messageId}-tool-${toolCallId}`; | |
| } | |
| } | |
| if (part.type === ContentTypes.TEXT && part.tool_call_ids != null && part.tool_call_ids.length > 0) { | |
| return `part-${messageId}-tool-links-${part.tool_call_ids.join(',')}`; | |
| return `part-${messageId}-tool-${toolCallId}-${idx}`; | |
| } | |
| } | |
| if (part.type === ContentTypes.TEXT && part.tool_call_ids != null && part.tool_call_ids.length > 0) { | |
| return `part-${messageId}-tool-links-${part.tool_call_ids.join(',')}-${idx}`; |
| const fullHtml = injectIntoHead(html, cspMeta + SANDBOX_BASE_STYLE); | ||
|
|
||
| const blob = new Blob([fullHtml], { type: 'text/html' }); | ||
| innerFrame.src = URL.createObjectURL(blob); |
There was a problem hiding this comment.
The opaque blob URL created for the inner iframe is never revoked with URL.revokeObjectURL(). This can lead to memory leaks if many MCP apps are loaded during a session. Consider storing the blob URL ref and revoking it in the iframe cleanup (when innerFrame is removed or on window unload).
| * @param {object} res - Express response | ||
| */ | ||
| const serveMCPSandbox = async (req, res) => { | ||
| try { |
There was a problem hiding this comment.
The sandbox endpoint lacks authentication but accesses req.user?.role to determine if apps are enabled. When req.user is undefined (unauthenticated requests), getMCPAppsConfig will use a default role, which could bypass intended access controls. This endpoint should either require authentication or explicitly handle the anonymous case with a documented default behavior.
| try { | |
| try { | |
| // Require authentication to avoid relying on a default role for anonymous users | |
| if (!req.user) { | |
| return res.status(401).json({ error: 'Unauthorized' }); | |
| } |
| if (params.oauthTokens) { | ||
| this.oauthTokens = params.oauthTokens; | ||
| } | ||
| const enableApps = params.enableApps !== false; // default true |
There was a problem hiding this comment.
The enableApps parameter defaults to true in MCPConnection but is never actually set based on the mcpSettings.apps configuration. This means connections will always advertise MCP Apps capability regardless of the apps configuration setting. The MCPConnectionFactory should pass the enableApps flag from the application configuration when creating connections.
| return res.sendFile(sandboxPath, (error) => { | ||
| if (error) { | ||
| logger.error('[serveMCPSandbox] Error:', error); | ||
| if (!res.headersSent) { | ||
| res.status(500).json({ error: 'Failed to load MCP sandbox' }); | ||
| } | ||
| } | ||
| }); |
There was a problem hiding this comment.
The error handler sendFile callback checks res.headersSent before sending an error response, but then always tries to send JSON. If headers are already sent (e.g., partial file transfer), attempting to send JSON will cause an error. Consider logging only when headers are sent, or ensure the error path cannot be reached after headers are sent.
| onSubmit={methods.handleSubmit(submitMessage)} | ||
| className={cn( | ||
| 'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2', | ||
| 'relative z-50 mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2', |
There was a problem hiding this comment.
The z-index value 50 for the chat form may conflict with the fullscreen MCP app which uses z-index 2147483647 (max safe integer). While this specific case works, consider documenting the z-index layering strategy or using CSS custom properties to centralize z-index management across components that can overlap (header=30, form=50, fullscreen=max).
Standalone server for testing LibreChat's MCP Apps host implementation (PR danny-avila/LibreChat#11799). Exercises all host bridge methods including tools/call, resources/read, ui/message, display modes, model context, and teardown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Hey @danny-avila — I put together a test server you can use to exercise the full MCP Apps host implementation: https://github.com/KyleKincer/librechat-mcp-apps-test-server It's a single MCP server that registers an interactive HTML app as a resource. When the model calls To run it: git clone https://github.com/KyleKincer/librechat-mcp-apps-test-server.git
cd librechat-mcp-apps-test-server
npm install
npm startThen add this to mcpSettings:
apps: true
mcpServers:
mcp_apps_integration:
type: streamable-http
url: http://localhost:3099/mcpRestart the backend, and in chat ask the model to call |
Summary
Closes #10641
How it works
When an MCP server tool declares
_meta.ui.resourceUri, LibreChat:mcp_appartifact attachment after the tool call completesThe existing MCP tool path (tools without
_meta.ui) is unchanged.Changes
Backend (
api/)mcpApps.js):readMCPResource,appToolCall,serveMCPSandboxwith role-based config gating, domain filtering, and tool visibility enforcementmcp.js):POST /resources/readandPOST /app-tool-callbehind JWT + MCP permission middleware;GET /sandboxis intentionally public (config-gated in controller) so the iframe can loadmcp_appartifact processing onmcpSettings.appsconfig; supports both streaming and non-streaming response modesMCP package (
packages/api/src/mcp/)parsers.ts): Createsmcp_appartifacts whentoolUiMeta.resourceUriis present on a toolMCPServerInspector.ts):getAllToolFunctions()now returns separatemodelToolsandallToolssets, respecting_meta.ui.visibility('model'|'app')MCPManager.ts): AddsreadResource(),appToolCall(),getAllToolsForServer(); reduces per-call metadata overhead with three-tier fallback (call result > cached config > live tools/list)connection.ts): Advertisesio.modelcontextprotocol/uicapability during MCP handshake when apps are enabledtypes/index.ts,schemas.ts):MCPAppArtifact,Tools.mcp_appenum,LCFunctionTool._metawith visibilityFrontend (
client/)MCPApp/):MCPAppInlinefetches HTML and delegates toMCPAppContainer, which manages the iframe lifecycle, bridge, portal rendering, geometry tracking, and display mode (inline/fullscreen)@modelcontextprotocol/ext-apps(toggle with?mcpBridge=sdkor localStorage)mcp-sandbox.html): Two-layer iframe architecture — outer frame validates/forwards postMessage traffic, inner frame runs app code in a restricted sandbox with injected CSPmcp_appattachment rendering viastableMCPAppRef, preserving app state across parent re-rendersChatView.tsx,Header.tsx): Uniquedata-chat-view-rootattribute for portal targetingConfiguration (
librechat.yaml)Security
sandbox-*methods; inner iframe created from blob URL withsandbox="allow-scripts"onlyallowedConnectDomains, filtered againstblockedDomains(wildcard support)visibilityincluding'app'; model-only tools are rejected with 403Test plan
mcpApps.test.js): domain filtering, visibility enforcement, sandbox serving, config gating