diff --git a/client/package.json b/client/package.json index b30115c0b..96e35a98b 100644 --- a/client/package.json +++ b/client/package.json @@ -9,23 +9,37 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^3.10.0", "@tanstack/react-query": "^5.83.0", "@tanstack/react-table": "^8.21.3", + "@uiw/react-json-view": "^2.0.0-alpha.33", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", + "classnames": "^2.5.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "fast-deep-equal": "^3.1.3", "framer-motion": "^12.23.6", "input-otp": "^1.4.2", "lucide-react": "^0.525.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.2", "react": "19.1.0", + "react-day-picker": "^9.8.0", "react-dom": "19.1.0", "react-hook-form": "^7.60.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.3", + "react18-json-view": "^0.2.9", + "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "simple-icons": "^15.6.0", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", diff --git a/client/public/catalyst.png b/client/public/catalyst.png new file mode 100644 index 000000000..b32348ae3 Binary files /dev/null and b/client/public/catalyst.png differ diff --git a/client/public/claude_logo.png b/client/public/claude_logo.png new file mode 100644 index 000000000..931464775 Binary files /dev/null and b/client/public/claude_logo.png differ diff --git a/client/public/demo_1.png b/client/public/demo_1.png new file mode 100644 index 000000000..7499b8b00 Binary files /dev/null and b/client/public/demo_1.png differ diff --git a/client/public/demo_2.png b/client/public/demo_2.png new file mode 100644 index 000000000..1d13989f4 Binary files /dev/null and b/client/public/demo_2.png differ diff --git a/client/public/demo_3.png b/client/public/demo_3.png new file mode 100644 index 000000000..d2de09409 Binary files /dev/null and b/client/public/demo_3.png differ diff --git a/client/public/file.svg b/client/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/client/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/globe.svg b/client/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/client/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/mcp.svg b/client/public/mcp.svg new file mode 100644 index 000000000..5cd83a8bf --- /dev/null +++ b/client/public/mcp.svg @@ -0,0 +1 @@ +ModelContextProtocol \ No newline at end of file diff --git a/client/public/mcp_jam.svg b/client/public/mcp_jam.svg new file mode 100644 index 000000000..8ae6e7770 --- /dev/null +++ b/client/public/mcp_jam.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/public/mcp_jam_dark.png b/client/public/mcp_jam_dark.png new file mode 100644 index 000000000..a3decf590 Binary files /dev/null and b/client/public/mcp_jam_dark.png differ diff --git a/client/public/mcp_jam_light.png b/client/public/mcp_jam_light.png new file mode 100644 index 000000000..a06eccdb2 Binary files /dev/null and b/client/public/mcp_jam_light.png differ diff --git a/client/public/next.svg b/client/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/client/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/ollama_dark.png b/client/public/ollama_dark.png new file mode 100644 index 000000000..1f7a4ddd4 Binary files /dev/null and b/client/public/ollama_dark.png differ diff --git a/client/public/ollama_logo.svg b/client/public/ollama_logo.svg new file mode 100644 index 000000000..d7780867b --- /dev/null +++ b/client/public/ollama_logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/client/public/openai_logo.png b/client/public/openai_logo.png new file mode 100644 index 000000000..ca0ef35c0 Binary files /dev/null and b/client/public/openai_logo.png differ diff --git a/client/public/vercel.svg b/client/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/client/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/window.svg b/client/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/client/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 5a6573963..9cf33ef82 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,30 +1,130 @@ -import { useState } from 'react' +import { useState } from "react"; -function App() { - const [count, setCount] = useState(0) +import { ServersTab } from "./components/ServersTab"; +import { ToolsTab } from "./components/ToolsTab"; +import { ResourcesTab } from "./components/ResourcesTab"; +import { PromptsTab } from "./components/PromptsTab"; +import { ChatTab } from "./components/ChatTab"; +import { SettingsTab } from "./components/SettingsTab"; +import { TracingTab } from "./components/TracingTab"; +import { MCPSidebar } from "./components/mcp-sidebar"; +import { ActiveServerSelector } from "./components/ActiveServerSelector"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "./components/ui/sidebar"; +import { ThemeSwitcher } from "./components/sidebar/theme-switcher"; +import { useAppState } from "./hooks/use-app-state"; +import { PreferencesStoreProvider } from "./stores/preferences/preferences-provider"; +import { Toaster } from "./components/ui/sonner"; - return ( -
-
-

MCP Inspector

-
- -

- Click the button to test React functionality -

+// Import global styles +import "./index.css"; + +export default function App() { + const [activeTab, setActiveTab] = useState("servers"); + + const { + appState, + isLoading, + connectedServerConfigs, + selectedMCPConfig, + handleConnect, + handleDisconnect, + handleReconnect, + handleUpdate, + setSelectedServer, + toggleServerSelection, + selectedMCPConfigsMap, + setSelectedMultipleServersToAllServers, + } = useAppState(); + + const handleNavigate = (section: string) => { + setActiveTab(section); + if (section === "chat") { + setSelectedMultipleServersToAllServers(); + } + }; + + if (isLoading) { + return ( +
+
+
+

Loading...

-

- Phase 1 Migration: Hono + Vite foundation is ready!
- Frontend: http://localhost:8080 | Backend: http://localhost:8001 -

-
- ) -} + ); + } + + return ( + + + + +
+
+
+ +
+
+ +
+
+
+ +
+ {/* Active Server Selector - Only show on Tools, Resources, and Prompts pages */} + {(activeTab === "tools" || + activeTab === "resources" || + activeTab === "prompts" || + activeTab === "chat") && ( + + )} + + {/* Content Areas */} + {activeTab === "servers" && ( + + )} + + {activeTab === "tools" && ( + + )} + + {activeTab === "resources" && ( + + )} + + {activeTab === "prompts" && ( + + )} + + {activeTab === "chat" && ( + + )} + + {activeTab === "tracing" && } -export default App \ No newline at end of file + {activeTab === "settings" && } +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/client/src/components/ActiveServerSelector.tsx b/client/src/components/ActiveServerSelector.tsx new file mode 100644 index 000000000..2ce13e489 --- /dev/null +++ b/client/src/components/ActiveServerSelector.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { ServerWithName } from "@/hooks/use-app-state"; +import { cn } from "@/lib/utils"; +import { AddServerModal } from "./connection/AddServerModal"; +import { ServerFormData } from "@/lib/types"; +import { Check } from "lucide-react"; + +interface ActiveServerSelectorProps { + connectedServerConfigs: Record; + selectedServer: string; + selectedMultipleServers: string[]; + isMultiSelectEnabled: boolean; + onServerChange: (server: string) => void; + onMultiServerToggle: (server: string) => void; + onConnect: (formData: ServerFormData) => void; +} + +function getStatusColor(status: string): string { + switch (status) { + case "connected": + return "bg-green-500 dark:bg-green-400"; + case "connecting": + return "bg-yellow-500 dark:bg-yellow-400 animate-pulse"; + case "failed": + return "bg-red-500 dark:bg-red-400"; + case "disconnected": + return "bg-muted-foreground"; + default: + return "bg-muted-foreground"; + } +} + +function getStatusText(status: string): string { + switch (status) { + case "connected": + return "Connected"; + case "connecting": + return "Connecting..."; + case "failed": + return "Failed"; + case "disconnected": + return "Disconnected"; + default: + return "Unknown"; + } +} + +export function ActiveServerSelector({ + connectedServerConfigs, + selectedServer, + selectedMultipleServers, + isMultiSelectEnabled, + onServerChange, + onMultiServerToggle, + onConnect, +}: ActiveServerSelectorProps) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const servers = Object.entries(connectedServerConfigs); + if (servers.length === 0) { + return ( +
+ No servers connected. Add a server to get started. +
+ ); + } + + return ( +
+
+ {servers.map(([name, serverConfig]) => { + const isSelected = isMultiSelectEnabled + ? selectedMultipleServers.includes(name) + : selectedServer === name; + + return ( + + ); + })} + + {/* Add Server Button */} + +
+ + setIsAddModalOpen(false)} + onConnect={onConnect} + /> +
+ ); +} diff --git a/client/src/components/ChatTab.tsx b/client/src/components/ChatTab.tsx new file mode 100644 index 000000000..a7bd220f0 --- /dev/null +++ b/client/src/components/ChatTab.tsx @@ -0,0 +1,249 @@ + +import { useRef, useEffect, useState } from "react"; +import { MessageCircle } from "lucide-react"; +import { MastraMCPServerDefinition } from "@/lib/types"; +import { useChat } from "@/hooks/use-chat"; +import { Message } from "./chat/message"; +import { ChatInput } from "./chat/chat-input"; +import { ElicitationDialog } from "./ElicitationDialog"; +import { TooltipProvider } from "./ui/tooltip"; +import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "sonner"; + +interface ChatTabProps { + serverConfigs?: Record; + systemPrompt?: string; +} + +export function ChatTab({ serverConfigs, systemPrompt = "" }: ChatTabProps) { + const messagesContainerRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); + + const { + messages, + isLoading, + error, + input, + setInput, + sendMessage, + stopGeneration, + regenerateMessage, + clearChat, + model, + availableModels, + setModel, + elicitationRequest, + elicitationLoading, + handleElicitationResponse, + } = useChat({ + serverConfigs: serverConfigs, + systemPrompt, + onError: (error) => { + toast.error(error); + }, + }); + console.log("availableModels", availableModels); + const hasMessages = messages.length > 0; + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (isAtBottom && messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = + messagesContainerRef.current.scrollHeight; + } + }, [messages, isAtBottom]); + + // Check if user is at bottom + const handleScroll = () => { + if (!messagesContainerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = + messagesContainerRef.current; + const threshold = 100; + const atBottom = scrollHeight - scrollTop - clientHeight < threshold; + + setIsAtBottom(atBottom); + }; + + const handleCopyMessage = (content: string) => { + navigator.clipboard.writeText(content); + }; + console.log(availableModels); + // Empty state - centered input + if (!hasMessages) { + return ( +
+
+ {/* Welcome Message */} + +
+

+ Let's test out your MCP servers! +

+ {serverConfigs && ( +
+

+ Connected servers: {Object.keys(serverConfigs).join(", ")} +

+
+ )} +
+
+ + {/* Centered Input */} + + + {availableModels.length === 0 && ( + + Configure API keys in Settings or start Ollama to enable chat + + )} + +
+ + {/* Elicitation Dialog */} + +
+ ); + } + + // Active state - messages with bottom input + return ( + +
+ {/* Messages Area - Scrollable with bottom padding for input */} +
+
+ + {messages.map((message, index) => ( + + {}} + onRegenerate={regenerateMessage} + onCopy={handleCopyMessage} + showActions={true} + /> + + ))} + {/* Thinking indicator */} + {isLoading && + messages.length > 0 && + messages[messages.length - 1].role === "user" && ( + +
+
+ +
+
+ + Thinking + +
+
+
+
+
+
+
+ + )} + +
+
+ + {/* Error Display - Absolute positioned above input */} + + {error && ( + +
+

{error}

+
+
+ )} +
+ + {/* Fixed Bottom Input - Absolute positioned */} +
+
+ +
+
+ + {/* Elicitation Dialog */} + +
+ + ); +} diff --git a/client/src/components/ElicitationDialog.tsx b/client/src/components/ElicitationDialog.tsx new file mode 100644 index 000000000..8dcf9f6d9 --- /dev/null +++ b/client/src/components/ElicitationDialog.tsx @@ -0,0 +1,304 @@ + +import React, { useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Textarea } from "./ui/textarea"; +import { Badge } from "./ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { MessageSquare, X, Check, RefreshCw } from "lucide-react"; + +interface FormField { + name: string; + type: string; + description?: string; + required: boolean; + value: any; + enum?: string[]; + minimum?: number; + maximum?: number; + pattern?: string; +} + +interface ElicitationRequest { + requestId: string; + message: string; + schema: any; + timestamp: string; +} + +interface ElicitationDialogProps { + elicitationRequest: ElicitationRequest | null; + onResponse: ( + action: "accept" | "decline" | "cancel", + parameters?: Record, + ) => Promise; + loading?: boolean; +} + +export function ElicitationDialog({ + elicitationRequest, + onResponse, + loading = false, +}: ElicitationDialogProps) { + const [fields, setFields] = useState([]); + + // Generate form fields from schema when request changes + React.useEffect(() => { + if (elicitationRequest?.schema) { + generateFormFields(elicitationRequest.schema); + } else { + setFields([]); + } + }, [elicitationRequest]); + + const generateFormFields = (schema: any) => { + if (!schema || !schema.properties) { + setFields([]); + return; + } + + const formFields: FormField[] = []; + const required = schema.required || []; + + Object.entries(schema.properties).forEach(([key, prop]: [string, any]) => { + const fieldType = prop.enum ? "enum" : prop.type || "string"; + formFields.push({ + name: key, + type: fieldType, + description: prop.description, + required: required.includes(key), + value: getDefaultValue(fieldType, prop.enum), + enum: prop.enum, + minimum: prop.minimum, + maximum: prop.maximum, + pattern: prop.pattern, + }); + }); + + setFields(formFields); + }; + + const getDefaultValue = (type: string, enumValues?: string[]) => { + switch (type) { + case "enum": + return enumValues?.[0] || ""; + case "string": + return ""; + case "number": + case "integer": + return ""; + case "boolean": + return false; + case "array": + return []; + case "object": + return {}; + default: + return ""; + } + }; + + const updateFieldValue = (fieldName: string, value: any) => { + setFields((prev) => + prev.map((field) => + field.name === fieldName ? { ...field, value } : field, + ), + ); + }; + + const buildParameters = (): Record => { + const params: Record = {}; + fields.forEach((field) => { + if ( + field.value !== "" && + field.value !== null && + field.value !== undefined + ) { + let processedValue = field.value; + + if (field.type === "number" || field.type === "integer") { + processedValue = Number(field.value); + } else if (field.type === "boolean") { + processedValue = Boolean(field.value); + } else if (field.type === "array" || field.type === "object") { + try { + processedValue = JSON.parse(field.value); + } catch { + processedValue = field.value; + } + } + + params[field.name] = processedValue; + } + }); + return params; + }; + + const handleResponse = async (action: "accept" | "decline" | "cancel") => { + if (action === "accept") { + // Validate required fields + const missingFields = fields.filter( + (field) => field.required && (!field.value || field.value === ""), + ); + + if (missingFields.length > 0) { + // You could show validation errors here + return; + } + + const parameters = buildParameters(); + await onResponse(action, parameters); + } else { + await onResponse(action); + } + }; + + const renderField = (field: FormField) => { + if (field.type === "enum") { + return ( + + ); + } else if (field.type === "boolean") { + return ( +
+ updateFieldValue(field.name, e.target.checked)} + className="w-4 h-4 text-primary bg-background border-border rounded focus:ring-ring focus:ring-2" + /> + + {field.value ? "Enabled" : "Disabled"} + +
+ ); + } else if (field.type === "array" || field.type === "object") { + return ( +