diff --git a/client/src/components/SettingsModal.tsx b/client/src/components/SettingsModal.tsx index 92bb390..2b8ba46 100644 --- a/client/src/components/SettingsModal.tsx +++ b/client/src/components/SettingsModal.tsx @@ -24,23 +24,195 @@ import { Text, Divider, Flex, - Switch, + Link, useToast, IconButton, InputGroup, InputRightElement, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, } from "@chakra-ui/react"; -import { FaEye, FaEyeSlash, FaPlus } from "react-icons/fa"; +import { FaEye, FaEyeSlash, FaPlus, FaExternalLinkAlt } from "react-icons/fa"; import { useSettingsContext } from "../contexts/SettingsContext"; +import { ToolCredentialInfo, CredentialRequirement } from "../../shared/types"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; } +// Component for a single tool credential form +interface ToolCredentialFormProps { + tool: ToolCredentialInfo; + onSave: ( + toolName: string, + serverId: string, + credentials: Record + ) => Promise; +} + +const ToolCredentialForm: React.FC = ({ + tool, + onSave +}) => { + const [credentials, setCredentials] = useState>({}); + const [showPasswords, setShowPasswords] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + const toast = useToast(); + + const handleInputChange = (id: string, value: string) => { + setCredentials((prev) => ({ + ...prev, + [id]: value, + })); + }; + + const togglePasswordVisibility = (id: string) => { + setShowPasswords((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }; + + const handleSave = async () => { + // Check that all required fields are filled + const missingFields = tool.credentials + .map(cred => cred.id) + .filter(id => !credentials[id]); + + if (missingFields.length > 0) { + toast({ + title: "Missing credentials", + description: `Please fill in all required fields: ${missingFields.join(", ")}`, + status: "error", + duration: 3000, + isClosable: true, + }); + return; + } + + setIsSaving(true); + try { + const success = await onSave(tool.toolName, tool.serverId, credentials); + + if (success) { + toast({ + title: "Credentials saved", + description: `Credentials for ${tool.toolName} have been saved`, + status: "success", + duration: 3000, + isClosable: true, + }); + } else { + throw new Error("Failed to save credentials"); + } + } catch (error) { + toast({ + title: "Error saving credentials", + description: error instanceof Error ? error.message : "Unknown error", + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsSaving(false); + } + }; + + return ( + + + {tool.toolName} + + + Server: {tool.serverName} + + + + {tool.credentials.map((cred) => ( + + {cred.name || cred.id} + + handleInputChange(cred.id, e.target.value)} + placeholder={`Enter ${cred.name || cred.id}`} + /> + + : } + size="sm" + variant="ghost" + onClick={() => togglePasswordVisibility(cred.id)} + /> + + + {cred.description && ( + {cred.description} + )} + + ))} + + {tool.credentials.some(cred => cred.acquisition?.url) && ( + + + Where to get credentials: + + {tool.credentials + .filter(cred => cred.acquisition?.url) + .map(cred => ( + + + {cred.name} credentials + + + + ))} + + )} + + + + + ); +}; + const SettingsModal: React.FC = ({ isOpen, onClose }) => { - const { apiKey, setApiKey, nandaServers, registerNandaServer } = - useSettingsContext(); + const { + apiKey, + setApiKey, + nandaServers, + registerNandaServer, + getToolsWithCredentialRequirements, + setToolCredentials + } = useSettingsContext(); + const [tempApiKey, setTempApiKey] = useState(""); const [showApiKey, setShowApiKey] = useState(false); const [newServer, setNewServer] = useState({ @@ -48,6 +220,8 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { name: "", url: "", }); + const [toolsWithCredentials, setToolsWithCredentials] = useState([]); + const [isLoadingTools, setIsLoadingTools] = useState(false); const toast = useToast(); // Reset temp values when modal opens @@ -55,9 +229,29 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { if (isOpen) { setTempApiKey(apiKey || ""); setShowApiKey(false); + loadToolsWithCredentials(); } }, [isOpen, apiKey]); + const loadToolsWithCredentials = async () => { + setIsLoadingTools(true); + try { + const tools = await getToolsWithCredentialRequirements(); + setToolsWithCredentials(tools); + } catch (error) { + console.error("Failed to load tools with credential requirements:", error); + toast({ + title: "Error", + description: "Failed to load tools that require credentials", + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoadingTools(false); + } + }; + const handleSaveApiKey = () => { setApiKey(tempApiKey); toast({ @@ -117,6 +311,11 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { url: "", }); + // Reload tools with credentials after adding a server + setTimeout(() => { + loadToolsWithCredentials(); + }, 1000); + toast({ title: "Server Added", description: `Server "${newServer.name}" has been added`, @@ -138,6 +337,7 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { API Nanda Servers + Tool Credentials About @@ -280,6 +480,51 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { + {/* Tool Credentials Tab */} + + + + Tool API Credentials + + + Some tools require API keys or other credentials to function. + Configure them here. + + + {isLoadingTools ? ( + Loading tools... + ) : toolsWithCredentials.length === 0 ? ( + + No tools requiring credentials found. Try adding servers with tools that need credentials. + + ) : ( + + {toolsWithCredentials.map((tool, index) => ( + +

+ + + {tool.toolName} + + + +

+ + + +
+ ))} +
+ )} +
+
+ {/* About Tab */} diff --git a/client/src/contexts/SettingsContext.tsx b/client/src/contexts/SettingsContext.tsx index ad5c74f..48c9fa4 100644 --- a/client/src/contexts/SettingsContext.tsx +++ b/client/src/contexts/SettingsContext.tsx @@ -6,12 +6,7 @@ import React, { useEffect, useCallback, } from "react"; - -interface ServerConfig { - id: string; - name: string; - url: string; -} +import { ServerConfig, ToolCredentialInfo, ToolCredentialRequest } from "../../shared/types"; interface SettingsContextProps { apiKey: string | null; @@ -20,6 +15,12 @@ interface SettingsContextProps { registerNandaServer: (server: ServerConfig) => void; removeNandaServer: (id: string) => void; refreshRegistry: () => Promise<{ servers: ServerConfig[] }>; + getToolsWithCredentialRequirements: () => Promise; + setToolCredentials: ( + toolName: string, + serverId: string, + credentials: Record + ) => Promise; } const SettingsContext = createContext({ @@ -29,6 +30,8 @@ const SettingsContext = createContext({ registerNandaServer: () => {}, removeNandaServer: () => {}, refreshRegistry: async () => ({ servers: [] }), + getToolsWithCredentialRequirements: async () => [], + setToolCredentials: async () => false, }); export const useSettingsContext = () => useContext(SettingsContext); @@ -46,6 +49,34 @@ export const SettingsProvider: React.FC = ({ }) => { const [apiKey, setApiKeyState] = useState(null); const [nandaServers, setNandaServers] = useState([]); + const [sessionId, setSessionId] = useState(null); + + // Initialize session on mount + useEffect(() => { + const createSession = async () => { + try { + const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/api/session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to create session"); + } + + const data = await response.json(); + setSessionId(data.sessionId); + } catch (error) { + console.error("Error creating session:", error); + // Fallback to a local session ID if needed + setSessionId("local-" + Math.random().toString(36).substring(2, 15)); + } + }; + + createSession(); + }, []); // Load settings from local storage on mount useEffect(() => { @@ -151,6 +182,74 @@ export const SettingsProvider: React.FC = ({ } }, []); + // Get tools that require credentials + const getToolsWithCredentialRequirements = useCallback(async (): Promise => { + if (!sessionId) return []; + + try { + const response = await fetch( + `${process.env.REACT_APP_API_BASE_URL}/api/tools/credentials`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-Session-ID": sessionId, + }, + } + ); + + if (!response.ok) { + throw new Error("Failed to get tools with credential requirements"); + } + + const data = await response.json(); + return data.tools || []; + } catch (error) { + console.error("Error getting tools with credential requirements:", error); + return []; + } + }, [sessionId]); + + // Set credentials for a tool + const setToolCredentials = useCallback( + async ( + toolName: string, + serverId: string, + credentials: Record + ): Promise => { + if (!sessionId) return false; + + try { + const response = await fetch( + `${process.env.REACT_APP_API_BASE_URL}/api/tools/credentials`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Session-ID": sessionId, + }, + body: JSON.stringify({ + toolName, + serverId, + credentials, + } as ToolCredentialRequest), + } + ); + + if (!response.ok) { + throw new Error("Failed to set tool credentials"); + } + + const data = await response.json(); + return data.success || false; + } catch (error) { + console.error(`Error setting credentials for tool ${toolName}:`, error); + return false; + } + }, + [sessionId] + ); + // Refresh servers from registry const refreshRegistry = useCallback(async () => { try { @@ -188,6 +287,8 @@ export const SettingsProvider: React.FC = ({ registerNandaServer, removeNandaServer, refreshRegistry, + getToolsWithCredentialRequirements, + setToolCredentials, }} > {children} diff --git a/server/src/mcp/manager.ts b/server/src/mcp/manager.ts index df89078..1baa01f 100644 --- a/server/src/mcp/manager.ts +++ b/server/src/mcp/manager.ts @@ -5,9 +5,10 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { ToolRegistry } from "./toolRegistry.js"; import { SessionManager } from "./sessionManager.js"; +import { ToolInfo, ToolCredentialInfo } from "../../shared/types.js"; export interface McpManager { - discoverTools: (sessionId: string) => Promise; + discoverTools: (sessionId: string) => Promise; executeToolCall: ( sessionId: string, toolName: string, @@ -15,6 +16,13 @@ export interface McpManager { ) => Promise; registerServer: (serverConfig: ServerConfig) => Promise; getAvailableServers: () => ServerConfig[]; + getToolsWithCredentialRequirements: (sessionId: string) => ToolCredentialInfo[]; + setToolCredentials: ( + sessionId: string, + toolName: string, + serverId: string, + credentials: Record + ) => Promise; cleanup: () => Promise; } @@ -57,7 +65,12 @@ export function setupMcpManager(io: SocketIoServer): McpManager { // Register tools in our registry if (toolsResult?.tools) { - toolRegistry.registerTools(serverConfig.id, client, toolsResult.tools); + toolRegistry.registerTools( + serverConfig.id, + serverConfig.name, + client, + toolsResult.tools + ); } // Store the connected client for later use @@ -82,7 +95,7 @@ export function setupMcpManager(io: SocketIoServer): McpManager { }; // Discover all available tools for a session - const discoverTools = async (sessionId: string): Promise => { + const discoverTools = async (sessionId: string): Promise => { return toolRegistry.getAllTools(); }; @@ -97,13 +110,25 @@ export function setupMcpManager(io: SocketIoServer): McpManager { throw new Error(`Tool ${toolName} not found`); } - const { client, tool } = toolInfo; + const { client, tool, serverId } = toolInfo; try { + // Get credentials for this tool if required + const credentials = sessionManager.getToolCredentials( + sessionId, + toolName, + serverId + ); + + // Add credentials to args if available + const argsWithCredentials = credentials + ? { ...args, __credentials: credentials } + : args; + // Execute the tool via MCP const result = await client.callTool({ name: toolName, - arguments: args, + arguments: argsWithCredentials, }); return result; @@ -118,6 +143,32 @@ export function setupMcpManager(io: SocketIoServer): McpManager { return [...servers]; }; + // Get tools that require credentials + const getToolsWithCredentialRequirements = (sessionId: string): ToolCredentialInfo[] => { + return toolRegistry.getToolsWithCredentialRequirements(); + }; + + // Set credentials for a tool + const setToolCredentials = async ( + sessionId: string, + toolName: string, + serverId: string, + credentials: Record + ): Promise => { + try { + sessionManager.setToolCredentials( + sessionId, + toolName, + serverId, + credentials + ); + return true; + } catch (error) { + console.error(`Error setting credentials for tool ${toolName}:`, error); + return false; + } + }; + // Clean up connections when closing const cleanup = async (): Promise => { for (const [serverId, client] of connectedClients.entries()) { @@ -137,6 +188,8 @@ export function setupMcpManager(io: SocketIoServer): McpManager { executeToolCall, registerServer, getAvailableServers, + getToolsWithCredentialRequirements, + setToolCredentials, cleanup, }; } diff --git a/server/src/mcp/sessionManager.ts b/server/src/mcp/sessionManager.ts index c79c53e..f361762 100644 --- a/server/src/mcp/sessionManager.ts +++ b/server/src/mcp/sessionManager.ts @@ -1,15 +1,24 @@ // server/src/mcp/sessionManager.ts import { v4 as uuidv4 } from "uuid"; +import crypto from "crypto"; + +interface ToolCredential { + toolName: string; + serverId: string; + data: string; // Encrypted credentials +} interface Session { id: string; anthropicApiKey?: string; + credentials: ToolCredential[]; createdAt: Date; lastActive: Date; } export class SessionManager { private sessions: Map = new Map(); + private encryptionKey: Buffer; // Session cleanup interval in milliseconds (1 hour) private readonly CLEANUP_INTERVAL = 60 * 60 * 1000; @@ -17,6 +26,13 @@ export class SessionManager { constructor() { // Set up session cleanup setInterval(() => this.cleanupSessions(), this.CLEANUP_INTERVAL); + + // Generate or load encryption key (in production, this should be loaded from a secure source) + this.encryptionKey = Buffer.from( + process.env.CREDENTIAL_ENCRYPTION_KEY || + crypto.randomBytes(32).toString('hex'), + 'hex' + ); } createSession(): string { @@ -25,6 +41,7 @@ export class SessionManager { this.sessions.set(sessionId, { id: sessionId, + credentials: [], createdAt: now, lastActive: now, }); @@ -58,6 +75,94 @@ export class SessionManager { return this.sessions.get(sessionId)?.anthropicApiKey; } + // Store credentials for a tool + setToolCredentials( + sessionId: string, + toolName: string, + serverId: string, + credentials: Record + ): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + // Remove any existing credentials for this tool + session.credentials = session.credentials.filter( + cred => !(cred.toolName === toolName && cred.serverId === serverId) + ); + + // Encrypt the credentials + const encrypted = this.encryptData(JSON.stringify(credentials)); + + // Add the new credentials + session.credentials.push({ + toolName, + serverId, + data: encrypted + }); + + session.lastActive = new Date(); + this.sessions.set(sessionId, session); + } + + // Get credentials for a tool + getToolCredentials( + sessionId: string, + toolName: string, + serverId: string + ): Record | null { + const session = this.sessions.get(sessionId); + if (!session) return null; + + const credential = session.credentials.find( + cred => cred.toolName === toolName && cred.serverId === serverId + ); + + if (!credential) return null; + + try { + // Decrypt the credentials + const decrypted = this.decryptData(credential.data); + return JSON.parse(decrypted); + } catch (error) { + console.error(`Error decrypting credentials for tool ${toolName}:`, error); + return null; + } + } + + // Encrypt data using AES-256-GCM + private encryptData(data: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); + + const encrypted = Buffer.concat([ + cipher.update(data, 'utf8'), + cipher.final() + ]); + + const authTag = cipher.getAuthTag(); + + // Return IV + AuthTag + Encrypted data as base64 string + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); + } + + // Decrypt data using AES-256-GCM + private decryptData(encryptedBase64: string): string { + const encryptedBuffer = Buffer.from(encryptedBase64, 'base64'); + + // Extract IV, AuthTag, and encrypted data + const iv = encryptedBuffer.subarray(0, 16); + const authTag = encryptedBuffer.subarray(16, 32); + const encrypted = encryptedBuffer.subarray(32); + + const decipher = crypto.createDecipheriv('aes-256-gcm', this.encryptionKey, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]).toString('utf8'); + } + private cleanupSessions(): void { const now = new Date(); const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours diff --git a/server/src/mcp/toolRegistry.ts b/server/src/mcp/toolRegistry.ts index 88adc13..c4b189f 100644 --- a/server/src/mcp/toolRegistry.ts +++ b/server/src/mcp/toolRegistry.ts @@ -1,22 +1,43 @@ // server/src/mcp/toolRegistry.ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { CredentialRequirement, ToolInfo as SharedToolInfo } from "../../shared/types.js"; interface ToolInfo { serverId: string; + serverName: string; client: Client; tool: Tool; + credentialRequirements?: CredentialRequirement[]; } export class ToolRegistry { private tools: Map = new Map(); - registerTools(serverId: string, client: Client, tools: Tool[]): void { + registerTools(serverId: string, serverName: string, client: Client, tools: Tool[]): void { for (const tool of tools) { + // Extract credential requirements if present in the tool definition + const credentialRequirements = (tool.inputSchema as any)?.__credentials?.required?.map( + (id: string): CredentialRequirement => { + const acquisition = (tool.inputSchema as any)?.__credentials?.acquisition; + return { + id, + name: id.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), + description: (tool.inputSchema as any)?.__credentials?.descriptions?.[id], + acquisition: acquisition ? { + url: acquisition.url, + instructions: acquisition.instructions + } : undefined + }; + } + ); + this.tools.set(tool.name, { serverId, + serverName, client, tool, + credentialRequirements }); } } @@ -25,14 +46,40 @@ export class ToolRegistry { return this.tools.get(toolName); } - getAllTools(): Tool[] { - return Array.from(this.tools.values()).map((info) => info.tool); + getAllTools(): SharedToolInfo[] { + return Array.from(this.tools.values()).map((info) => ({ + name: info.tool.name, + description: info.tool.description, + inputSchema: info.tool.inputSchema, + credentialRequirements: info.credentialRequirements + })); } - getToolsByServerId(serverId: string): Tool[] { + getToolsByServerId(serverId: string): SharedToolInfo[] { return Array.from(this.tools.values()) .filter((info) => info.serverId === serverId) - .map((info) => info.tool); + .map((info) => ({ + name: info.tool.name, + description: info.tool.description, + inputSchema: info.tool.inputSchema, + credentialRequirements: info.credentialRequirements + })); + } + + getToolsWithCredentialRequirements(): { + toolName: string; + serverName: string; + serverId: string; + credentials: CredentialRequirement[]; + }[] { + return Array.from(this.tools.values()) + .filter(info => info.credentialRequirements && info.credentialRequirements.length > 0) + .map(info => ({ + toolName: info.tool.name, + serverName: info.serverName, + serverId: info.serverId, + credentials: info.credentialRequirements || [] + })); } removeToolsByServerId(serverId: string): void { diff --git a/server/src/routes.ts b/server/src/routes.ts index 1efb6b5..69b4cd4 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -188,6 +188,54 @@ export function setupRoutes(app: Express, mcpManager: McpManager): void { } }); + // NEW: Get tools that require credentials + app.get("/api/tools/credentials", (req: Request, res: Response) => { + try { + const sessionId = (req.headers["x-session-id"] as string) || "12345"; + const tools = mcpManager.getToolsWithCredentialRequirements(sessionId); + res.json({ tools }); + } catch (error) { + console.error("Error getting tools with credential requirements:", error); + res.status(500).json({ + error: error.message || "An error occurred while fetching tools", + }); + } + }); + + // NEW: Set credentials for a tool + app.post("/api/tools/credentials", async (req: Request, res: Response) => { + const { toolName, serverId, credentials } = req.body; + const sessionId = (req.headers["x-session-id"] as string) || "12345"; + + if (!toolName || !serverId || !credentials) { + return res.status(400).json({ + error: "Missing required fields. toolName, serverId, and credentials are required" + }); + } + + try { + const success = await mcpManager.setToolCredentials( + sessionId, + toolName, + serverId, + credentials + ); + + if (success) { + res.json({ success: true }); + } else { + res.status(500).json({ + error: "Failed to set credentials for the tool" + }); + } + } catch (error) { + console.error(`Error setting credentials for tool ${toolName}:`, error); + res.status(500).json({ + error: error.message || "An error occurred while setting credentials", + }); + } + }); + // Server registration endpoint app.post("/api/servers", async (req: Request, res: Response) => { const { id, name, url } = req.body; diff --git a/shared/types.ts b/shared/types.ts index 1b92539..7546109 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -60,12 +60,44 @@ export interface ApiKeyResponse { success: boolean; } +// New interfaces for tool credential management +export interface CredentialRequirement { + id: string; + name: string; + description?: string; + acquisition?: { + url?: string; + instructions?: string; + }; +} + +export interface ToolCredentialInfo { + toolName: string; + serverName: string; + serverId: string; + credentials: CredentialRequirement[]; +} + +export interface ToolCredentialRequest { + toolName: string; + serverId: string; + credentials: Record; +} + +export interface ToolCredentialResponse { + success: boolean; + error?: string; +} + +export interface ToolInfo { + name: string; + description?: string; + inputSchema: any; + credentialRequirements?: CredentialRequirement[]; +} + export interface ToolsListResponse { - tools: Array<{ - name: string; - description?: string; - inputSchema: any; - }>; + tools: ToolInfo[]; } export interface ServersListResponse {