-
Notifications
You must be signed in to change notification settings - Fork 372
feat: Multi-Server MCP Support Infrastructure #321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 26 commits
3a1e5d0
b37e9f3
2258fd0
7443bf0
54576bd
d9c4f58
16ea828
b4c96a7
6db3b52
8dbdaf4
1a7a111
fee57b6
c63e1ec
564b895
1a8bb5e
8a39de6
4638eb0
3707931
7dce3fb
3d8ec7b
18f3986
e39ea74
8b6a463
99aa4db
c01e7bf
4a8fa78
c1fd422
6de3b39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| #!/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", | ||
| 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", | ||
| url: "https://api.github.com/mcp", | ||
| authProvider: { | ||
| type: "api-key", | ||
| apiKey: "your-api-key-here", | ||
| }, | ||
| }, | ||
| "local-stdio": { | ||
| type: "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(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getMCPServers } from "@/lib/environment/mcp-servers"; | ||
| import { handleServerAuth } from "@/lib/mcp-auth"; | ||
|
|
||
| export const runtime = "edge"; | ||
|
|
||
| export async function proxyRequest( | ||
| req: NextRequest, | ||
| { params }: { params: { server: string; path: string[] } }, | ||
| ): Promise<Response> { | ||
| 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, | ||
| }); | ||
| } | ||
|
|
||
| // Export handlers for all HTTP methods | ||
| export const GET = proxyRequest; | ||
| export const POST = proxyRequest; | ||
| export const PUT = proxyRequest; | ||
| export const PATCH = proxyRequest; | ||
| export const DELETE = proxyRequest; | ||
| export const HEAD = proxyRequest; | ||
| export const OPTIONS = proxyRequest; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { createServerClient } from "@supabase/ssr"; | ||
| import { getMCPServers } from "@/lib/environment/mcp-servers"; | ||
|
|
||
| // This will contain the object which contains the access token | ||
| const MCP_TOKENS = process.env.MCP_TOKENS; | ||
|
|
@@ -89,6 +90,33 @@ async function getMcpAccessToken(supabaseToken: string, mcpServerUrl: URL) { | |
| * @returns The response from the MCP server. | ||
| */ | ||
| export async function proxyRequest(req: NextRequest): Promise<Response> { | ||
| // 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]) { | ||
| // Import and use the new per-server proxy handler | ||
| const { proxyRequest: newProxyRequest } = await import( | ||
| "./[server]/[...path]/route" | ||
| ); | ||
| return newProxyRequest(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 +127,6 @@ export async function proxyRequest(req: NextRequest): Promise<Response> { | |
| ); | ||
| } | ||
|
|
||
| // 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}`; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, ToolWithServer[]>; | ||
| selectedTools: string[]; | ||
| onToolToggle: (toolName: string) => void; | ||
| } | ||
|
|
||
| export function ToolSelectionByServer({ | ||
| toolsByServer, | ||
| selectedTools, | ||
| onToolToggle, | ||
| }: ToolSelectionByServerProps) { | ||
| const [expandedServers, setExpandedServers] = useState<Set<string>>( | ||
| 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 ( | ||
| <div className="space-y-2"> | ||
| {Array.from(toolsByServer.entries()).map(([serverName, tools]) => ( | ||
| <Collapsible | ||
| key={serverName} | ||
| open={expandedServers.has(serverName)} | ||
| onOpenChange={() => toggleServer(serverName)} | ||
| > | ||
| <CollapsibleTrigger className="bg-muted/50 hover:bg-muted flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium"> | ||
| <span> | ||
| {serverName} ({tools.length} tools) | ||
| </span> | ||
| {expandedServers.has(serverName) ? ( | ||
| <ChevronDown className="h-4 w-4" /> | ||
| ) : ( | ||
| <ChevronRight className="h-4 w-4" /> | ||
| )} | ||
| </CollapsibleTrigger> | ||
| <CollapsibleContent className="mt-2 space-y-1 pl-4"> | ||
| {tools.map((tool) => ( | ||
| <ConfigFieldTool | ||
| key={`${serverName}-${tool.name}`} | ||
| id={tool.name} | ||
| label={tool.name} | ||
| description={tool.description} | ||
| value={selectedTools.includes(tool.name)} | ||
| setValue={(checked) => onToolToggle(tool.name)} | ||
| /> | ||
| ))} | ||
| </CollapsibleContent> | ||
| </Collapsible> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The script migrate-single-to-multi-mcp.ts claims you can run it with node, but it’s a TypeScript file (.ts)! Of course, Node.js will just throw an error, because it can’t execute TypeScript out-of-the-box.