Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
117 changes: 78 additions & 39 deletions apps/web/src/app/api/oap_mcp/proxy-request.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
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;
Expand Down Expand Up @@ -79,6 +85,60 @@ 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,
Expand All @@ -89,24 +149,17 @@ async function getMcpAccessToken(supabaseToken: string, mcpServerUrl: URL) {
* @returns The response from the MCP server.
*/
export async function proxyRequest(req: NextRequest): Promise<Response> {
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
Expand All @@ -119,36 +172,22 @@ export async function proxyRequest(req: NextRequest): Promise<Response> {
}
});

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),
);
}

Expand Down Expand Up @@ -209,13 +248,13 @@ export async function proxyRequest(req: NextRequest): Promise<Response> {
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",
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/app/api/oap_mcp/proxy/[..._path]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
17 changes: 14 additions & 3 deletions apps/web/src/hooks/use-mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ 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;
}

Expand All @@ -20,9 +29,11 @@ function getMCPUrlOrThrow() {
export default function useMCP({
name,
version,
id,
}: {
name: string;
version: string;
id?: string;
}) {
const [tools, setTools] = useState<Tool[]>([]);
const [cursor, setCursor] = useState("");
Expand All @@ -36,7 +47,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,
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/lib/environment/mcp-servers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions apps/web/src/types/mcp.ts
Original file line number Diff line number Diff line change
@@ -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;
}