From f73d1392e78d0fb9a7cf3b0f270489cb68c05643 Mon Sep 17 00:00:00 2001 From: Jimmyyy Date: Wed, 14 May 2025 15:33:09 +0800 Subject: [PATCH] Add MCP integration and update AI SDK for MCP compatibility --- app/components/chat/AssistantMessage.tsx | 63 +- app/components/chat/BaseChat.tsx | 4 + app/components/chat/Chat.client.tsx | 162 +- app/components/chat/MCPConnection.tsx | 416 ++ app/components/chat/ToolInvocation.tsx | 188 + app/lib/hooks/useMCPConfig.ts | 116 + app/lib/persistence/db.ts | 74 +- app/lib/services/mcp.ts | 140 + app/routes/api.chat.ts | 81 +- app/routes/api.mcp-check.ts | 58 + app/types/context.ts | 7 + icons/mcp.svg | 1 + package.json | 4 +- pnpm-lock.yaml | 8355 ++++++++++++---------- public/icons/mcp.svg | 1 + 15 files changed, 5704 insertions(+), 3966 deletions(-) create mode 100644 app/components/chat/MCPConnection.tsx create mode 100644 app/components/chat/ToolInvocation.tsx create mode 100644 app/lib/hooks/useMCPConfig.ts create mode 100644 app/lib/services/mcp.ts create mode 100644 app/routes/api.mcp-check.ts create mode 100644 icons/mcp.svg create mode 100644 public/icons/mcp.svg diff --git a/app/components/chat/AssistantMessage.tsx b/app/components/chat/AssistantMessage.tsx index 022b4fbe..4b984182 100644 --- a/app/components/chat/AssistantMessage.tsx +++ b/app/components/chat/AssistantMessage.tsx @@ -7,6 +7,8 @@ import { WORK_DIR } from '~/utils/constants'; import WithTooltip from '~/components/ui/Tooltip'; import type { Message } from 'ai'; import type { ProviderInfo } from '~/types/model'; +import { ToolInvocation } from './ToolInvocation'; +import type { ToolInvocationAnnotation } from '~/types/context'; interface AssistantMessageProps { content: string; @@ -81,46 +83,50 @@ export const AssistantMessage = memo( totalTokens: number; } = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value; + // Extract tool invocations from annotations + const toolInvocations = filteredAnnotations.filter( + (annotation) => annotation.type === 'toolInvocation', + ) as ToolInvocationAnnotation[]; + return (
<>
{(codeContext || chatSummary) && ( }> - {chatSummary && ( -
+
+ {chatSummary && (

Summary

{chatSummary}
- {codeContext && ( -
-

Context

-
- {codeContext.map((x) => { - const normalized = normalizedFilePath(x); - return ( - - { - e.preventDefault(); - e.stopPropagation(); - openArtifactInWorkbench(normalized); - }} - > - {normalized} - - - ); - })} -
+ )} + {codeContext && ( +
+

Context

+
+ {codeContext.map((x) => { + const normalized = normalizedFilePath(x); + return ( + + { + e.preventDefault(); + e.stopPropagation(); + openArtifactInWorkbench(normalized); + }} + > + {normalized} + + + ); + })}
- )} -
- )} +
+ )}
)} @@ -158,6 +164,9 @@ export const AssistantMessage = memo( {content} + + {/* Display tool invocations if present */} + {toolInvocations.length > 0 && }
); }, diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 1b2beeaf..7745afee 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -17,6 +17,10 @@ import styles from './BaseChat.module.scss'; import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons'; import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; import GitCloneButton from './GitCloneButton'; +import { McpConnection } from './MCPConnection'; +import FilePreview from './FilePreview'; +import { ModelSelector } from '~/components/chat/ModelSelector'; +import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; import type { ProviderInfo } from '~/types/model'; import StarterTemplates from './StarterTemplates'; import type { ActionAlert, SupabaseAlert, DeployAlert } from '~/types/actions'; diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 8a9dc700..f5e14b02 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -4,7 +4,7 @@ */ import { useStore } from '@nanostores/react'; import type { Message } from 'ai'; -import { useChat } from 'ai/react'; +import { useChat } from '@ai-sdk/react'; import { useAnimate } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; @@ -27,6 +27,8 @@ import { logStore } from '~/lib/stores/logs'; import { streamingState } from '~/lib/stores/streaming'; import { filesToArtifacts } from '~/utils/fileUtils'; import { supabaseConnection } from '~/lib/stores/supabase'; +import { useMCPConfig } from '~/lib/hooks/useMCPConfig'; +import type { TextUIPart, FileUIPart, Attachment } from '@ai-sdk/ui-utils'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -127,6 +129,7 @@ export const ChatImpl = memo( const actionAlert = useStore(workbenchStore.alert); const deployAlert = useStore(workbenchStore.deployAlert); const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection + const { config: mcpConfig } = useMCPConfig(); const selectedProject = supabaseConn.stats?.projects?.find( (project) => project.id === supabaseConn.selectedProjectId, ); @@ -178,6 +181,7 @@ export const ChatImpl = memo( anonKey: supabaseConn?.credentials?.anonKey, }, }, + mcpConfig: mcpConfig || undefined, }, sendExtraMessageFields: true, onError: (e) => { @@ -222,12 +226,7 @@ export const ChatImpl = memo( runAnimation(); append({ role: 'user', - content: [ - { - type: 'text', - text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`, - }, - ] as any, // Type assertion to bypass compiler check + content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`, }); } }, [model, provider, searchParams]); @@ -300,6 +299,59 @@ export const ChatImpl = memo( setChatStarted(true); }; + // Helper function to create message parts array from text and images + const createMessageParts = (text: string, images: string[] = []): Array => { + // Create an array of properly typed message parts + const parts: Array = [ + { + type: 'text', + text, + }, + ]; + + // Add image parts if any + images.forEach((imageData) => { + // Extract correct MIME type from the data URL + const mimeType = imageData.split(';')[0].split(':')[1] || 'image/jpeg'; + + // Create file part according to AI SDK format + parts.push({ + type: 'file', + mimeType, + data: imageData.replace(/^data:image\/[^;]+;base64,/, ''), + }); + }); + + return parts; + }; + + // Helper function to convert File[] to Attachment[] for AI SDK + const filesToAttachments = async (files: File[]): Promise => { + if (files.length === 0) { + return undefined; + } + + const attachments = await Promise.all( + files.map( + (file) => + new Promise((resolve) => { + const reader = new FileReader(); + + reader.onloadend = () => { + resolve({ + name: file.name, + contentType: file.type, + url: reader.result as string, + }); + }; + reader.readAsDataURL(file); + }), + ), + ); + + return attachments; + }; + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { const messageContent = messageInput || input; @@ -340,20 +392,16 @@ export const ChatImpl = memo( if (temResp) { const { assistantMessage, userMessage } = temResp; + + // Format message with text in the proper parts structure + const userMessageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`; + setMessages([ { id: `1-${new Date().getTime()}`, role: 'user', - content: [ - { - type: 'text', - text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`, - }, - ...imageDataList.map((imageData) => ({ - type: 'image', - image: imageData, - })), - ] as any, + content: userMessageText, + parts: createMessageParts(userMessageText, imageDataList), }, { id: `2-${new Date().getTime()}`, @@ -367,7 +415,13 @@ export const ChatImpl = memo( annotations: ['hidden'], }, ]); - reload(); + + const reloadOptions = + uploadedFiles.length > 0 + ? { experimental_attachments: await filesToAttachments(uploadedFiles) } + : undefined; + + reload(reloadOptions); setInput(''); Cookies.remove(PROMPT_COOKIE_KEY); @@ -385,23 +439,21 @@ export const ChatImpl = memo( } // If autoSelectTemplate is disabled or template selection failed, proceed with normal message + const userMessageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`; + + const attachments = uploadedFiles.length > 0 ? await filesToAttachments(uploadedFiles) : undefined; + setMessages([ { id: `${new Date().getTime()}`, role: 'user', - content: [ - { - type: 'text', - text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`, - }, - ...imageDataList.map((imageData) => ({ - type: 'image', - image: imageData, - })), - ] as any, + content: userMessageText, + parts: createMessageParts(userMessageText, imageDataList), + experimental_attachments: attachments, }, ]); - reload(); + + reload(attachments ? { experimental_attachments: attachments } : undefined); setFakeLoading(false); setInput(''); Cookies.remove(PROMPT_COOKIE_KEY); @@ -426,35 +478,35 @@ export const ChatImpl = memo( if (modifiedFiles !== undefined) { const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`); - append({ - role: 'user', - content: [ - { - type: 'text', - text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`, - }, - ...imageDataList.map((imageData) => ({ - type: 'image', - image: imageData, - })), - ] as any, - }); + const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`; + + const attachmentOptions = + uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined; + + append( + { + role: 'user', + content: messageText, + parts: createMessageParts(messageText, imageDataList), + }, + attachmentOptions, + ); workbenchStore.resetAllFileModifications(); } else { - append({ - role: 'user', - content: [ - { - type: 'text', - text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`, - }, - ...imageDataList.map((imageData) => ({ - type: 'image', - image: imageData, - })), - ] as any, - }); + const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`; + + const attachmentOptions = + uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined; + + append( + { + role: 'user', + content: messageText, + parts: createMessageParts(messageText, imageDataList), + }, + attachmentOptions, + ); } setInput(''); diff --git a/app/components/chat/MCPConnection.tsx b/app/components/chat/MCPConnection.tsx new file mode 100644 index 00000000..a7ac5a74 --- /dev/null +++ b/app/components/chat/MCPConnection.tsx @@ -0,0 +1,416 @@ +import { useEffect, useState } from 'react'; +import { classNames } from '~/utils/classNames'; +import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog'; +import { useMCPConfig, type MCPConfig } from '~/lib/hooks/useMCPConfig'; +import { IconButton } from '~/components/ui/IconButton'; + +// Example MCP configuration that users can load +const EXAMPLE_MCP_CONFIG: MCPConfig = { + mcpServers: { + everything: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-everything'], + }, + git: { + command: 'uvx', + args: ['mcp-server-git'], + }, + 'remote-sse': { + type: 'sse', + url: 'http://localhost:8000/sse', + }, + }, +}; + +type ServerStatus = Record; +type ServerErrors = Record; +type ServerTools = Record; + +export function McpConnection() { + const { config, updateConfig, lastUpdate, isLoading } = useMCPConfig(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [configText, setConfigText] = useState(''); + const [error, setError] = useState(null); + const [serverStatus, setServerStatus] = useState({}); + const [serverErrors, setServerErrors] = useState({}); + const [serverTools, setServerTools] = useState({}); + const [checkingServers, setCheckingServers] = useState(false); + const [configTextParsed, setConfigTextParsed] = useState(null); + const [expandedServer, setExpandedServer] = useState(null); + const [isInitialLoad, setIsInitialLoad] = useState(true); + + // Initialize config text from config + useEffect(() => { + if (config) { + setConfigText(JSON.stringify(config, null, 2)); + setConfigTextParsed(config); + } else { + setConfigText(JSON.stringify({ mcpServers: {} }, null, 2)); + setConfigTextParsed({ mcpServers: {} }); + } + }, [config, lastUpdate]); + + // Check server availability on initial load + useEffect(() => { + if (isInitialLoad && configTextParsed?.mcpServers && Object.keys(configTextParsed.mcpServers).length > 0) { + checkServerAvailability(); + setIsInitialLoad(false); + } + }, [configTextParsed, isInitialLoad]); + + // Reset initial load flag when config is updated externally + useEffect(() => { + setIsInitialLoad(true); + }, [lastUpdate]); + + // Parse the textarea content when it changes + useEffect(() => { + try { + const parsed = JSON.parse(configText) as MCPConfig; + setConfigTextParsed(parsed); + + if (error?.includes('JSON')) { + setError(null); + } + } catch (e) { + setConfigTextParsed(null); + setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`); + } + }, [configText, error]); + + const handleSave = () => { + try { + const parsed = JSON.parse(configText) as MCPConfig; + updateConfig(parsed); + setError(null); + setIsDialogOpen(false); + } catch { + setError('Invalid JSON format'); + } + }; + + const handleLoadExample = () => { + setConfigText(JSON.stringify(EXAMPLE_MCP_CONFIG, null, 2)); + setError(null); + }; + + const checkServerAvailability = async () => { + try { + const parsed = JSON.parse(configText) as MCPConfig; + + if (!parsed?.mcpServers) { + setError('No servers configured or invalid configuration'); + return; + } + + setCheckingServers(true); + setError(null); + setServerErrors({}); + setServerTools({}); + + try { + const response = await fetch('/api/mcp-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mcpServers: parsed.mcpServers }), + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (typeof data === 'object' && data !== null && 'serverStatus' in data && 'serverErrors' in data) { + setServerStatus(data.serverStatus as ServerStatus); + setServerErrors((data.serverErrors as ServerErrors) || ({} as ServerErrors)); + + if ('serverTools' in data) { + setServerTools((data.serverTools as ServerTools) || ({} as ServerTools)); + } + } else { + throw new Error('Invalid response format from server'); + } + } catch (e) { + setError(`Failed to check server availability: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setCheckingServers(false); + } + } catch (e) { + setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`); + setCheckingServers(false); + } + }; + + const toggleServerExpanded = (serverName: string) => { + setExpandedServer(expandedServer === serverName ? null : serverName); + }; + + const handleDialogOpen = (open: boolean) => { + setIsDialogOpen(open); + + if ( + open && + configTextParsed?.mcpServers && + Object.keys(configTextParsed.mcpServers).length > 0 && + Object.keys(serverStatus).length === 0 + ) { + checkServerAvailability(); + } + }; + + const formatToolSchema = (toolName: string, toolSchema: any) => { + if (!toolSchema) { + return null; + } + + try { + const parameters = toolSchema.parameters?.properties || {}; + + return ( +
+
{toolName}
+
{toolSchema.description || 'No description available'}
+ + {Object.keys(parameters).length > 0 && ( +
+
Parameters:
+
+ {Object.entries(parameters).map(([paramName, paramDetails]: [string, any]) => ( +
+ {paramName} + {paramDetails.required && *} + + {paramDetails.type || 'any'} + {paramDetails.description ? ` - ${paramDetails.description}` : ''} + +
+ ))} +
+
+ )} +
+ ); + } catch (e) { + return ( +
+ Error parsing tool schema: {e instanceof Error ? e.message : String(e)} +
+ ); + } + }; + + const StatusBadge = ({ status }: { status: 'checking' | 'available' | 'unavailable' }) => { + const badgeStyles = { + checking: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + available: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + unavailable: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + }; + + const text = { + checking: 'Checking...', + available: 'Available', + unavailable: 'Unavailable', + }; + + const icon = + status === 'checking' ? ( +
+ ) : null; + + return ( + + {icon} + {text[status]} + + ); + }; + + const renderServerList = () => { + if (!configTextParsed?.mcpServers) { + return

Invalid configuration or no servers defined

; + } + + const serverEntries = Object.entries(configTextParsed.mcpServers); + + if (serverEntries.length === 0) { + return

No MCP servers configured

; + } + + return ( +
+ {serverEntries.map(([serverName, serverConfig]) => { + const isAvailable = serverStatus[serverName]; + const statusKnown = serverName in serverStatus; + const errorMessage = serverErrors[serverName]; + const serverToolsData = serverTools[serverName]; + const isExpanded = expandedServer === serverName; + + return ( +
+
+
+
toggleServerExpanded(serverName)} + className="flex items-center gap-1 text-bolt-elements-textPrimary" + > +
+ {serverName} +
+ {serverConfig.type === 'sse' ? ( + SSE: {serverConfig.url} + ) : ( + + {serverConfig.command} {serverConfig.args?.join(' ')} + + )} +
+ + {checkingServers ? ( + + ) : ( + statusKnown && + )} +
+ + {/* Display error message if server is unavailable */} + {statusKnown && !isAvailable && errorMessage && ( +
Error: {errorMessage}
+ )} + + {/* Display tool schemas if server is expanded */} + {isExpanded && statusKnown && isAvailable && serverToolsData && ( +
+
Available Tools:
+ {Object.keys(serverToolsData).length === 0 ? ( +
No tools available
+ ) : ( +
+ {Object.entries(serverToolsData).map(([toolName, toolSchema]) => ( +
{formatToolSchema(toolName, toolSchema)}
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ ); + }; + + return ( +
+
+ setIsDialogOpen(!isDialogOpen)} title="Configure MCP" className="transition-all"> + {isLoading ? ( +
+ ) : ( + MCP + )} + +
+ + + {isDialogOpen && ( + +
+ + MCP + MCP Configuration + + +
+
+
+ + +
+ {renderServerList()} +
+ +
+ +