mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Add MCP integration and update AI SDK for MCP compatibility
This commit is contained in:
parent
bb8fc48bea
commit
f73d1392e7
@ -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 (
|
||||
<div className="overflow-hidden w-full">
|
||||
<>
|
||||
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
|
||||
{(codeContext || chatSummary) && (
|
||||
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
|
||||
{chatSummary && (
|
||||
<div className="max-w-chat">
|
||||
<div className="max-w-chat">
|
||||
{chatSummary && (
|
||||
<div className="summary max-h-96 flex flex-col">
|
||||
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
|
||||
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
|
||||
<Markdown>{chatSummary}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
{codeContext && (
|
||||
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
|
||||
<h2>Context</h2>
|
||||
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
|
||||
{codeContext.map((x) => {
|
||||
const normalized = normalizedFilePath(x);
|
||||
return (
|
||||
<Fragment key={normalized}>
|
||||
<code
|
||||
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openArtifactInWorkbench(normalized);
|
||||
}}
|
||||
>
|
||||
{normalized}
|
||||
</code>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{codeContext && (
|
||||
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
|
||||
<h2>Context</h2>
|
||||
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
|
||||
{codeContext.map((x) => {
|
||||
const normalized = normalizedFilePath(x);
|
||||
return (
|
||||
<Fragment key={normalized}>
|
||||
<code
|
||||
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openArtifactInWorkbench(normalized);
|
||||
}}
|
||||
>
|
||||
{normalized}
|
||||
</code>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context"></div>
|
||||
</Popover>
|
||||
)}
|
||||
@ -158,6 +164,9 @@ export const AssistantMessage = memo(
|
||||
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
|
||||
{content}
|
||||
</Markdown>
|
||||
|
||||
{/* Display tool invocations if present */}
|
||||
{toolInvocations.length > 0 && <ToolInvocation toolInvocations={toolInvocations} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@ -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';
|
||||
|
@ -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<TextUIPart | FileUIPart> => {
|
||||
// Create an array of properly typed message parts
|
||||
const parts: Array<TextUIPart | FileUIPart> = [
|
||||
{
|
||||
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<Attachment[] | undefined> => {
|
||||
if (files.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attachments = await Promise.all(
|
||||
files.map(
|
||||
(file) =>
|
||||
new Promise<Attachment>((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('');
|
||||
|
416
app/components/chat/MCPConnection.tsx
Normal file
416
app/components/chat/MCPConnection.tsx
Normal file
@ -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<string, boolean>;
|
||||
type ServerErrors = Record<string, string>;
|
||||
type ServerTools = Record<string, any>;
|
||||
|
||||
export function McpConnection() {
|
||||
const { config, updateConfig, lastUpdate, isLoading } = useMCPConfig();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [serverStatus, setServerStatus] = useState<ServerStatus>({});
|
||||
const [serverErrors, setServerErrors] = useState<ServerErrors>({});
|
||||
const [serverTools, setServerTools] = useState<ServerTools>({});
|
||||
const [checkingServers, setCheckingServers] = useState(false);
|
||||
const [configTextParsed, setConfigTextParsed] = useState<MCPConfig | null>(null);
|
||||
const [expandedServer, setExpandedServer] = useState<string | null>(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 (
|
||||
<div className="mt-2 ml-4 p-2 rounded-md bg-bolt-elements-background-depth-2 text-xs font-mono">
|
||||
<div className="font-medium mb-1">{toolName}</div>
|
||||
<div className="text-bolt-elements-textSecondary">{toolSchema.description || 'No description available'}</div>
|
||||
|
||||
{Object.keys(parameters).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium mb-1">Parameters:</div>
|
||||
<div className="ml-2 space-y-1">
|
||||
{Object.entries(parameters).map(([paramName, paramDetails]: [string, any]) => (
|
||||
<div key={paramName}>
|
||||
<span className="text-bolt-elements-textAccent">{paramName}</span>
|
||||
{paramDetails.required && <span className="text-red-500 ml-1">*</span>}
|
||||
<span className="text-bolt-elements-textSecondary ml-2">
|
||||
{paramDetails.type || 'any'}
|
||||
{paramDetails.description ? ` - ${paramDetails.description}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="mt-2 ml-4 p-2 rounded-md bg-red-100 dark:bg-red-900 text-xs">
|
||||
Error parsing tool schema: {e instanceof Error ? e.message : String(e)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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' ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 ${badgeStyles[status]}`}>
|
||||
{icon}
|
||||
{text[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderServerList = () => {
|
||||
if (!configTextParsed?.mcpServers) {
|
||||
return <p className="text-sm text-bolt-elements-textSecondary">Invalid configuration or no servers defined</p>;
|
||||
}
|
||||
|
||||
const serverEntries = Object.entries(configTextParsed.mcpServers);
|
||||
|
||||
if (serverEntries.length === 0) {
|
||||
return <p className="text-sm text-bolt-elements-textSecondary">No MCP servers configured</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={serverName} className="flex flex-col py-1 px-2 rounded-md bg-bolt-elements-background-depth-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
onClick={() => toggleServerExpanded(serverName)}
|
||||
className="flex items-center gap-1 text-bolt-elements-textPrimary"
|
||||
>
|
||||
<div className={`i-ph:${isExpanded ? 'caret-down' : 'caret-right'} w-3 h-3`} />
|
||||
<span className="font-medium">{serverName}</span>
|
||||
</div>
|
||||
{serverConfig.type === 'sse' ? (
|
||||
<span className="text-xs text-bolt-elements-textSecondary">SSE: {serverConfig.url}</span>
|
||||
) : (
|
||||
<span className="text-xs text-bolt-elements-textSecondary">
|
||||
{serverConfig.command} {serverConfig.args?.join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{checkingServers ? (
|
||||
<StatusBadge status="checking" />
|
||||
) : (
|
||||
statusKnown && <StatusBadge status={isAvailable ? 'available' : 'unavailable'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display error message if server is unavailable */}
|
||||
{statusKnown && !isAvailable && errorMessage && (
|
||||
<div className="mt-1 ml-4 text-xs text-red-600 dark:text-red-400">Error: {errorMessage}</div>
|
||||
)}
|
||||
|
||||
{/* Display tool schemas if server is expanded */}
|
||||
{isExpanded && statusKnown && isAvailable && serverToolsData && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs font-medium ml-2 mb-1">Available Tools:</div>
|
||||
{Object.keys(serverToolsData).length === 0 ? (
|
||||
<div className="ml-4 text-xs text-bolt-elements-textSecondary">No tools available</div>
|
||||
) : (
|
||||
<div className="mt-1 space-y-2">
|
||||
{Object.entries(serverToolsData).map(([toolName, toolSchema]) => (
|
||||
<div key={toolName}>{formatToolSchema(toolName, toolSchema)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex">
|
||||
<IconButton onClick={() => setIsDialogOpen(!isDialogOpen)} title="Configure MCP" className="transition-all">
|
||||
{isLoading ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin" />
|
||||
) : (
|
||||
<img className="w-4 h-4" height="20" width="20" src="/icons/mcp.svg" alt="MCP" />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<DialogRoot open={isDialogOpen} onOpenChange={handleDialogOpen}>
|
||||
{isDialogOpen && (
|
||||
<Dialog className="max-w-4xl w-full p-6">
|
||||
<div className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||
<DialogTitle>
|
||||
<img className="w-5 h-5" height="24" width="24" src="/icons/mcp.svg" alt="MCP" />
|
||||
MCP Configuration
|
||||
</DialogTitle>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm text-bolt-elements-textSecondary">Configured MCP Servers</label>
|
||||
<button
|
||||
onClick={checkServerAvailability}
|
||||
disabled={
|
||||
checkingServers ||
|
||||
!configTextParsed ||
|
||||
Object.keys(configTextParsed?.mcpServers || {}).length === 0
|
||||
}
|
||||
className={classNames(
|
||||
'px-3 py-1 rounded-md text-xs flex items-center gap-1',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'hover:bg-bolt-elements-background-depth-1',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{checkingServers ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
|
||||
) : (
|
||||
<div className="i-ph:arrow-counter-clockwise w-3 h-3" />
|
||||
)}
|
||||
Check availability
|
||||
</button>
|
||||
</div>
|
||||
{renderServerList()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Configuration JSON</label>
|
||||
<textarea
|
||||
value={configText}
|
||||
onChange={(e) => setConfigText(e.target.value)}
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 rounded-lg text-sm font-mono h-72',
|
||||
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
||||
'border',
|
||||
error ? 'border-bolt-elements-icon-error' : 'border-[#E5E5E5] dark:border-[#333333]',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus',
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-bolt-elements-icon-error">{error}</p>}
|
||||
|
||||
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
||||
The MCP configuration format is identical to the one used in Claude Desktop.
|
||||
<a
|
||||
href="https://modelcontextprotocol.io/examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-link hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View example servers
|
||||
<div className="i-ph:arrow-square-out w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 mt-6">
|
||||
<button
|
||||
onClick={handleLoadExample}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-bolt-elements-borderColor
|
||||
bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary
|
||||
hover:bg-bolt-elements-background-depth-3"
|
||||
>
|
||||
Load Example
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<DialogClose asChild>
|
||||
<DialogButton type="secondary">Cancel</DialogButton>
|
||||
</DialogClose>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 rounded-lg text-sm flex items-center gap-2
|
||||
bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent
|
||||
hover:bg-bolt-elements-item-backgroundActive"
|
||||
>
|
||||
<div className="i-ph:floppy-disk w-4 h-4" />
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</DialogRoot>
|
||||
</div>
|
||||
);
|
||||
}
|
188
app/components/chat/ToolInvocation.tsx
Normal file
188
app/components/chat/ToolInvocation.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { memo, useRef, useState } from 'react';
|
||||
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
|
||||
import type { ToolInvocationAnnotation } from '~/types/context';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
const highlighterOptions = {
|
||||
langs: ['json'],
|
||||
themes: ['light-plus', 'dark-plus'],
|
||||
};
|
||||
|
||||
const jsonHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
|
||||
import.meta.hot?.data.jsonHighlighter ?? (await createHighlighter(highlighterOptions));
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.jsonHighlighter = jsonHighlighter;
|
||||
}
|
||||
|
||||
interface ToolInvocationProps {
|
||||
toolInvocations: ToolInvocationAnnotation[];
|
||||
}
|
||||
|
||||
export const ToolInvocation = memo(({ toolInvocations }: ToolInvocationProps) => {
|
||||
const userToggledDetails = useRef(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const toggleDetails = () => {
|
||||
userToggledDetails.current = true;
|
||||
setShowDetails(!showDetails);
|
||||
};
|
||||
|
||||
if (toolInvocations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tool-invocation border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150 mt-4">
|
||||
<div className="flex">
|
||||
<button
|
||||
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
|
||||
onClick={toggleDetails}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className={'i-ph:wrench'} style={{ fontSize: '2rem' }}></div>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
<div className="px-5 p-3.5 w-full text-left">
|
||||
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
|
||||
MCP Tool Invocations
|
||||
</div>
|
||||
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">
|
||||
{toolInvocations.length} tool{toolInvocations.length > 1 ? 's' : ''} used
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
<AnimatePresence>
|
||||
<motion.button
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: 'auto' }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ duration: 0.15, ease: cubicEasingFn }}
|
||||
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
|
||||
onClick={toggleDetails}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className={showDetails ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showDetails && (
|
||||
<motion.div
|
||||
className="details"
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: '0px' }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
||||
|
||||
<div className="p-5 text-left bg-bolt-elements-actions-background">
|
||||
<ToolList toolInvocations={toolInvocations} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface JsonCodeBlockProps {
|
||||
className?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
function JsonCodeBlock({ className, code }: JsonCodeBlockProps) {
|
||||
let formattedCode = code;
|
||||
|
||||
try {
|
||||
if (typeof code !== 'string') {
|
||||
formattedCode = JSON.stringify(code, null, 2);
|
||||
} else if (!code.trim().startsWith('{') && !code.trim().startsWith('[')) {
|
||||
// Not JSON, keep as is
|
||||
} else {
|
||||
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, keep original code
|
||||
logger.error('Failed to parse JSON', { error: e });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('text-xs rounded-md overflow-hidden', className)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: jsonHighlighter.codeToHtml(formattedCode, {
|
||||
lang: 'json',
|
||||
theme: 'dark-plus',
|
||||
}),
|
||||
}}
|
||||
style={{
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolListProps {
|
||||
toolInvocations: ToolInvocationAnnotation[];
|
||||
}
|
||||
|
||||
const toolVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
const ToolList = memo(({ toolInvocations }: ToolListProps) => {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<ul className="list-none space-y-4">
|
||||
{toolInvocations.map((tool, index) => {
|
||||
const isLast = index === toolInvocations.length - 1;
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
key={index}
|
||||
variants={toolVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-sm mb-2">
|
||||
<div className="text-lg text-bolt-elements-icon-success">
|
||||
<div className="i-ph:check"></div>
|
||||
</div>
|
||||
<div className="font-semibold">{tool.toolName}</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-6 mb-2">
|
||||
<div className="text-bolt-elements-textSecondary text-xs mb-1">Parameters:</div>
|
||||
<div className="bg-[#1E1E1E] p-3 rounded-md">
|
||||
<JsonCodeBlock className="mb-0" code={JSON.stringify(tool.parameters)} />
|
||||
</div>
|
||||
|
||||
<div className="text-bolt-elements-textSecondary text-xs mt-3 mb-1">Result:</div>
|
||||
<div
|
||||
className={classNames('bg-[#1E1E1E] p-3 rounded-md', {
|
||||
'mb-3.5': !isLast,
|
||||
})}
|
||||
>
|
||||
<JsonCodeBlock className="mb-0" code={JSON.stringify(tool.result)} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
116
app/lib/hooks/useMCPConfig.ts
Normal file
116
app/lib/hooks/useMCPConfig.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { db } from '~/lib/persistence/useChatHistory';
|
||||
import { getMCPConfig, saveMCPConfig } from '~/lib/persistence/db';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
export interface MCPConfig {
|
||||
mcpServers: Record<
|
||||
string,
|
||||
{
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
env?: Record<string, string>;
|
||||
type?: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
// Create an event dispatcher to notify when the config changes
|
||||
export const mcpConfigEvents = {
|
||||
listeners: new Set<() => void>(),
|
||||
|
||||
addListener(callback: () => void) {
|
||||
this.listeners.add(callback);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
},
|
||||
|
||||
notifyChange() {
|
||||
this.listeners.forEach((callback) => callback());
|
||||
},
|
||||
};
|
||||
|
||||
export function useMCPConfig() {
|
||||
const [config, setConfig] = useState<MCPConfig | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadConfigFromDB = async () => {
|
||||
if (!db) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const savedConfig = await getMCPConfig(db);
|
||||
|
||||
if (savedConfig) {
|
||||
setConfig(savedConfig);
|
||||
} else {
|
||||
setConfig(null);
|
||||
}
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to load MCP config', error as Error, {
|
||||
component: 'MCPConfig',
|
||||
action: 'load',
|
||||
type: 'error',
|
||||
message: 'Failed to load MCP configuration from IndexedDB',
|
||||
});
|
||||
console.error('Error loading MCP config:', error);
|
||||
setConfig(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigFromDB();
|
||||
|
||||
// Add listener for config changes from other components
|
||||
const cleanup = mcpConfigEvents.addListener(() => {
|
||||
loadConfigFromDB();
|
||||
setLastUpdate(Date.now());
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
const updateConfig = async (newConfig: MCPConfig) => {
|
||||
if (!db) {
|
||||
toast.error('Database is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMCPConfig(db, newConfig);
|
||||
setConfig(newConfig);
|
||||
setLastUpdate(Date.now());
|
||||
|
||||
// Notify other components that the config has changed
|
||||
mcpConfigEvents.notifyChange();
|
||||
|
||||
toast.success('MCP configuration saved');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to save MCP config', error as Error, {
|
||||
component: 'MCPConfig',
|
||||
action: 'save',
|
||||
type: 'error',
|
||||
message: 'Failed to save MCP configuration to IndexedDB',
|
||||
});
|
||||
toast.error('Failed to save MCP configuration');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
updateConfig,
|
||||
lastUpdate,
|
||||
isLoading,
|
||||
};
|
||||
}
|
@ -2,11 +2,13 @@ import type { Message } from 'ai';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import type { ChatHistoryItem } from './useChatHistory';
|
||||
import type { Snapshot } from './types'; // Import Snapshot type
|
||||
import type { MCPConfig } from '~/lib/hooks/useMCPConfig';
|
||||
|
||||
export interface IChatMetadata {
|
||||
gitUrl: string;
|
||||
gitBranch?: string;
|
||||
netlifySiteId?: string;
|
||||
apiActionIds?: string[]; // IDs of API actions associated with this chat
|
||||
}
|
||||
|
||||
const logger = createScopedLogger('ChatHistory');
|
||||
@ -19,7 +21,7 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open('boltHistory', 2);
|
||||
const request = indexedDB.open('boltHistory', 3); // Increment version to trigger upgrade
|
||||
|
||||
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
@ -38,6 +40,13 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
|
||||
db.createObjectStore('snapshots', { keyPath: 'chatId' });
|
||||
}
|
||||
}
|
||||
|
||||
// Add mcpConfig store for version 3
|
||||
if (oldVersion < 3) {
|
||||
if (!db.objectStoreNames.contains('mcpConfig')) {
|
||||
db.createObjectStore('mcpConfig', { keyPath: 'id' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event: Event) => {
|
||||
@ -341,3 +350,66 @@ export async function deleteSnapshot(db: IDBDatabase, chatId: string): Promise<v
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// MCP Configuration functions
|
||||
|
||||
/**
|
||||
* Save MCP configuration to IndexedDB
|
||||
* @param db The IndexedDB database instance
|
||||
* @param config The MCP configuration to save
|
||||
*/
|
||||
export async function saveMCPConfig(db: IDBDatabase, config: MCPConfig): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('mcpConfig', 'readwrite');
|
||||
const store = transaction.objectStore('mcpConfig');
|
||||
|
||||
const configObject = {
|
||||
id: 'default', // We use a fixed ID since we only have one config
|
||||
config,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const request = store.put(configObject);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP configuration from IndexedDB
|
||||
* @param db The IndexedDB database instance
|
||||
* @returns The MCP configuration or null if not found
|
||||
*/
|
||||
export async function getMCPConfig(db: IDBDatabase): Promise<MCPConfig | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('mcpConfig', 'readonly');
|
||||
const store = transaction.objectStore('mcpConfig');
|
||||
const request = store.get('default');
|
||||
|
||||
request.onsuccess = () => {
|
||||
if (request.result) {
|
||||
resolve(request.result.config);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete MCP configuration from IndexedDB
|
||||
* @param db The IndexedDB database instance
|
||||
*/
|
||||
export async function deleteMCPConfig(db: IDBDatabase): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('mcpConfig', 'readwrite');
|
||||
const store = transaction.objectStore('mcpConfig');
|
||||
const request = store.delete('default');
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
140
app/lib/services/mcp.ts
Normal file
140
app/lib/services/mcp.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { experimental_createMCPClient } from 'ai';
|
||||
import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('mcp-service');
|
||||
|
||||
// MCP config types
|
||||
export type StdioMCPConfig = {
|
||||
type: 'stdio';
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export type SSEMCPConfig = {
|
||||
type: 'sse';
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MCPConfig = StdioMCPConfig | SSEMCPConfig;
|
||||
|
||||
export interface MCPClient {
|
||||
tools: () => Promise<any>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
type ServerConfig = {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
env?: Record<string, string>;
|
||||
type?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a single MCP client for a server configuration
|
||||
*/
|
||||
export async function createMCPClient(serverName: string, serverConfig: ServerConfig): Promise<MCPClient | null> {
|
||||
if (!serverConfig) {
|
||||
throw new Error(`Invalid configuration for server "${serverName}"`);
|
||||
}
|
||||
|
||||
const isSSE = serverConfig.type === 'sse' || (!serverConfig.command && serverConfig.url);
|
||||
|
||||
if (isSSE && !serverConfig.url) {
|
||||
throw new Error(`Missing URL for SSE server "${serverName}"`);
|
||||
}
|
||||
|
||||
if (!isSSE && !serverConfig.command) {
|
||||
throw new Error(`Missing command for stdio server "${serverName}"`);
|
||||
}
|
||||
|
||||
const client = isSSE
|
||||
? await createSSEClient(serverName, serverConfig.url!)
|
||||
: await createStdioClient(serverName, serverConfig);
|
||||
|
||||
// Verify that the client can get tools
|
||||
try {
|
||||
await client.tools();
|
||||
return client;
|
||||
} catch (e) {
|
||||
throw new Error(`Server connection established but failed to get available tools: ${errorToString(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSSEClient(serverName: string, url: string): Promise<MCPClient> {
|
||||
logger.debug(`Creating SSE MCP client for ${serverName} with URL: ${url}`);
|
||||
|
||||
try {
|
||||
return await experimental_createMCPClient({
|
||||
transport: { type: 'sse', url },
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to connect to SSE endpoint "${url}": ${errorToString(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createStdioClient(serverName: string, config: ServerConfig): Promise<MCPClient> {
|
||||
const { command, args, env, cwd } = config;
|
||||
|
||||
logger.debug(`Creating stdio MCP client for '${serverName}' with command: '${command}' ${args?.join(' ') || ''}`);
|
||||
|
||||
try {
|
||||
const transport = new Experimental_StdioMCPTransport({
|
||||
command: command!,
|
||||
args,
|
||||
env,
|
||||
cwd,
|
||||
});
|
||||
|
||||
return await experimental_createMCPClient({ transport });
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to start command "${command}": ${errorToString(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMCPClients(mcpConfig?: {
|
||||
mcpServers: Record<string, ServerConfig>;
|
||||
}): Promise<{ tools: Record<string, any>; clients: MCPClient[] }> {
|
||||
const tools = {};
|
||||
const clients: MCPClient[] = [];
|
||||
|
||||
if (!mcpConfig?.mcpServers) {
|
||||
return { tools, clients };
|
||||
}
|
||||
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpConfig.mcpServers)) {
|
||||
try {
|
||||
const client = await createMCPClient(serverName, serverConfig);
|
||||
|
||||
if (client) {
|
||||
clients.push(client);
|
||||
|
||||
const toolSet = await client.tools();
|
||||
Object.assign(tools, toolSet);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize MCP client for server: ${serverName}`, error);
|
||||
|
||||
// Continue to the next server rather than failing completely
|
||||
}
|
||||
}
|
||||
|
||||
return { tools, clients };
|
||||
}
|
||||
|
||||
export async function closeMCPClients(clients: MCPClient[]): Promise<void> {
|
||||
const closePromises = clients.map((client) =>
|
||||
client.close().catch((e) => logger.error('Error closing MCP client:', e)),
|
||||
);
|
||||
|
||||
await Promise.allSettled(closePromises);
|
||||
}
|
||||
|
||||
// Helper function to consistently format errors
|
||||
function errorToString(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
@ -11,6 +11,7 @@ import type { ContextAnnotation, ProgressAnnotation } from '~/types/context';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { createSummary } from '~/lib/.server/llm/create-summary';
|
||||
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
|
||||
import { createMCPClients, closeMCPClients } from '~/lib/services/mcp';
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return chatAction(args);
|
||||
@ -37,7 +38,7 @@ function parseCookies(cookieHeader: string): Record<string, string> {
|
||||
}
|
||||
|
||||
async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const { messages, files, promptId, contextOptimization, supabase, chatMode } = await request.json<{
|
||||
const { messages, files, promptId, contextOptimization, supabase, chatMode, mcpConfig } = await request.json<{
|
||||
messages: Messages;
|
||||
files: any;
|
||||
promptId?: string;
|
||||
@ -51,6 +52,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
supabaseUrl?: string;
|
||||
};
|
||||
};
|
||||
mcpConfig?: {
|
||||
mcpServers: Record<string, any>;
|
||||
};
|
||||
}>();
|
||||
|
||||
const cookieHeader = request.headers.get('Cookie');
|
||||
@ -69,6 +73,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const encoder: TextEncoder = new TextEncoder();
|
||||
let progressCounter: number = 1;
|
||||
|
||||
// Initialize MCP clients if configuration is provided
|
||||
const { tools, clients } = await createMCPClients(mcpConfig);
|
||||
|
||||
try {
|
||||
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
|
||||
logger.debug(`Total message length: ${totalMessageContent.split(' ').length}, words`);
|
||||
@ -190,7 +197,67 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
|
||||
const options: StreamingOptions = {
|
||||
supabaseConnection: supabase,
|
||||
toolChoice: 'none',
|
||||
tools,
|
||||
maxSteps: 6,
|
||||
onStepFinish: ({ toolCalls, toolResults }) => {
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
toolCalls.forEach((toolCall, index) => {
|
||||
const toolName = toolCall.toolName;
|
||||
const toolResult = toolResults?.[index];
|
||||
|
||||
logger.debug(`MCP Tool '${toolName}' executed with args: ${JSON.stringify(toolCall.args)}`);
|
||||
|
||||
// Add progress indicator to frontend that tool is running
|
||||
dataStream.writeData({
|
||||
type: 'progress',
|
||||
label: 'tool',
|
||||
status: 'in-progress',
|
||||
order: progressCounter++,
|
||||
message: `Running tool: ${toolName}`,
|
||||
} satisfies ProgressAnnotation);
|
||||
|
||||
if (toolResult) {
|
||||
logger.debug(`MCP Tool '${toolName}' result: ${JSON.stringify(toolResult)}`);
|
||||
|
||||
// Add progress indicator that tool completed
|
||||
dataStream.writeData({
|
||||
type: 'progress',
|
||||
label: 'tool',
|
||||
status: 'complete',
|
||||
order: progressCounter++,
|
||||
message: `Tool executed: ${toolName}`,
|
||||
} satisfies ProgressAnnotation);
|
||||
|
||||
// Add tool invocation annotation
|
||||
dataStream.writeMessageAnnotation({
|
||||
type: 'toolInvocation',
|
||||
toolName,
|
||||
parameters: toolCall.args,
|
||||
result: toolResult,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`MCP Tool '${toolName}' didn't return a result`);
|
||||
|
||||
// Add progress indicator that tool failed
|
||||
dataStream.writeData({
|
||||
type: 'progress',
|
||||
label: 'tool',
|
||||
status: 'complete',
|
||||
order: progressCounter++,
|
||||
message: `Tool executed with no result: ${toolName}`,
|
||||
} satisfies ProgressAnnotation);
|
||||
|
||||
// Add tool invocation annotation even without result
|
||||
dataStream.writeMessageAnnotation({
|
||||
type: 'toolInvocation',
|
||||
toolName,
|
||||
parameters: toolCall.args,
|
||||
result: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onFinish: async ({ text: content, finishReason, usage }) => {
|
||||
logger.debug('usage', JSON.stringify(usage));
|
||||
|
||||
@ -218,6 +285,11 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
} satisfies ProgressAnnotation);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Close MCP clients
|
||||
if (clients.length > 0) {
|
||||
await closeMCPClients(clients);
|
||||
}
|
||||
|
||||
// stream.close();
|
||||
return;
|
||||
}
|
||||
@ -357,6 +429,11 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
|
||||
// Clean up MCP clients if they exist
|
||||
if (clients && clients.length > 0) {
|
||||
await closeMCPClients(clients).catch((e) => logger.error('Error closing MCP clients during error handling:', e));
|
||||
}
|
||||
|
||||
if (error.message?.includes('API key')) {
|
||||
throw new Response('Invalid or missing API key', {
|
||||
status: 401,
|
||||
|
58
app/routes/api.mcp-check.ts
Normal file
58
app/routes/api.mcp-check.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { createMCPClient } from '~/lib/services/mcp';
|
||||
|
||||
const logger = createScopedLogger('api.mcp-check');
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const body = (await request.json()) as { mcpServers?: Record<string, any> };
|
||||
const { mcpServers } = body;
|
||||
|
||||
if (!mcpServers || typeof mcpServers !== 'object') {
|
||||
return Response.json({ error: 'Invalid MCP servers configuration' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverStatus: Record<string, boolean> = {};
|
||||
const serverErrors: Record<string, string> = {};
|
||||
const serverTools: Record<string, any> = {};
|
||||
|
||||
// Check each server in parallel
|
||||
const checkPromises = Object.entries(mcpServers).map(async ([serverName, serverConfig]) => {
|
||||
try {
|
||||
const client = await createMCPClient(serverName, serverConfig);
|
||||
|
||||
if (client) {
|
||||
serverStatus[serverName] = true;
|
||||
|
||||
// Get tools from the client
|
||||
try {
|
||||
const tools = await client.tools();
|
||||
serverTools[serverName] = tools;
|
||||
} catch (toolError) {
|
||||
logger.error(`Failed to get tools from server ${serverName}:`, toolError);
|
||||
serverErrors[serverName] =
|
||||
`Connected but failed to get tools: ${toolError instanceof Error ? toolError.message : String(toolError)}`;
|
||||
}
|
||||
|
||||
await client.close();
|
||||
} else {
|
||||
serverStatus[serverName] = false;
|
||||
serverErrors[serverName] = 'Failed to create client';
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to check MCP server ${serverName}:`, error);
|
||||
serverStatus[serverName] = false;
|
||||
serverErrors[serverName] = errorMessage;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(checkPromises);
|
||||
|
||||
return Response.json({ serverStatus, serverErrors, serverTools });
|
||||
} catch (error) {
|
||||
logger.error('Error checking MCP servers:', error);
|
||||
return Response.json({ error: 'Failed to check MCP servers' }, { status: 500 });
|
||||
}
|
||||
}
|
@ -16,3 +16,10 @@ export type ProgressAnnotation = {
|
||||
order: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToolInvocationAnnotation = {
|
||||
type: 'toolInvocation';
|
||||
toolName: string;
|
||||
parameters: Record<string, unknown>;
|
||||
result: unknown;
|
||||
};
|
||||
|
1
icons/mcp.svg
Normal file
1
icons/mcp.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>
|
After Width: | Height: | Size: 978 B |
@ -51,6 +51,8 @@
|
||||
"@ai-sdk/google": "0.0.52",
|
||||
"@ai-sdk/mistral": "0.0.43",
|
||||
"@ai-sdk/openai": "1.1.2",
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"@ai-sdk/ui-utils": "^1.2.11",
|
||||
"@codemirror/autocomplete": "^6.18.3",
|
||||
"@codemirror/commands": "^6.7.1",
|
||||
"@codemirror/lang-cpp": "^6.0.2",
|
||||
@ -104,7 +106,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ai": "4.1.2",
|
||||
"ai": "4.3.15",
|
||||
"chalk": "^5.4.1",
|
||||
"chart.js": "^4.4.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
8355
pnpm-lock.yaml
8355
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
1
public/icons/mcp.svg
Normal file
1
public/icons/mcp.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>
|
After Width: | Height: | Size: 978 B |
Loading…
Reference in New Issue
Block a user