Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/web/scripts/migrate-single-to-multi-mcp.ts
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
Copy link

@gedion gedion Aug 14, 2025

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.

*
* 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();
}
78 changes: 78 additions & 0 deletions apps/web/src/app/api/oap_mcp/[server]/[...path]/route.ts
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;
33 changes: 28 additions & 5 deletions apps/web/src/app/api/oap_mcp/proxy-request.ts
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;
Expand Down Expand Up @@ -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),
},
});
}
Copy link

@gedion gedion Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is throwing an error for me.

Switching to dynamic import with const { POST: newProxyRequest } = await import("./[server]/route") and calling the handler with params: { server: potentialServerName } works correctly for per-server proxying in Next.js route handlers. The try/catch ensures graceful fallback to legacy logic if the import fails. This approach resolves import and routing issues present in the MR.

}

// Legacy behavior - continue with single server logic
if (!MCP_SERVER_URL) {
return new Response(
JSON.stringify({
Expand All @@ -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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
Expand Down Expand Up @@ -60,7 +60,8 @@ export function AgentFieldsForm({
config: Record<string, any>;
}>();

const { tools, setTools, getTools, cursor, loading } = useMCPContext();
const { tools, toolsByServer, setTools, getTools, cursor, loading } =
useMCPContext();
const { toolSearchTerm, debouncedSetSearchTerm, displayTools } =
useSearchTools(tools, {
preSelectedTools: toolConfigurations[0]?.default?.tools,
Expand Down Expand Up @@ -152,28 +153,27 @@ export function AgentFieldsForm({
/>
<div className="relative w-full flex-1 basis-[500px] rounded-md border-[1px] border-slate-200 px-4">
<div className="absolute inset-0 overflow-y-auto px-4">
{toolConfigurations[0]?.label
? displayTools.map((c) => (
<Controller
key={`tool-${c.name}`}
control={form.control}
name={`config.${toolConfigurations[0].label}`}
render={({ field: { value, onChange } }) => (
<ConfigFieldTool
key={`tool-${c.name}`}
id={c.name}
label={c.name}
description={c.description}
agentId={agentId}
toolId={toolConfigurations[0].label}
className="border-b-[1px] py-4"
value={value}
setValue={onChange}
/>
)}
{toolConfigurations[0]?.label && (
<Controller
control={form.control}
name={`config.${toolConfigurations[0].label}`}
render={({ field: { value, onChange } }) => (
<ToolSelectionByServer
toolsByServer={toolsByServer}
selectedTools={value?.tools || []}
onToolToggle={(toolName) => {
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 && (
<p className="my-4 w-full text-center text-sm text-slate-500">
No tools found matching "{toolSearchTerm}".
Expand Down
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>
);
}
Loading