diff --git a/README.md b/README.md index 1e06e79..65e60e2 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ MCP Host is a complete end-to-end implementation of a Model Context Protocol (MC 1. Clone the repository: ```bash - git clone https://github.com/yourusername/mcp-host.git - cd mcp-host + git clone https://github.com/aidecentralized/canvas.git + cd canvas ``` 2. Start the application with Docker Compose: diff --git a/client/package.json b/client/package.json index 1c27c5a..7694db8 100644 --- a/client/package.json +++ b/client/package.json @@ -6,6 +6,7 @@ "@chakra-ui/react": "^2.8.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@modelcontextprotocol/sdk": "^1.9.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.5.1", @@ -21,6 +22,7 @@ "react-markdown": "latest", "react-scripts": "5.0.1", "react-syntax-highlighter": "^15.5.0", + "socket.io": "^4.8.1", "socket.io-client": "^4.7.2", "typescript": "^4.9.5", "uuid": "^9.0.1", diff --git a/client/src/components/SettingsModal.tsx b/client/src/components/SettingsModal.tsx index 92bb390..0779939 100644 --- a/client/src/components/SettingsModal.tsx +++ b/client/src/components/SettingsModal.tsx @@ -1,325 +1,329 @@ // client/src/components/SettingsModal.tsx import React, { useState, useEffect } from "react"; import { - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalFooter, - ModalBody, - ModalCloseButton, - Button, - FormControl, - FormLabel, - Input, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - VStack, - FormHelperText, - Box, - Heading, - Text, - Divider, - Flex, - Switch, - useToast, - IconButton, - InputGroup, - InputRightElement, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Button, + FormControl, + FormLabel, + Input, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + VStack, + FormHelperText, + Box, + Heading, + Text, + Divider, + Flex, + Switch, + useToast, + IconButton, + InputGroup, + InputRightElement, } from "@chakra-ui/react"; import { FaEye, FaEyeSlash, FaPlus } from "react-icons/fa"; import { useSettingsContext } from "../contexts/SettingsContext"; interface SettingsModalProps { - isOpen: boolean; - onClose: () => void; + isOpen: boolean; + onClose: () => void; } const SettingsModal: React.FC = ({ isOpen, onClose }) => { - const { apiKey, setApiKey, nandaServers, registerNandaServer } = - useSettingsContext(); - const [tempApiKey, setTempApiKey] = useState(""); - const [showApiKey, setShowApiKey] = useState(false); - const [newServer, setNewServer] = useState({ - id: "", - name: "", - url: "", - }); - const toast = useToast(); - - // Reset temp values when modal opens - useEffect(() => { - if (isOpen) { - setTempApiKey(apiKey || ""); - setShowApiKey(false); - } - }, [isOpen, apiKey]); - - const handleSaveApiKey = () => { - setApiKey(tempApiKey); - toast({ - title: "API Key Saved", - status: "success", - duration: 3000, - isClosable: true, - position: "top", - }); - }; + const { apiKey, setApiKey, nandaServers, registerNandaServer } = + useSettingsContext(); + const [tempApiKey, setTempApiKey] = useState(""); + const [showApiKey, setShowApiKey] = useState(false); + const [newServer, setNewServer] = useState({ + id: "", + name: "", + url: "", + }); + const toast = useToast(); - const toggleShowApiKey = () => { - setShowApiKey(!showApiKey); - }; + // Reset temp values when modal opens + useEffect(() => { + if (isOpen) { + setTempApiKey(apiKey || ""); + setShowApiKey(false); + } + }, [isOpen, apiKey]); - const handleAddServer = () => { - // Validate server info - if (!newServer.id || !newServer.name || !newServer.url) { + const handleSaveApiKey = () => { + setApiKey(tempApiKey); toast({ - title: "Validation Error", - description: "All server fields are required", - status: "error", - duration: 3000, - isClosable: true, - position: "top", + title: "API Key Saved", + status: "success", + duration: 3000, + isClosable: true, + position: "top", }); - return; - } + }; - // Validate URL - try { - new URL(newServer.url); - } catch (error) { - toast({ - title: "Invalid URL", - description: - "Please enter a valid URL (e.g., http://localhost:3001/sse)", - status: "error", - duration: 3000, - isClosable: true, - position: "top", - }); - return; - } + const toggleShowApiKey = () => { + setShowApiKey(!showApiKey); + }; - // Register new server - registerNandaServer({ - id: newServer.id, - name: newServer.name, - url: newServer.url, - }); + const handleAddServer = () => { + // Validate server info + if (!newServer.id || !newServer.name || !newServer.url) { + toast({ + title: "Validation Error", + description: "All server fields are required", + status: "error", + duration: 3000, + isClosable: true, + position: "top", + }); + return; + } - // Reset form - setNewServer({ - id: "", - name: "", - url: "", - }); + // Validate URL + try { + new URL(newServer.url); + } catch (error) { + toast({ + title: "Invalid URL", + description: + "Please enter a valid URL (e.g., http://localhost:3001/sse)", + status: "error", + duration: 3000, + isClosable: true, + position: "top", + }); + return; + } + + // Register new server + registerNandaServer({ + id: newServer.id, + name: newServer.name, + url: newServer.url, + }); - toast({ - title: "Server Added", - description: `Server "${newServer.name}" has been added`, - status: "success", - duration: 3000, - isClosable: true, - position: "top", - }); - }; + // Reset form + setNewServer({ + id: "", + name: "", + url: "", + }); + + toast({ + title: "Server Added", + description: `Server "${newServer.name}" has been added`, + status: "success", + duration: 3000, + isClosable: true, + position: "top", + }); + }; - return ( - - - - Settings - - - - - API - Nanda Servers - About - + return ( + + + + Settings + + + + + API + Nanda Servers + About + - - {/* API Settings Tab */} - - - - Anthropic API Key - - setTempApiKey(e.target.value)} - placeholder="sk-ant-api03-..." - autoComplete="off" - /> - - : } - size="sm" - variant="ghost" - onClick={toggleShowApiKey} - /> - - - - You can get your API key from the{" "} - - Anthropic Console - - - + + {/* API Settings Tab */} + + + + Anthropic API Key + + setTempApiKey(e.target.value)} + placeholder="sk-ant-api03-..." + autoComplete="off" + color={"black"} + /> + + : } + size="sm" + variant="ghost" + onClick={toggleShowApiKey} + /> + + + + You can get your API key from the{" "} + + Anthropic Console + + + - - - + + + - {/* Nanda Servers Tab */} - - - - - Registered Nanda Servers - - {nandaServers.length === 0 ? ( - No servers registered yet - ) : ( - nandaServers.map((server) => ( - - {server.name} - - ID: {server.id} - - - URL: {server.url} - - - )) - )} - + {/* Nanda Servers Tab */} + + + + + Registered Nanda Servers + + {nandaServers.length === 0 ? ( + No servers registered yet + ) : ( + nandaServers.map((server) => ( + + {server.name} + + ID: {server.id} + + + URL: {server.url} + + + )) + )} + - + - - - Add New Server - + + + Add New Server + - - - Server ID - - setNewServer({ ...newServer, id: e.target.value }) - } - placeholder="weather" - /> - - A unique identifier for this server - - + + + Server ID + + setNewServer({ ...newServer, id: e.target.value }) + } + color={"black"} + placeholder="weather" + /> + + A unique identifier for this server + + - - Server Name - - setNewServer({ ...newServer, name: e.target.value }) - } - placeholder="Weather Server" - /> - - A friendly name for this server - - + + Server Name + + setNewServer({ ...newServer, name: e.target.value }) + } + color={"black"} + placeholder="Weather Server" + /> + + A friendly name for this server + + - - Server URL - - setNewServer({ ...newServer, url: e.target.value }) - } - placeholder="http://localhost:3001/sse" - /> - - The SSE endpoint URL of the Nanda server - - + + Server URL + + setNewServer({ ...newServer, url: e.target.value }) + } + color={"black"} + placeholder="http://localhost:3001/sse" + /> + + The SSE endpoint URL of the Nanda server + + - - - - - + + + + + - {/* About Tab */} - - - Nanda Host - - A beautiful chat interface with Nanda integration for - enhanced capabilities through agents, resources, and tools. - + {/* About Tab */} + + + Nanda Host + + A beautiful chat interface with Nanda integration for + enhanced capabilities through agents, resources, and tools. + - - - What is Nanda? - - - Nanda is an open standard built on top of MCP for enabling - coordination between agents, resources and tools (ARTs). - It enables LLMs to discover and execute tools, access - resources, and use predefined prompts. - - + + + What is Nanda? + + + Nanda is an open standard built on top of MCP for enabling + coordination between agents, resources and tools (ARTs). + It enables LLMs to discover and execute tools, access + resources, and use predefined prompts. + + - + - - Version 1.0.0 • MIT License - - - - - - + + Version 1.0.0 • MIT License + + + + + + - - - - - - ); + + + + + + ); }; export default SettingsModal; diff --git a/client/src/contexts/SettingsContext.tsx b/client/src/contexts/SettingsContext.tsx index ad5c74f..7088561 100644 --- a/client/src/contexts/SettingsContext.tsx +++ b/client/src/contexts/SettingsContext.tsx @@ -1,4 +1,5 @@ // client/src/contexts/SettingsContext.tsx +import { setupMcpManager } from "../mcp/manager"; import React, { createContext, useContext, @@ -22,12 +23,13 @@ interface SettingsContextProps { refreshRegistry: () => Promise<{ servers: ServerConfig[] }>; } +export const mcpManager = setupMcpManager() const SettingsContext = createContext({ apiKey: null, - setApiKey: () => {}, + setApiKey: () => { }, nandaServers: [], - registerNandaServer: () => {}, - removeNandaServer: () => {}, + registerNandaServer: () => { }, + removeNandaServer: () => { }, refreshRegistry: async () => ({ servers: [] }), }); @@ -88,6 +90,7 @@ export const SettingsProvider: React.FC = ({ newServers[existingIndex] = server; } else { // Add new server + mcpManager.registerServer(server); newServers = [...prevServers, server]; } @@ -96,18 +99,6 @@ export const SettingsProvider: React.FC = ({ NANDA_SERVERS_STORAGE_KEY, JSON.stringify(newServers) ); - - // Also register with backend - fetch(`${process.env.REACT_APP_API_BASE_URL}/api/servers`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(server), - }).catch((error) => { - console.error("Failed to register server with backend:", error); - }); - return newServers; }); }, []); @@ -124,59 +115,60 @@ export const SettingsProvider: React.FC = ({ }); }, []); - // Register servers with backend on initial load + // Register servers with mcpmanager on initial load useEffect(() => { - if (nandaServers.length > 0) { - // Register all servers with the backend - const registerAllServers = async () => { - for (const server of nandaServers) { - try { - await fetch(`${process.env.REACT_APP_API_BASE_URL}/api/servers`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(server), - }); - } catch (error) { - console.error( - `Failed to register server ${server.id} with backend:`, - error - ); - } - } - }; - - registerAllServers(); + const registerAllServers = async () => { + for (const server of nandaServers) { + mcpManager.registerServer(server) + // try { + // await fetch(`${process.env.REACT_APP_API_BASE_URL}/api/servers`, { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify(server), + // }); + // } catch (error) { + // console.error( + // `Failed to register server ${server.id} with backend:`, + // error + // ); + } } + if (nandaServers.length > 0 && mcpManager.getAvailableServers().length > 0) { + registerAllServers(); + }; }, []); // Refresh servers from registry const refreshRegistry = useCallback(async () => { - try { - const response = await fetch( - `${process.env.REACT_APP_API_BASE_URL}/api/registry/refresh`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error || "Failed to refresh servers from registry" - ); - } - - const data = await response.json(); - return data; - } catch (error) { - console.error("Error refreshing servers from registry:", error); - throw error; - } + // try { + // const response = await fetch( + // `${process.env.REACT_APP_API_BASE_URL}/api/registry/refresh`, + // { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // } + // ); + + // if (!response.ok) { + // const errorData = await response.json(); + // throw new Error( + // errorData.error || "Failed to refresh servers from registry" + // ); + // } + + // const data = await response.json(); + // return data; + // } catch (error) { + // console.error("Error refreshing servers from registry:", error); + // throw error; + // } + // implment custom function for refreshRegistry + + return { servers: mcpManager.getAvailableServers() } }, []); return ( diff --git a/client/src/mcp/manager.ts b/client/src/mcp/manager.ts new file mode 100644 index 0000000..e426732 --- /dev/null +++ b/client/src/mcp/manager.ts @@ -0,0 +1,139 @@ +// server/src/mcp/manager.ts +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { ToolRegistry } from "./toolRegistry"; + +export interface McpManager { + discoverTools: () => Promise; + executeToolCall: ( + sessionId: string, + toolName: string, + args: any + ) => Promise; + registerServer: (serverConfig: ServerConfig) => Promise; + getAvailableServers: () => ServerConfig[]; + cleanup: () => Promise; +} + +export interface ServerConfig { + id: string; + name: string; + url: string; +} + +export function setupMcpManager(): McpManager { + // Registry to keep track of available MCP tools + const toolRegistry = new ToolRegistry(); + + // Session manager to handle client sessions + // const sessionManager = new SessionManager(); + + // Available server configurations + const servers: ServerConfig[] = []; + + // Cache of connected clients + const connectedClients: Map = new Map(); + + const registerServer = async (serverConfig: ServerConfig): Promise => { + servers.push(serverConfig); + + try { + // Create MCP client for this server using SSE transport + const sseUrl = new URL(serverConfig.url); + const transport = new SSEClientTransport(sseUrl); + + const client = new Client({ + name: "mcp-host", + version: "1.0.0", + }); + + await client.connect(transport); + + // Fetch available tools from the server + const toolsResult = await client.listTools(); + + // Register tools in our registry + if (toolsResult?.tools) { + toolRegistry.registerTools(serverConfig.id, client, toolsResult.tools); + } + + // Store the connected client for later use + connectedClients.set(serverConfig.id, client); + + console.log( + `Registered server ${serverConfig.name} with ${toolsResult?.tools?.length || 0 + } tools` + ); + } catch (error) { + console.error( + `Failed to connect to MCP server ${serverConfig.name}:`, + error + ); + // Remove the server from our list since we couldn't connect + const index = servers.findIndex((s) => s.id === serverConfig.id); + if (index !== -1) { + servers.splice(index, 1); + } + } + }; + + // Discover all available tools for a session + const discoverTools = async (): Promise => { + return toolRegistry.getAllTools(); + }; + + // Execute a tool call + const executeToolCall = async ( + sessionId: string, + toolName: string, + args: any + ): Promise => { + const toolInfo = toolRegistry.getToolInfo(toolName); + if (!toolInfo) { + throw new Error(`Tool ${toolName} not found`); + } + + const { client, tool } = toolInfo; + + try { + // Execute the tool via MCP + const result = await client.callTool({ + name: toolName, + arguments: args, + }); + + return result; + } catch (error) { + console.error(`Error executing tool ${toolName}:`, error); + throw error; + } + }; + + // Get all available server configurations + const getAvailableServers = (): ServerConfig[] => { + return [...servers]; + }; + + // Clean up connections when closing + const cleanup = async (): Promise => { + for (const [serverId, client] of Array.from(connectedClients.entries())) { + try { + await client.close(); + console.log(`Closed connection to server ${serverId}`); + } catch (error) { + console.error(`Error closing connection to server ${serverId}:`, error); + } + } + connectedClients.clear(); + }; + + // Return the MCP manager interface + return { + discoverTools, + executeToolCall, + registerServer, + getAvailableServers, + cleanup, + }; +} diff --git a/client/src/mcp/sessionManager.ts b/client/src/mcp/sessionManager.ts new file mode 100644 index 0000000..52331a6 --- /dev/null +++ b/client/src/mcp/sessionManager.ts @@ -0,0 +1,73 @@ +// server/src/mcp/sessionManager.ts +import { v4 as uuidv4 } from "uuid"; + +interface Session { + id: string; + anthropicApiKey?: string; + createdAt: Date; + lastActive: Date; +} + +export class SessionManager { + private sessions: Map = new Map(); + + // Session cleanup interval in milliseconds (1 hour) + private readonly CLEANUP_INTERVAL = 60 * 60 * 1000; + + constructor() { + // Set up session cleanup + setInterval(() => this.cleanupSessions(), this.CLEANUP_INTERVAL); + } + + createSession(): string { + const sessionId = uuidv4(); + const now = new Date(); + + this.sessions.set(sessionId, { + id: sessionId, + createdAt: now, + lastActive: now, + }); + + return sessionId; + } + + getSession(sessionId: string): Session | undefined { + const session = this.sessions.get(sessionId); + + if (session) { + // Update last active time + session.lastActive = new Date(); + this.sessions.set(sessionId, session); + } + + return session; + } + + setAnthropicApiKey(sessionId: string, apiKey: string): void { + const session = this.sessions.get(sessionId); + + if (session) { + session.anthropicApiKey = apiKey; + session.lastActive = new Date(); + this.sessions.set(sessionId, session); + } + } + + getAnthropicApiKey(sessionId: string): string | undefined { + return this.sessions.get(sessionId)?.anthropicApiKey; + } + + private cleanupSessions(): void { + const now = new Date(); + const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours + + for (const [sessionId, session] of Array.from(this.sessions.entries())) { + const inactiveTime = now.getTime() - session.lastActive.getTime(); + + if (inactiveTime > SESSION_TIMEOUT) { + this.sessions.delete(sessionId); + } + } + } +} diff --git a/client/src/mcp/toolRegistry.ts b/client/src/mcp/toolRegistry.ts new file mode 100644 index 0000000..ce7d306 --- /dev/null +++ b/client/src/mcp/toolRegistry.ts @@ -0,0 +1,45 @@ +// server/src/mcp/toolRegistry.ts +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Tool } from "@modelcontextprotocol/sdk/types.js"; + +interface ToolInfo { + serverId: string; + client: Client; + tool: Tool; +} + +export class ToolRegistry { + private tools: Map = new Map(); + + registerTools(serverId: string, client: Client, tools: Tool[]): void { + for (const tool of tools) { + this.tools.set(tool.name, { + serverId, + client, + tool, + }); + } + } + + getToolInfo(toolName: string): ToolInfo | undefined { + return this.tools.get(toolName); + } + + getAllTools(): Tool[] { + return Array.from(this.tools.values()).map((info) => info.tool); + } + + getToolsByServerId(serverId: string): Tool[] { + return Array.from(this.tools.values()) + .filter((info) => info.serverId === serverId) + .map((info) => info.tool); + } + + removeToolsByServerId(serverId: string): void { + for (const [toolName, info] of Array.from(this.tools.entries())) { + if (info.serverId === serverId) { + this.tools.delete(toolName); + } + } + } +}