diff --git a/apps/web/scripts/migrate-single-to-multi-mcp.ts b/apps/web/scripts/migrate-single-to-multi-mcp.ts new file mode 100644 index 00000000..38e2475d --- /dev/null +++ b/apps/web/scripts/migrate-single-to-multi-mcp.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +/** + * Migration script to convert legacy single MCP server configuration to multi-server format + * + * Usage: + * node scripts/migrate-single-to-multi-mcp.ts + * + * This script reads the legacy environment variables: + * - NEXT_PUBLIC_MCP_SERVER_URL + * - NEXT_PUBLIC_MCP_AUTH_REQUIRED + * + * And generates the new NEXT_PUBLIC_MCP_SERVERS JSON configuration. + * + * The output can be copied to your .env file or environment configuration. + * + * Example output: + * NEXT_PUBLIC_MCP_SERVERS='{"default":{"type":"http","url":"http://localhost:3001","authProvider":{"type":"bearer"}}}' + */ + +import { MCPServersConfig, MCPServerHTTPConfig } from "../src/types/mcp"; + +function migrateSingleToMultiMCP(): void { + const legacyUrl = process.env.NEXT_PUBLIC_MCP_SERVER_URL; + const authRequired = process.env.NEXT_PUBLIC_MCP_AUTH_REQUIRED === "true"; + + if (!legacyUrl) { + console.error("❌ No legacy MCP server configuration found."); + console.error(" NEXT_PUBLIC_MCP_SERVER_URL is not set."); + process.exit(1); + } + + console.log("🔍 Found legacy MCP configuration:"); + console.log(` URL: ${legacyUrl}`); + console.log(` Auth Required: ${authRequired}`); + console.log(""); + + // Create the new multi-server configuration + const serverConfig: MCPServerHTTPConfig = { + type: "http", + transport: "http", + url: legacyUrl, + }; + + // Add auth provider if authentication was required + if (authRequired) { + serverConfig.authProvider = { + type: "bearer", + }; + } + + const multiServerConfig: MCPServersConfig = { + default: serverConfig, + }; + + // Generate the JSON string + const jsonConfig = JSON.stringify(multiServerConfig); + + console.log("✅ Generated multi-server configuration:"); + console.log(""); + console.log("Add the following to your .env file:"); + console.log("====================================="); + console.log(`NEXT_PUBLIC_MCP_SERVERS='${jsonConfig}'`); + console.log("====================================="); + console.log(""); + console.log("📝 Notes:"); + console.log(" - The legacy server is now named 'default'"); + console.log(" - You can add more servers by editing the JSON"); + console.log(" - The legacy variables can be removed after migration"); + console.log(""); + console.log("Example with multiple servers:"); + console.log("=============================="); + const exampleConfig: MCPServersConfig = { + default: serverConfig, + "github-tools": { + type: "http", + transport: "http", + url: "https://api.github.com/mcp", + authProvider: { + type: "api-key", + apiKey: "your-api-key-here", + }, + }, + "local-stdio": { + type: "stdio", + transport: "stdio", + command: "node", + args: ["./local-mcp-server.js"], + }, + }; + console.log( + `NEXT_PUBLIC_MCP_SERVERS='${JSON.stringify(exampleConfig, null, 2)}'`, + ); +} + +// Run the migration +if (require.main === module) { + migrateSingleToMultiMCP(); +} diff --git a/apps/web/src/app/api/oap_mcp/[server]/[...path]/route.ts b/apps/web/src/app/api/oap_mcp/[server]/[...path]/route.ts new file mode 100644 index 00000000..54aa2b42 --- /dev/null +++ b/apps/web/src/app/api/oap_mcp/[server]/[...path]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest } from "next/server"; +import { proxyMultiServerRequest } from "../../proxy-request"; + +export const runtime = "edge"; + +function extractParamsFromUrl(req: NextRequest) { + const pathname = req.nextUrl?.pathname ?? new URL(req.url).pathname; + const afterBase = pathname.replace(/^\/api\/oap_mcp\//, ""); + const segments = afterBase.split("/").filter(Boolean); + const [server, ...path] = segments; + return { server, path } as const; +} + +export async function GET(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} + +export async function POST(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} + +export async function PUT(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} + +export async function PATCH(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} + +export async function DELETE(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} + +export async function HEAD(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} + +export async function OPTIONS(req: NextRequest) { + const { server, path } = extractParamsFromUrl(req); + return proxyMultiServerRequest(req, { params: { server, path } }); +} diff --git a/apps/web/src/app/api/oap_mcp/proxy-request.ts b/apps/web/src/app/api/oap_mcp/proxy-request.ts index 0376d13d..fe509d18 100644 --- a/apps/web/src/app/api/oap_mcp/proxy-request.ts +++ b/apps/web/src/app/api/oap_mcp/proxy-request.ts @@ -1,6 +1,72 @@ +import { getMCPServers } from "@/lib/environment/mcp-servers"; +import { handleServerAuth } from "@/lib/mcp-auth"; import { NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; +export async function proxyMultiServerRequest( + req: NextRequest, + { params }: { params: { server: string; path: string[] } }, +): Promise { + const servers = getMCPServers(); + const serverConfig = servers[params.server]; + + if (!serverConfig) { + return NextResponse.json( + { message: `Server ${params.server} not found` }, + { status: 404 }, + ); + } + + if (serverConfig.type === "stdio") { + return NextResponse.json( + { message: "STDIO transport not supported via proxy" }, + { status: 400 }, + ); + } + + // Construct target URL + const path = params.path.join("/"); + const targetUrl = new URL(serverConfig.url); + targetUrl.pathname = `${targetUrl.pathname}/mcp/${path}`; + + // Handle authentication based on server config + const headers = new Headers(); + req.headers.forEach((value, key) => { + if (key.toLowerCase() !== "host") { + headers.append(key, value); + } + }); + + // Apply server-specific auth + if (serverConfig.authProvider) { + const accessToken = await handleServerAuth(serverConfig, req); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + } + + // Apply custom headers + if (serverConfig.headers) { + Object.entries(serverConfig.headers).forEach(([key, value]) => { + headers.set(key, value); + }); + } + + // Make the proxied request + const response = await fetch(targetUrl.toString(), { + method: req.method, + headers, + body: req.body, + }); + + // Return the response + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + // This will contain the object which contains the access token const MCP_TOKENS = process.env.MCP_TOKENS; const MCP_SERVER_URL = process.env.NEXT_PUBLIC_MCP_SERVER_URL; @@ -89,6 +155,29 @@ async function getMcpAccessToken(supabaseToken: string, mcpServerUrl: URL) { * @returns The response from the MCP server. */ export async function proxyRequest(req: NextRequest): Promise { + // Extract the path after '/api/oap_mcp/' + // Example: /api/oap_mcp/foo/bar -> /foo/bar + const url = new URL(req.url); + const path = url.pathname.replace(/^\/api\/oap_mcp/, ""); + + // Check if the first path segment might be a server name + const pathSegments = path.split("/").filter(Boolean); + if (pathSegments.length > 0) { + const servers = getMCPServers(); + const potentialServerName = pathSegments[0]; + + // If the first segment matches a configured server, delegate to new proxy + if (servers[potentialServerName]) { + return proxyMultiServerRequest(req, { + params: { + server: potentialServerName, + path: pathSegments.slice(1), + }, + }); + } + } + + // Legacy behavior - continue with single server logic if (!MCP_SERVER_URL) { return new Response( JSON.stringify({ @@ -99,11 +188,6 @@ export async function proxyRequest(req: NextRequest): Promise { ); } - // Extract the path after '/api/oap_mcp/' - // Example: /api/oap_mcp/foo/bar -> /foo/bar - const url = new URL(req.url); - const path = url.pathname.replace(/^\/api\/oap_mcp/, ""); - // Construct the target URL const targetUrlObj = new URL(MCP_SERVER_URL); targetUrlObj.pathname = `${targetUrlObj.pathname}${targetUrlObj.pathname.endsWith("/") ? "" : "/"}mcp${path}${url.search}`; diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx index effa3cdc..cd44fb87 100644 --- a/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx +++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/agent-form.tsx @@ -9,7 +9,6 @@ import { ConfigField, ConfigFieldAgents, ConfigFieldRAG, - ConfigFieldTool, } from "@/features/chat/components/configuration-sidebar/config-field"; import { useSearchTools } from "@/hooks/use-search-tools"; import { useMCPContext } from "@/providers/MCP"; @@ -22,6 +21,7 @@ import { import _ from "lodash"; import { useFetchPreselectedTools } from "@/hooks/use-fetch-preselected-tools"; import { Controller, useFormContext } from "react-hook-form"; +import { ToolSelectionByServer } from "./tool-selection-by-server"; export function AgentFieldsFormLoading() { return ( @@ -60,7 +60,8 @@ export function AgentFieldsForm({ config: Record; }>(); - const { tools, setTools, getTools, cursor, loading } = useMCPContext(); + const { tools, toolsByServer, setTools, getTools, getToolsByServer, cursor, loading } = + useMCPContext(); const { toolSearchTerm, debouncedSetSearchTerm, displayTools } = useSearchTools(tools, { preSelectedTools: toolConfigurations[0]?.default?.tools, @@ -152,28 +153,27 @@ export function AgentFieldsForm({ />
- {toolConfigurations[0]?.label - ? displayTools.map((c) => ( - ( - - )} + {toolConfigurations[0]?.label && ( + ( + { + const currentTools = value?.tools || []; + const newTools = currentTools.includes(toolName) + ? currentTools.filter( + (t: string) => t !== toolName, + ) + : [...currentTools, toolName]; + onChange({ ...value, tools: newTools }); + }} /> - )) - : null} + )} + /> + )} {displayTools.length === 0 && toolSearchTerm && (

No tools found matching "{toolSearchTerm}". @@ -192,7 +192,7 @@ export function AgentFieldsForm({ onClick={async () => { try { setLoadingMore(true); - const moreTool = await getTools(cursor); + const moreTool = await getToolsByServer(cursor, cursor); setTools((prevTools) => [ ...prevTools, ...moreTool, diff --git a/apps/web/src/features/agents/components/create-edit-agent-dialogs/tool-selection-by-server.tsx b/apps/web/src/features/agents/components/create-edit-agent-dialogs/tool-selection-by-server.tsx new file mode 100644 index 00000000..fcae27a1 --- /dev/null +++ b/apps/web/src/features/agents/components/create-edit-agent-dialogs/tool-selection-by-server.tsx @@ -0,0 +1,70 @@ +import { ToolWithServer } from "@/types/mcp"; +import { ConfigFieldTool } from "@/features/chat/components/configuration-sidebar/config-field"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +interface ToolSelectionByServerProps { + toolsByServer: Map; + selectedTools: string[]; + onToolToggle: (toolName: string) => void; +} + +export function ToolSelectionByServer({ + toolsByServer, + selectedTools, + onToolToggle, +}: ToolSelectionByServerProps) { + const [expandedServers, setExpandedServers] = useState>( + new Set(Array.from(toolsByServer.keys())), + ); + + const toggleServer = (serverName: string) => { + const newExpanded = new Set(expandedServers); + if (newExpanded.has(serverName)) { + newExpanded.delete(serverName); + } else { + newExpanded.add(serverName); + } + setExpandedServers(newExpanded); + }; + + return ( +

+ {Array.from(toolsByServer.entries()).map(([serverName, tools]) => ( + toggleServer(serverName)} + > + + + {serverName} ({tools.length} tools) + + {expandedServers.has(serverName) ? ( + + ) : ( + + )} + + + {tools.map((tool) => ( + onToolToggle(tool.name)} + /> + ))} + + + ))} +
+ ); +} diff --git a/apps/web/src/features/chat/components/configuration-sidebar/index.tsx b/apps/web/src/features/chat/components/configuration-sidebar/index.tsx index ddc13302..731d6600 100644 --- a/apps/web/src/features/chat/components/configuration-sidebar/index.tsx +++ b/apps/web/src/features/chat/components/configuration-sidebar/index.tsx @@ -8,7 +8,6 @@ import { ConfigField, ConfigFieldAgents, ConfigFieldRAG, - ConfigFieldTool, } from "@/features/chat/components/configuration-sidebar/config-field"; import { ConfigSection } from "@/features/chat/components/configuration-sidebar/config-section"; import { useConfigStore } from "@/features/chat/hooks/use-config-store"; @@ -26,6 +25,7 @@ import { import { toast } from "sonner"; import _ from "lodash"; import { useMCPContext } from "@/providers/MCP"; +import { ToolSelectionByServer } from "@/features/agents/components/create-edit-agent-dialogs/tool-selection-by-server"; import { Search } from "@/components/ui/tool-search"; import { useSearchTools } from "@/hooks/use-search-tools"; import { useFetchPreselectedTools } from "@/hooks/use-fetch-preselected-tools"; @@ -122,7 +122,7 @@ export const ConfigurationSidebar = forwardRef< AIConfigPanelProps >(({ className, open }, ref: ForwardedRef) => { const { configsByAgentId, resetConfig } = useConfigStore(); - const { tools, setTools, getTools, cursor } = useMCPContext(); + const { tools, toolsByServer, setTools, getTools, cursor } = useMCPContext(); const [agentId] = useQueryState("agentId"); const [deploymentId] = useQueryState("deploymentId"); const [threadId] = useQueryState("threadId"); @@ -145,10 +145,9 @@ export const ConfigurationSidebar = forwardRef< setOpenNameAndDescriptionAlertDialog, ] = useState(false); - const { toolSearchTerm, debouncedSetSearchTerm, displayTools } = - useSearchTools(tools, { - preSelectedTools: toolConfigurations[0]?.default?.tools, - }); + const { toolSearchTerm, debouncedSetSearchTerm } = useSearchTools(tools, { + preSelectedTools: toolConfigurations[0]?.default?.tools, + }); const { loadingMore, setLoadingMore } = useFetchPreselectedTools({ tools, setTools, @@ -365,25 +364,46 @@ export const ConfigurationSidebar = forwardRef< placeholder="Search tools..." />
- {agentId && - displayTools.length > 0 && - displayTools.map((c, index) => ( - - ))} - {agentId && - displayTools.length === 0 && - toolSearchTerm && ( -

- No tools found matching "{toolSearchTerm}". -

- )} + {agentId && toolsByServer.size > 0 && ( + { + const currentConfig = + configsByAgentId[agentId]?.[ + toolConfigurations[0]?.label + ] || {}; + const currentTools = currentConfig.tools || []; + const newTools = currentTools.includes(toolName) + ? currentTools.filter( + (t: string) => t !== toolName, + ) + : [...currentTools, toolName]; + + // Update the config store + const newConfig = { + ...currentConfig, + tools: newTools, + }; + useConfigStore + .getState() + .setConfig( + agentId, + toolConfigurations[0]?.label, + newConfig, + ); + }} + /> + )} + {agentId && tools.length === 0 && toolSearchTerm && ( +

+ No tools found matching "{toolSearchTerm}". +

+ )} {!agentId && (

Select an agent to see tools. diff --git a/apps/web/src/features/tools/playground/index.tsx b/apps/web/src/features/tools/playground/index.tsx index 7a214d68..6f1debdd 100644 --- a/apps/web/src/features/tools/playground/index.tsx +++ b/apps/web/src/features/tools/playground/index.tsx @@ -17,12 +17,20 @@ import _ from "lodash"; import { useQueryState } from "nuqs"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; export default function ToolsPlaygroundInterface() { - const { tools, loading, callTool } = useMCPContext(); + const { tools, toolsByServer, servers, loading, callTool } = useMCPContext(); const router = useRouter(); const [selectedToolName, setSelectedToolName] = useQueryState("tool"); + const [selectedServerName, setSelectedServerName] = useQueryState("server"); const [selectedTool, setSelectedTool] = useState(); const [inputValues, setInputValues] = useState({}); const [response, setResponse] = useState(null); @@ -46,17 +54,32 @@ export default function ToolsPlaygroundInterface() { return; } - const tool = tools.find((tool) => tool.name === selectedToolName); + // Find tool across all servers + let foundTool = null; + let foundServer = null; + + for (const [serverName, serverTools] of toolsByServer.entries()) { + const tool = serverTools.find((t) => t.name === selectedToolName); + if (tool) { + foundTool = tool; + foundServer = serverName; + break; + } + } - if (!tool) { + if (!foundTool) { toast.error("Tool not found", { richColors: true }); setSelectedToolName(null); router.replace("/tools"); return; } + resetState(); - setSelectedTool(tool); - }, [tools, loading, selectedToolName]); + setSelectedTool(foundTool); + if (foundServer && foundServer !== selectedServerName) { + setSelectedServerName(foundServer); + } + }, [tools, toolsByServer, loading, selectedToolName, selectedServerName]); const handleInputChange = (newValues: any) => { setInputValues(newValues); @@ -120,14 +143,41 @@ export default function ToolsPlaygroundInterface() {

Tools Playground

- { - resetState(); - setSelectedTool(t); - setSelectedToolName(t.name); - }} - /> +
+ + { + resetState(); + setSelectedTool(t); + setSelectedToolName(t.name); + // Update server selection if tool is from different server + if ("serverName" in t) { + setSelectedServerName(t.serverName); + } + }} + /> +
diff --git a/apps/web/src/hooks/use-mcp.tsx b/apps/web/src/hooks/use-mcp.tsx index a2cca83b..18d83827 100644 --- a/apps/web/src/hooks/use-mcp.tsx +++ b/apps/web/src/hooks/use-mcp.tsx @@ -1,100 +1,191 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { Tool } from "@/types/tool"; -import { useState } from "react"; +import { Dispatch, SetStateAction, useState } from "react"; +import { getMCPServers } from "@/lib/environment/mcp-servers"; +import { MCPServerConfiguration, ToolWithServer } from "@/types/mcp"; -function getMCPUrlOrThrow() { - if (!process.env.NEXT_PUBLIC_BASE_API_URL) { - throw new Error("NEXT_PUBLIC_BASE_API_URL is not defined"); - } +export interface UseMCPOptions { + name: string; + version: string; + serverName?: string; // Optional: connect to specific server +} - const url = new URL(process.env.NEXT_PUBLIC_BASE_API_URL); - url.pathname = `${url.pathname}${url.pathname.endsWith("/") ? "" : "/"}oap_mcp`; - return url; +export interface MCPConnection { + serverName: string; + client: Client; + config: MCPServerConfiguration; } /** - * Custom hook for interacting with the Model Context Protocol (MCP). - * Provides functions to connect to an MCP server and list available tools. + * Custom hook for interacting with multiple Model Context Protocol (MCP) servers. + * Provides functions to connect to MCP servers and manage tools from multiple sources. */ -export default function useMCP({ - name, - version, -}: { - name: string; - version: string; -}) { - const [tools, setTools] = useState([]); - const [cursor, setCursor] = useState(""); - - /** - * Creates an MCP client and connects it to the specified server URL. - * @param url - The URL of the MCP server. - * @param options - Client identification options. - * @param options.name - The name of the client. - * @param options.version - The version of the client. - * @returns A promise that resolves to the connected MCP client instance. - */ - const createAndConnectMCPClient = async () => { - const url = getMCPUrlOrThrow(); - const connectionClient = new StreamableHTTPClientTransport(new URL(url)); - const mcp = new Client({ - name, - version, - }); +export default function useMCP({ name, version, serverName }: UseMCPOptions) { + const [connections, setConnections] = useState>( + new Map(), + ); + const [toolsByServer, setToolsByServer] = useState< + Map + >(new Map()); + const [cursorsByServer, setCursorsByServer] = useState>( + new Map(), + ); + + const createAndConnectMCPClient = async ( + serverName: string, + serverConfig: MCPServerConfiguration, + ): Promise => { + if (serverConfig.type === "stdio") { + // Handle stdio transport (not supported in browser) + throw new Error("STDIO transport not supported in browser environment"); + } + + // Handle HTTP/SSE transport - use proxy route for same-origin + const proxyUrl = new URL(window.location.origin); + proxyUrl.pathname = `/api/oap_mcp/${serverName}`; - await mcp.connect(connectionClient); - return mcp; + const transport = new StreamableHTTPClientTransport(proxyUrl); + const client = new Client({ name, version }); + + await client.connect(transport); + return client; }; - /** - * Connects to an MCP server and retrieves the list of available tools. - * @param url - The URL of the MCP server. - * @param options - Client identification options. - * @param options.name - The name of the client. - * @param options.version - The version of the client. - * @returns A promise that resolves to an array of available tools. - */ - const getTools = async (nextCursor?: string): Promise => { - const mcp = await createAndConnectMCPClient(); - const tools = await mcp.listTools({ cursor: nextCursor }); + const getToolsFromServer = async ( + serverName: string, + nextCursor?: string, + ): Promise => { + const servers = getMCPServers(); + const serverConfig = servers[serverName]; + + if (!serverConfig) { + throw new Error(`Server ${serverName} not found in configuration`); + } + + let connection = connections.get(serverName); + if (!connection) { + const client = await createAndConnectMCPClient(serverName, serverConfig); + connection = { serverName, client, config: serverConfig }; + setConnections((prev) => new Map(prev).set(serverName, connection!)); + } + + const tools = await connection.client.listTools({ cursor: nextCursor }); + if (tools.nextCursor) { - setCursor(tools.nextCursor); + setCursorsByServer((prev) => + new Map(prev).set(serverName, tools.nextCursor!), + ); } else { - setCursor(""); + setCursorsByServer((prev) => { + const next = new Map(prev); + next.delete(serverName); + return next; + }); } - return tools.tools; + + return tools.tools.map((tool) => ({ + ...tool, + serverName, + serverConfig, + })); + }; + + const getAllTools = async (): Promise => { + const servers = getMCPServers(); + const allTools: ToolWithServer[] = []; + + await Promise.all( + Object.keys(servers).map(async (serverName) => { + try { + const tools = await getToolsFromServer(serverName); + allTools.push(...tools); + } catch (e) { + console.error(`Failed to get tools from ${serverName}:`, e); + } + }), + ); + + return allTools; + }; + + const getToolsByServer = async (serverName: string, cursor?: string): Promise => { + const tools = await getToolsFromServer(serverName, cursor); + return tools; }; - /** - * Calls a tool on the MCP server. - * @param name - The name of the tool. - * @param version - The version of the tool. Optional. - * @param args - The arguments to pass to the tool. - * @returns A promise that resolves to the response from the tool. - */ const callTool = async ({ name, args, version, + serverName: specificServer, }: { name: string; args: Record; version?: string; + serverName?: string; }) => { - const mcp = await createAndConnectMCPClient(); - const response = await mcp.callTool({ - name, - version, - arguments: args, + // Find which server has this tool + let targetServer = specificServer; + + if (!targetServer) { + for (const [server, tools] of toolsByServer.entries()) { + if (tools.some((t) => t.name === name)) { + targetServer = server; + break; + } + } + } + + if (!targetServer) { + throw new Error(`Tool ${name} not found in any server`); + } + + const connection = connections.get(targetServer); + if (!connection) { + throw new Error(`Not connected to server ${targetServer}`); + } + + return connection.client.callTool({ name, version, arguments: args }); + }; + + // Legacy compatibility - maintain old interface + const tools = Array.from(toolsByServer.values()).flat(); + const setTools: Dispatch> = (value) => { + setToolsByServer((prevMap) => { + const prevTools = Array.from(prevMap.values()).flat(); + const nextTools = + typeof value === "function" + ? (value as (prevState: ToolWithServer[]) => ToolWithServer[])( + prevTools, + ) + : value; + + const newMap = new Map(); + nextTools.forEach((tool) => { + const serverTools = newMap.get(tool.serverName) || []; + serverTools.push(tool); + newMap.set(tool.serverName, serverTools); + }); + return newMap; }); - return response; }; + // Legacy single cursor - returns first server's cursor + const cursor = Array.from(cursorsByServer.values())[0] || ""; + return { - getTools, + getToolsFromServer, + getAllTools, callTool, - createAndConnectMCPClient, + toolsByServer, + setToolsByServer, + cursorsByServer, + connections, + getToolsByServer, + // Legacy compatibility + getTools: getAllTools, + createAndConnectMCPClient: () => + createAndConnectMCPClient("default", getMCPServers().default), tools, setTools, cursor, diff --git a/apps/web/src/lib/environment/mcp-servers.ts b/apps/web/src/lib/environment/mcp-servers.ts new file mode 100644 index 00000000..cad39d0c --- /dev/null +++ b/apps/web/src/lib/environment/mcp-servers.ts @@ -0,0 +1,37 @@ +import { MCPServersConfig } from "@/types/mcp"; + +/** + * Get MCP servers configuration from environment variables. + * Supports both new multi-server format and legacy single-server format. + * + * @returns MCPServersConfig object with server configurations + */ +export function getMCPServers(): MCPServersConfig { + const serversJson = process.env.NEXT_PUBLIC_MCP_SERVERS; + + if (!serversJson) { + // Backward compatibility: check for single server config + const singleServerUrl = process.env.NEXT_PUBLIC_MCP_SERVER_URL; + if (singleServerUrl) { + return { + default: { + type: "http", + transport: "http", + url: singleServerUrl, + authProvider: + process.env.NEXT_PUBLIC_MCP_AUTH_REQUIRED === "true" + ? { type: "bearer" } + : undefined, + }, + }; + } + return {}; + } + + try { + return JSON.parse(serversJson); + } catch (e) { + console.error("Failed to parse NEXT_PUBLIC_MCP_SERVERS", e); + return {}; + } +} diff --git a/apps/web/src/lib/mcp-auth.ts b/apps/web/src/lib/mcp-auth.ts new file mode 100644 index 00000000..f87724d8 --- /dev/null +++ b/apps/web/src/lib/mcp-auth.ts @@ -0,0 +1,122 @@ +import { MCPServerHTTPConfig } from "@/types/mcp"; +import { NextRequest } from "next/server"; + +interface ServerAuthState { + serverName: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; +} + +/** + * Handle authentication for a specific MCP server based on its configuration. + * Supports bearer tokens, API keys, and OAuth flows. + */ +export async function handleServerAuth( + serverConfig: MCPServerHTTPConfig, + req: NextRequest, +): Promise { + if (!serverConfig.authProvider) { + return null; + } + + const { authProvider } = serverConfig; + + switch (authProvider.type) { + case "oauth": + return handleOAuthFlow(serverConfig, authProvider, req); + case "bearer": + return handleBearerToken(serverConfig, req); + case "api-key": + return authProvider.apiKey || null; + default: + console.warn(`Unknown auth provider type: ${(authProvider as any).type}`); + return null; + } +} + +/** + * Handle OAuth 2.0 authentication flow. + * This is a stub implementation that should be expanded based on specific OAuth requirements. + */ +async function handleOAuthFlow( + serverConfig: MCPServerHTTPConfig, + authProvider: any, + req: NextRequest, +): Promise { + // Check for existing valid token + const existingToken = await getStoredToken(serverConfig.url); + if (existingToken && !isTokenExpired(existingToken)) { + return existingToken.accessToken || null; + } + + // TODO: Implement OAuth 2.0 flow as per MCP spec + // This would involve: + // 1. Discovery of authorization server + // 2. Dynamic client registration if needed + // 3. Authorization code flow with PKCE + // 4. Token exchange + // 5. Token storage + + // For now, return null as placeholder + console.warn( + "OAuth flow not fully implemented. Returning null for OAuth authentication.", + ); + return null; +} + +/** + * Handle bearer token authentication. + * Checks multiple sources for bearer tokens in order of precedence. + */ +async function handleBearerToken( + serverConfig: MCPServerHTTPConfig, + req: NextRequest, +): Promise { + // Check for bearer token in various sources + const authHeader = req.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + return authHeader.substring(7); + } + + // Check cookies + const tokenCookie = req.cookies.get("X-MCP-Access-Token"); + if (tokenCookie) { + return tokenCookie.value; + } + + // Check environment variables + const envTokens = process.env.MCP_TOKENS; + if (envTokens) { + try { + const tokens = JSON.parse(envTokens); + return tokens[serverConfig.url] || null; + } catch (e) { + console.error("Failed to parse MCP_TOKENS", e); + } + } + + return null; +} + +/** + * Get stored token for a server URL. + * This is a placeholder implementation that should be replaced with actual token storage. + */ +async function getStoredToken( + serverUrl: string, +): Promise { + // TODO: Implement actual token storage retrieval + // This could use cookies, local storage, or a server-side session store + return null; +} + +/** + * Check if a token has expired. + */ +function isTokenExpired(authState: ServerAuthState): boolean { + if (!authState.expiresAt) { + return false; + } + return Date.now() > authState.expiresAt; +} diff --git a/apps/web/src/providers/MCP.tsx b/apps/web/src/providers/MCP.tsx index fca7a885..effd4870 100644 --- a/apps/web/src/providers/MCP.tsx +++ b/apps/web/src/providers/MCP.tsx @@ -3,36 +3,84 @@ import React, { useContext, PropsWithChildren, useEffect, - useRef, useState, + Dispatch, + SetStateAction, } from "react"; import useMCP from "../hooks/use-mcp"; +import { getMCPServers } from "@/lib/environment/mcp-servers"; +import { MCPServersConfig, ToolWithServer } from "@/types/mcp"; -type MCPContextType = ReturnType & { loading: boolean }; +interface MCPContextType { + servers: MCPServersConfig; + toolsByServer: Map; + loading: boolean; + loadingByServer: Map; + getToolsFromServer: ( + serverName: string, + cursor?: string, + ) => Promise; + getAllTools: () => Promise; + callTool: (params: any) => Promise; + cursorsByServer: Map; + // Legacy compatibility + tools: ToolWithServer[]; + setTools: Dispatch>; + cursor: string; + getTools: () => Promise; + getToolsByServer: (serverName: string, cursor?: string) => Promise; + createAndConnectMCPClient: () => Promise; +} const MCPContext = createContext(null); export const MCPProvider: React.FC = ({ children }) => { const mcpState = useMCP({ - name: "Tools Interface", + name: "Open Agent Platform", version: "1.0.0", }); - const firstRequestMade = useRef(false); + const [loading, setLoading] = useState(false); + const [loadingByServer] = useState>(new Map()); + const servers = getMCPServers(); useEffect(() => { - if (mcpState.tools.length || firstRequestMade.current) return; - - firstRequestMade.current = true; + // Initial load of tools from all servers setLoading(true); mcpState - .getTools() - .then((tools) => mcpState.setTools(tools)) + .getAllTools() + .then((tools) => { + const toolsMap = new Map(); + tools.forEach((tool) => { + const serverTools = toolsMap.get(tool.serverName) || []; + serverTools.push(tool); + toolsMap.set(tool.serverName, serverTools); + }); + mcpState.setToolsByServer(toolsMap); + }) .finally(() => setLoading(false)); }, []); return ( - + {children} ); diff --git a/apps/web/src/types/configurable.ts b/apps/web/src/types/configurable.ts index 669eaeed..926d7efc 100644 --- a/apps/web/src/types/configurable.ts +++ b/apps/web/src/types/configurable.ts @@ -72,6 +72,14 @@ export type ConfigurableFieldMCPMetadata = { label: string; type: "mcp"; default?: { + // New multi-server configuration + servers?: { + [serverName: string]: { + tools?: string[]; + enabled?: boolean; + }; + }; + // Deprecated fields for backward compatibility tools?: string[]; url?: string; auth_required?: boolean; diff --git a/apps/web/src/types/mcp.ts b/apps/web/src/types/mcp.ts new file mode 100644 index 00000000..40459a74 --- /dev/null +++ b/apps/web/src/types/mcp.ts @@ -0,0 +1,68 @@ +import { Tool } from "./tool"; + +// Common fields for all MCP server configurations +export interface MCPServerConfig { + defaultToolTimeout?: number; + outputHandling?: + | "content" + | "artifact" + | { + audio?: "content" | "artifact"; + image?: "content" | "artifact"; + resource?: "content" | "artifact"; + text?: "content" | "artifact"; + }; +} + +// STDIO transport configuration +export interface MCPServerStdioConfig extends MCPServerConfig { + type: "stdio"; + transport: "stdio"; + command: string; + args: string[]; + cwd?: string; + encoding?: string; + env?: Record; + restart?: { + delayMs?: number; + enabled?: boolean; + maxAttempts?: number; + }; + stderr?: "overlapped" | "pipe" | "ignore" | "inherit"; +} + +// OAuth client provider interface (placeholder for now) +export interface OAuthClientProvider { + type: "oauth" | "bearer" | "api-key"; + clientId?: string; + authorizationUrl?: string; + tokenUrl?: string; + apiKey?: string; +} + +// HTTP/SSE transport configuration +export interface MCPServerHTTPConfig extends MCPServerConfig { + type: "http" | "sse"; + transport: "http" | "sse"; + url: string; + authProvider?: OAuthClientProvider; + automaticSSEFallback?: boolean; + headers?: Record; + reconnect?: { + delayMs?: number; + enabled?: boolean; + maxAttempts?: number; + }; +} + +export type MCPServerConfiguration = MCPServerStdioConfig | MCPServerHTTPConfig; + +export interface MCPServersConfig { + [serverName: string]: MCPServerConfiguration; +} + +// Update tool type to include server association +export interface ToolWithServer extends Tool { + serverName: string; + serverConfig: MCPServerConfiguration; +} diff --git a/apps/web/src/types/tool.ts b/apps/web/src/types/tool.ts index 7be3f09e..3d3a990a 100644 --- a/apps/web/src/types/tool.ts +++ b/apps/web/src/types/tool.ts @@ -18,3 +18,6 @@ export interface Tool { */ inputSchema: InputSchema; } + +// Re-export ToolWithServer from mcp.ts for convenience +export type { ToolWithServer } from "./mcp";