From 2e6a19f01bcc13c1091e6939e7dfec96f46fd57f Mon Sep 17 00:00:00 2001 From: bracesproul Date: Thu, 26 Jun 2025 11:53:40 -0600 Subject: [PATCH 1/2] feat: Support multiple MCP servers --- apps/web/src/app/api/oap_mcp/proxy-request.ts | 112 ++++++++++++------ .../app/api/oap_mcp/proxy/[..._path]/route.ts | 36 ++++++ apps/web/src/hooks/use-mcp.tsx | 15 ++- apps/web/src/lib/environment/mcp-servers.ts | 31 +++++ apps/web/src/types/mcp.ts | 25 ++++ 5 files changed, 177 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/app/api/oap_mcp/proxy/[..._path]/route.ts create mode 100644 apps/web/src/lib/environment/mcp-servers.ts create mode 100644 apps/web/src/types/mcp.ts 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..04f05706 100644 --- a/apps/web/src/app/api/oap_mcp/proxy-request.ts +++ b/apps/web/src/app/api/oap_mcp/proxy-request.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; +import { getMCPServerConfigs, isMCPServerConfig } from "@/lib/environment/mcp-servers"; +import { validate } from "uuid"; +import { MCPServerConfig } from "@/types/mcp"; // 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; -const MCP_AUTH_REQUIRED = process.env.NEXT_PUBLIC_MCP_AUTH_REQUIRED === "true"; +const mcpServers = getMCPServerConfigs(); + +const isAuthRequired = (server: MCPServerConfig): boolean => !!server.authUrl; async function getSupabaseToken(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; @@ -79,6 +82,58 @@ async function getMcpAccessToken(supabaseToken: string, mcpServerUrl: URL) { } } +function getIdAndPath(url: URL): { + id: string | null; + pathRest: string; +} { + // Extract the path and ID from the new format + // Example: /api/oap_mcp/proxy/[id] -> extract [id] and use it for the path + const pathMatch = url.pathname.match(/^\/api\/oap_mcp\/proxy\/([^/]+)(?:\/(.*))?$/); + + let id: string | null = null; + let pathRest = ""; + + if (pathMatch) { + // New path format: /api/oap_mcp/proxy/[id] + id = pathMatch[1]; + pathRest = pathMatch[2] ? `/${pathMatch[2]}` : ""; + } + return { id, pathRest }; +} + +function findServerOrThrow(url: URL): MCPServerConfig | Response { + if (!mcpServers?.length) { + return new Response( + JSON.stringify({ + message: + "No MCP servers found. Please set NEXT_PUBLIC_MCP_SERVERS environment variable.", + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + const { id } = getIdAndPath(url); + if (!id || !validate(id)) { + return new Response( + JSON.stringify({ + message: "Invalid MCP server ID.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + const selectedServer = mcpServers.find((mcpServer) => mcpServer.id === id); + if (!selectedServer) { + return new Response( + JSON.stringify({ + message: "MCP server not found.", + }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + + return selectedServer; +} + /** * Proxies requests from the client to the MCP server. * Extracts the path after '/api/oap_mcp', constructs the target URL, @@ -89,24 +144,17 @@ async function getMcpAccessToken(supabaseToken: string, mcpServerUrl: URL) { * @returns The response from the MCP server. */ export async function proxyRequest(req: NextRequest): Promise { - if (!MCP_SERVER_URL) { - return new Response( - JSON.stringify({ - message: - "MCP_SERVER_URL environment variable is not set. Please set it to the URL of your MCP server, or NEXT_PUBLIC_MCP_SERVER_URL if you do not want to use the proxy route.", - }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); + const reqUrl = new URL(req.url); + const selectedServer = findServerOrThrow(reqUrl); + if (!isMCPServerConfig(selectedServer)) { + return selectedServer; } - // 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/, ""); + const { pathRest } = getIdAndPath(reqUrl); // Construct the target URL - const targetUrlObj = new URL(MCP_SERVER_URL); - targetUrlObj.pathname = `${targetUrlObj.pathname}${targetUrlObj.pathname.endsWith("/") ? "" : "/"}mcp${path}${url.search}`; + const targetUrlObj = new URL(selectedServer.url); + targetUrlObj.pathname = `${targetUrlObj.pathname}${targetUrlObj.pathname.endsWith("/") ? "" : "/"}${pathRest}${reqUrl.search}`; const targetUrl = targetUrlObj.toString(); // Prepare headers, forwarding original headers except Host @@ -119,36 +167,22 @@ export async function proxyRequest(req: NextRequest): Promise { } }); - const mcpAccessTokenCookie = req.cookies.get("X-MCP-Access-Token")?.value; - // Authentication priority: - // 1. X-MCP-Access-Token header - // 2. X-MCP-Access-Token cookie - // 3. MCP_TOKENS environment variable - // 4. Supabase-JWT token exchange + const mcpAccessTokenCookieName = `X-MCP-Access-Token-${selectedServer.id}`; + const mcpAccessTokenCookie = req.cookies.get(mcpAccessTokenCookieName)?.value; let accessToken: string | null = null; - if (MCP_AUTH_REQUIRED) { + if (isAuthRequired(selectedServer)) { const supabaseToken = await getSupabaseToken(req); if (mcpAccessTokenCookie) { accessToken = mcpAccessTokenCookie; - } else if (MCP_TOKENS) { - // Try to use MCP_TOKENS environment variable - try { - const { access_token } = JSON.parse(MCP_TOKENS); - if (access_token) { - accessToken = access_token; - } - } catch (e) { - console.error("Failed to parse MCP_TOKENS env variable", e); - } } // If no token yet, try Supabase-JWT token exchange - if (!accessToken && supabaseToken && MCP_SERVER_URL) { + if (!accessToken && supabaseToken) { accessToken = await getMcpAccessToken( supabaseToken, - new URL(MCP_SERVER_URL), + new URL(selectedServer.url), ); } @@ -209,13 +243,13 @@ export async function proxyRequest(req: NextRequest): Promise { newResponse.headers.set(key, value); }); - if (MCP_AUTH_REQUIRED) { + if (isAuthRequired(selectedServer)) { // If we used the Supabase token exchange, add the access token to the response // so it can be used in future requests - if (!mcpAccessTokenCookie && !MCP_TOKENS && accessToken) { + if (!mcpAccessTokenCookie && accessToken) { // Set a cookie with the access token that will be included in future requests newResponse.cookies.set({ - name: "X-MCP-Access-Token", + name: mcpAccessTokenCookieName, value: accessToken, httpOnly: false, // Allow JavaScript access so it can be read for headers secure: process.env.NODE_ENV === "production", diff --git a/apps/web/src/app/api/oap_mcp/proxy/[..._path]/route.ts b/apps/web/src/app/api/oap_mcp/proxy/[..._path]/route.ts new file mode 100644 index 00000000..9922daf5 --- /dev/null +++ b/apps/web/src/app/api/oap_mcp/proxy/[..._path]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "../../proxy-request"; + +export const runtime = "edge"; + +// Define handlers for all relevant HTTP methods +export async function GET(req: NextRequest) { + return proxyRequest(req); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req); +} + +export async function PUT(req: NextRequest) { + return proxyRequest(req); +} + +export async function PATCH(req: NextRequest) { + return proxyRequest(req); +} + +export async function DELETE(req: NextRequest) { + return proxyRequest(req); +} + +export async function HEAD(req: NextRequest) { + return proxyRequest(req); +} + +export async function OPTIONS(req: NextRequest) { + // For OPTIONS, you might want to return specific CORS headers + // or simply proxy the request as well, depending on MCP server requirements. + // Basic proxying for now: + return proxyRequest(req); +} diff --git a/apps/web/src/hooks/use-mcp.tsx b/apps/web/src/hooks/use-mcp.tsx index a2cca83b..31ba5c2c 100644 --- a/apps/web/src/hooks/use-mcp.tsx +++ b/apps/web/src/hooks/use-mcp.tsx @@ -2,14 +2,21 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Tool } from "@/types/tool"; import { useState } from "react"; +import { getMCPServerConfigs } from "@/lib/environment/mcp-servers"; -function getMCPUrlOrThrow() { +function getMCPUrlOrThrow(id?: string) { if (!process.env.NEXT_PUBLIC_BASE_API_URL) { throw new Error("NEXT_PUBLIC_BASE_API_URL is not defined"); } + const mcpServers = getMCPServerConfigs(); + const mcpServer = id ? mcpServers.find((mcpServer) => mcpServer.id === id) : mcpServers[0]; + if (!mcpServer) { + throw new Error(`MCP server ${id} not found`); + } + const url = new URL(process.env.NEXT_PUBLIC_BASE_API_URL); - url.pathname = `${url.pathname}${url.pathname.endsWith("/") ? "" : "/"}oap_mcp`; + url.pathname = `${url.pathname}${url.pathname.endsWith("/") ? "" : "/"}oap_mcp/${id}`; return url; } @@ -20,9 +27,11 @@ function getMCPUrlOrThrow() { export default function useMCP({ name, version, + id, }: { name: string; version: string; + id?: string; }) { const [tools, setTools] = useState([]); const [cursor, setCursor] = useState(""); @@ -36,7 +45,7 @@ export default function useMCP({ * @returns A promise that resolves to the connected MCP client instance. */ const createAndConnectMCPClient = async () => { - const url = getMCPUrlOrThrow(); + const url = getMCPUrlOrThrow(id); const connectionClient = new StreamableHTTPClientTransport(new URL(url)); const mcp = new Client({ name, 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..c24b01a9 --- /dev/null +++ b/apps/web/src/lib/environment/mcp-servers.ts @@ -0,0 +1,31 @@ +import { validate } from "uuid"; +import { MCPServerConfig } from "@/types/mcp"; + +export function isMCPServerConfig(obj: unknown): obj is MCPServerConfig { + return ( + typeof obj === "object" && + obj !== null && + "id" in obj && + typeof obj.id === "string" && + validate(obj.id) && + "name" in obj && + typeof obj.name === "string" && + "url" in obj && + typeof obj.url === "string" + ); +}; + +/** + * Loads the provided MCP server configs from the environment variable. + * @returns {MCPServerConfig[]} The list of MCP server configs. + */ +export function getMCPServerConfigs(): MCPServerConfig[] { + const mcpServers: MCPServerConfig[] = JSON.parse( + process.env.NEXT_PUBLIC_MCP_SERVERS || "[]", + ); + const allEnvConfigsValid = mcpServers.every(isMCPServerConfig); + if (!allEnvConfigsValid) { + throw new Error("Invalid MCP server config"); + } + return mcpServers; +} diff --git a/apps/web/src/types/mcp.ts b/apps/web/src/types/mcp.ts new file mode 100644 index 00000000..e4ae5f5a --- /dev/null +++ b/apps/web/src/types/mcp.ts @@ -0,0 +1,25 @@ +export interface MCPServerConfig { + /** + * A UUID v4 identifier for the MCP server. + */ + id: string; + /** + * A custom name for the MCP server. + * This will be rendered to the user in the UI. + */ + name: string; + /** + * The main URL of the MCP server. + * This should be the URL which is used + * to fetch and call tools. + */ + url: string; + /** + * The URL to use for authentication. + * This should be the URL which is used + * to authenticate the user. If this is defined, + * it is assumed that the MCP server requires + * authentication. + */ + authUrl?: string; +} \ No newline at end of file From 988727b1b1de34aa931a2197a75ff57d76b3a18d Mon Sep 17 00:00:00 2001 From: bracesproul Date: Thu, 26 Jun 2025 11:54:05 -0600 Subject: [PATCH 2/2] format --- apps/web/src/app/api/oap_mcp/proxy-request.ts | 15 ++++++++++----- apps/web/src/hooks/use-mcp.tsx | 4 +++- apps/web/src/lib/environment/mcp-servers.ts | 2 +- apps/web/src/types/mcp.ts | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) 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 04f05706..8846fa6f 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,9 @@ import { NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; -import { getMCPServerConfigs, isMCPServerConfig } from "@/lib/environment/mcp-servers"; +import { + getMCPServerConfigs, + isMCPServerConfig, +} from "@/lib/environment/mcp-servers"; import { validate } from "uuid"; import { MCPServerConfig } from "@/types/mcp"; @@ -86,13 +89,15 @@ function getIdAndPath(url: URL): { id: string | null; pathRest: string; } { - // Extract the path and ID from the new format + // Extract the path and ID from the new format // Example: /api/oap_mcp/proxy/[id] -> extract [id] and use it for the path - const pathMatch = url.pathname.match(/^\/api\/oap_mcp\/proxy\/([^/]+)(?:\/(.*))?$/); - + const pathMatch = url.pathname.match( + /^\/api\/oap_mcp\/proxy\/([^/]+)(?:\/(.*))?$/, + ); + let id: string | null = null; let pathRest = ""; - + if (pathMatch) { // New path format: /api/oap_mcp/proxy/[id] id = pathMatch[1]; diff --git a/apps/web/src/hooks/use-mcp.tsx b/apps/web/src/hooks/use-mcp.tsx index 31ba5c2c..0e5bdeac 100644 --- a/apps/web/src/hooks/use-mcp.tsx +++ b/apps/web/src/hooks/use-mcp.tsx @@ -10,7 +10,9 @@ function getMCPUrlOrThrow(id?: string) { } const mcpServers = getMCPServerConfigs(); - const mcpServer = id ? mcpServers.find((mcpServer) => mcpServer.id === id) : mcpServers[0]; + const mcpServer = id + ? mcpServers.find((mcpServer) => mcpServer.id === id) + : mcpServers[0]; if (!mcpServer) { throw new Error(`MCP server ${id} not found`); } diff --git a/apps/web/src/lib/environment/mcp-servers.ts b/apps/web/src/lib/environment/mcp-servers.ts index c24b01a9..d73545dc 100644 --- a/apps/web/src/lib/environment/mcp-servers.ts +++ b/apps/web/src/lib/environment/mcp-servers.ts @@ -13,7 +13,7 @@ export function isMCPServerConfig(obj: unknown): obj is MCPServerConfig { "url" in obj && typeof obj.url === "string" ); -}; +} /** * Loads the provided MCP server configs from the environment variable. diff --git a/apps/web/src/types/mcp.ts b/apps/web/src/types/mcp.ts index e4ae5f5a..da7d8bbd 100644 --- a/apps/web/src/types/mcp.ts +++ b/apps/web/src/types/mcp.ts @@ -22,4 +22,4 @@ export interface MCPServerConfig { * authentication. */ authUrl?: string; -} \ No newline at end of file +}