diff --git a/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts b/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts new file mode 100644 index 0000000000..69328167c3 --- /dev/null +++ b/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit } from "@app/server/config/rateLimiter"; + +const getMcpUrls = (siteUrl: string, endpointId: string) => { + // The MCP resource/connect URL + const resourceUrl = `${siteUrl}/api/v1/ai/mcp-endpoints/${endpointId}/connect`; + // The authorization server issuer (RFC 8414: metadata at /.well-known/oauth-authorization-server/{path}) + const authServerIssuer = `${siteUrl}/mcp-endpoints/${endpointId}`; + + // OAuth endpoint URLs + const apiBaseUrl = `${siteUrl}/api/v1/ai/mcp-endpoints/${endpointId}`; + const tokenEndpointUrl = `${apiBaseUrl}/oauth/token`; + const authorizeEndpointUrl = `${apiBaseUrl}/oauth/authorize`; + const registrationEndpointUrl = `${apiBaseUrl}/oauth/register`; + + return { + resourceUrl, + authServerIssuer, + tokenEndpointUrl, + authorizeEndpointUrl, + registrationEndpointUrl + }; +}; + +export const registerMcpEndpointMetadataRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const siteUrl = removeTrailingSlash(appCfg.SITE_URL || ""); + const siteHost = new URL(siteUrl).host; + const scopeAccess = `https://${siteHost}/mcp:access`; + + // OAuth 2.1: Protected Resource metadata + // GET /mcp-endpoints/:endpointId/.well-known/oauth-protected-resource + server.route({ + method: "GET", + url: "/:endpointId/.well-known/oauth-protected-resource", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }) + }, + handler: async (req, reply) => { + const { resourceUrl, authServerIssuer } = getMcpUrls(siteUrl, req.params.endpointId); + return reply.send({ + resource: resourceUrl, + authorization_servers: [authServerIssuer], + scopes_supported: ["openid", scopeAccess], + bearer_methods_supported: ["header"] + }); + } + }); +}; + +// RFC 8414 compliant OAuth Authorization Server metadata +// GET /.well-known/oauth-authorization-server/mcp-endpoints/:endpointId +export const registerMcpEndpointAuthServerMetadataRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const siteUrl = removeTrailingSlash(appCfg.SITE_URL || ""); + const siteHost = new URL(siteUrl).host; + const scopeAccess = `https://${siteHost}/mcp:access`; + + server.route({ + method: "GET", + url: "/mcp-endpoints/:endpointId", + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }) + }, + config: { + rateLimit: readLimit + }, + handler: async (req, reply) => { + const { authServerIssuer, authorizeEndpointUrl, tokenEndpointUrl, registrationEndpointUrl } = getMcpUrls( + siteUrl, + req.params.endpointId + ); + + return reply.send({ + issuer: authServerIssuer, + authorization_endpoint: authorizeEndpointUrl, + token_endpoint: tokenEndpointUrl, + registration_endpoint: registrationEndpointUrl, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["openid", scopeAccess] + }); + } + }); +}; diff --git a/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts b/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts index a747a78894..f76d6642cd 100644 --- a/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts +++ b/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts @@ -1,12 +1,120 @@ +import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from "fastify"; import { z } from "zod"; import { AiMcpEndpointServerToolsSchema } from "@app/db/schemas/ai-mcp-endpoint-server-tools"; import { AiMcpEndpointsSchema } from "@app/db/schemas/ai-mcp-endpoints"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const sendWwwAuthenticate = (reply: FastifyReply, endpointId: string, description?: string) => { + const appCfg = getConfig(); + const protectedResourceMetadataUrl = `${appCfg.SITE_URL}/mcp-endpoints/${endpointId}/.well-known/oauth-protected-resource`; + let header = `Bearer resource_metadata="${protectedResourceMetadataUrl}", scope="openid"`; + if (description) header = `${header}, error_description="${description}"`; + void reply.header("WWW-Authenticate", header); +}; + +// Custom onRequest hook to enforce auth while returning proper WWW-Authenticate hint for MCP clients +const requireMcpAuthHook = ( + req: FastifyRequest, + reply: FastifyReply, + done: HookHandlerDoneFunction, + endpointId: string +) => { + const { auth } = req; + if (!auth) { + sendWwwAuthenticate(reply, endpointId, "Missing authorization header"); + void reply.status(401).send(); + return; + } + + const allowed = auth.authMode === AuthMode.MCP_JWT; + if (!allowed) { + void reply.status(403).send(); + return; + } + + if (auth.authMode === AuthMode.MCP_JWT && !req.permission.orgId) { + void reply.status(401).send({ message: "Unauthorized: organization context required" }); + return; + } + + done(); +}; + export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }) + }, + url: "/:endpointId/connect", + onRequest: (req, reply, done) => requireMcpAuthHook(req, reply, done, req.params.endpointId), + handler: async (req, res) => { + await res.hijack(); // allow manual control of the underlying res + + if (req.auth.authMode !== AuthMode.MCP_JWT) { + throw new UnauthorizedError({ message: "Unauthorized" }); + } + + if (req.params.endpointId !== req.auth.token.mcp?.endpointId) { + throw new UnauthorizedError({ message: "Unauthorized" }); + } + + const { server: mcpServer, transport } = await server.services.aiMcpEndpoint.interactWithMcp({ + endpointId: req.params.endpointId + }); + + // Close transport when client disconnects + res.raw.on("close", () => { + void transport.close().catch((err) => { + logger.error(err, "Failed to close transport for mcp endpoint"); + }); + }); + + await mcpServer.connect(transport); + await transport.handleRequest(req.raw, res.raw, req.body); + } + }); + + server.route({ + method: ["GET", "DELETE"], + config: { + rateLimit: writeLimit + }, + url: "/:endpointId/connect", + + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }) + }, + onRequest: (req, reply, done) => requireMcpAuthHook(req, reply, done, req.params.endpointId), + handler: async (_req, res) => { + void res + .status(405) + .header("Allow", "POST") + .send({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed" + }, + id: null + }); + } + }); + server.route({ url: "/", method: "POST", @@ -278,4 +386,166 @@ export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) => return { tools }; } }); + + // OAUTH 2.0 + server.route({ + method: "POST", + url: "/:endpointId/oauth/register", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + body: z.object({ + redirect_uris: z.array(z.string()), + token_endpoint_auth_method: z.string(), + grant_types: z.array(z.string()), + response_types: z.array(z.string()), + client_name: z.string(), + client_uri: z.string().optional() + }), + response: { + 200: z.object({ + client_id: z.string(), + redirect_uris: z.array(z.string()), + client_name: z.string(), + client_uri: z.string().optional(), + grant_types: z.array(z.string()), + response_types: z.array(z.string()), + token_endpoint_auth_method: z.string(), + client_id_issued_at: z.number() + }) + } + }, + handler: async (req) => { + const payload = await server.services.aiMcpEndpoint.oauthRegisterClient({ + endpointId: req.params.endpointId, + ...req.body + }); + return payload; + } + }); + + // OAuth authorize - redirect to scope selection page + server.route({ + method: "GET", + url: "/:endpointId/oauth/authorize", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + querystring: z.object({ + response_type: z.string(), + client_id: z.string(), + code_challenge: z.string(), + code_challenge_method: z.enum(["S256"]), + redirect_uri: z.string(), + resource: z.string(), + state: z.string().optional() + }) + }, + handler: async (req, res) => { + await server.services.aiMcpEndpoint.oauthAuthorizeClient({ + clientId: req.query.client_id, + state: req.query.state + }); + const query = new URLSearchParams({ + ...req.query, + endpointId: req.params.endpointId + }).toString(); + + void res.redirect(`/organization/mcp-endpoint-finalize?${query}`); + } + }); + + server.route({ + method: "POST", + url: "/:endpointId/oauth/finalize", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + body: z.object({ + response_type: z.string(), + client_id: z.string(), + code_challenge: z.string(), + code_challenge_method: z.enum(["S256"]), + redirect_uri: z.string(), + resource: z.string(), + expireIn: z.string().refine((val) => ms(val) > 0, "Max TTL must be a positive number") + }), + response: { + 200: z.object({ + callbackUrl: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const userInfo = req.auth.authMode === AuthMode.JWT ? req.auth.user : null; + if (!userInfo) throw new BadRequestError({ message: "User info not found" }); + + const redirectUri = await server.services.aiMcpEndpoint.oauthFinalize({ + endpointId: req.params.endpointId, + clientId: req.body.client_id, + codeChallenge: req.body.code_challenge, + codeChallengeMethod: req.body.code_challenge_method, + redirectUri: req.body.redirect_uri, + resource: req.body.resource, + responseType: req.body.response_type, + projectId: "", + tokenId: req.auth.authMode === AuthMode.JWT ? req.auth.tokenVersionId : "", + userInfo, + expiry: req.body.expireIn, + permission: req.permission, + userAgent: req.auditLogInfo.userAgent || "", + userIp: req.auditLogInfo.ipAddress || "" + }); + + return { callbackUrl: redirectUri.toString() }; + } + }); + + server.route({ + method: "POST", + url: "/:endpointId/oauth/token", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + body: z.object({ + grant_type: z.literal("authorization_code"), + code: z.string(), + redirect_uri: z.string().url(), + code_verifier: z.string(), + client_id: z.string() + }), + response: { + 200: z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number(), + scope: z.string() + }) + } + }, + handler: async (req) => { + const payload = await server.services.aiMcpEndpoint.oauthTokenExchange({ + endpointId: req.params.endpointId, + ...req.body + }); + return payload; + } + }); }; diff --git a/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts index b71359a23b..8d16057aa7 100644 --- a/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts +++ b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts @@ -1,5 +1,25 @@ -import { NotFoundError } from "@app/lib/errors"; +import crypto from "node:crypto"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Server as RawMcpServer } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { crypto as cryptoModule } from "@app/lib/crypto"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { AuthTokenType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TAiMcpServerDALFactory } from "../ai-mcp-server/ai-mcp-server-dal"; +import { TAiMcpServerToolDALFactory } from "../ai-mcp-server/ai-mcp-server-tool-dal"; +import { TAiMcpServerCredentials } from "../ai-mcp-server/ai-mcp-server-types"; import { TAiMcpEndpointDALFactory } from "./ai-mcp-endpoint-dal"; import { TAiMcpEndpointServerDALFactory } from "./ai-mcp-endpoint-server-dal"; import { TAiMcpEndpointServerToolDALFactory } from "./ai-mcp-endpoint-server-tool-dal"; @@ -11,8 +31,13 @@ import { TDisableEndpointToolDTO, TEnableEndpointToolDTO, TGetAiMcpEndpointDTO, + TInteractWithMcpDTO, TListAiMcpEndpointsDTO, TListEndpointToolsDTO, + TOAuthAuthorizeClientDTO, + TOAuthFinalizeDTO, + TOAuthRegisterClientDTO, + TOAuthTokenExchangeDTO, TUpdateAiMcpEndpointDTO } from "./ai-mcp-endpoint-types"; @@ -20,15 +45,243 @@ type TAiMcpEndpointServiceFactoryDep = { aiMcpEndpointDAL: TAiMcpEndpointDALFactory; aiMcpEndpointServerDAL: TAiMcpEndpointServerDALFactory; aiMcpEndpointServerToolDAL: TAiMcpEndpointServerToolDALFactory; + aiMcpServerDAL: TAiMcpServerDALFactory; + aiMcpServerToolDAL: TAiMcpServerToolDALFactory; + kmsService: Pick; + keyStore: Pick; + authTokenService: Pick; +}; + +// OAuth schemas for parsing cached data +const DynamicClientInfoSchema = z.object({ + client_id: z.string(), + redirect_uris: z.array(z.string()), + client_name: z.string(), + client_uri: z.string().optional(), + grant_types: z.array(z.string()), + response_types: z.array(z.string()), + token_endpoint_auth_method: z.string(), + client_id_issued_at: z.number(), + state: z.string().optional() +}); + +const OauthChallengeCodeSchema = z.object({ + codeChallenge: z.string(), + codeChallengeMethod: z.string(), + userId: z.string(), + endpointId: z.string(), + expiry: z.string(), + redirectUri: z.string(), + userInfo: z.object({ + tokenId: z.string(), + orgId: z.string(), + authMethod: z.string().nullable(), + email: z.string(), + actorIp: z.string(), + actorName: z.string(), + actorUserAgent: z.string() + }) +}); + +const OAUTH_FLOW_EXPIRY_IN_SECS = 5 * 60; + +// PKCE challenge computation +const computePkceChallenge = (codeVerifier: string) => { + const sha256 = crypto.createHash("sha256").update(codeVerifier).digest(); + return Buffer.from(sha256).toString("base64url"); }; export type TAiMcpEndpointServiceFactory = ReturnType; +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */ export const aiMcpEndpointServiceFactory = ({ aiMcpEndpointDAL, aiMcpEndpointServerDAL, - aiMcpEndpointServerToolDAL + aiMcpEndpointServerToolDAL, + aiMcpServerDAL, + aiMcpServerToolDAL, + kmsService, + keyStore, + authTokenService }: TAiMcpEndpointServiceFactoryDep) => { + const interactWithMcp = async ({ endpointId }: TInteractWithMcpDTO) => { + const appCfg = getConfig(); + + // Get the endpoint + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + // Get connected servers for this endpoint + const connectedServerLinks = await aiMcpEndpointServerDAL.find({ aiMcpEndpointId: endpointId }); + + // Get enabled tools for this endpoint + const enabledToolConfigs = await aiMcpEndpointServerToolDAL.find({ aiMcpEndpointId: endpointId }); + const enabledToolIds = new Set(enabledToolConfigs.map((t) => t.aiMcpServerToolId)); + + // Get the actual server details + const serverIds = connectedServerLinks.map((link) => link.aiMcpServerId); + const servers = await Promise.all(serverIds.map((id) => aiMcpServerDAL.findById(id))); + const validServers = servers.filter((s) => s !== null && s !== undefined); + + if (validServers.length === 0) { + // Return an empty MCP server if no servers are connected + const emptyServer = new RawMcpServer( + { + name: "infisical-mcp-endpoint", + version: appCfg.INFISICAL_PLATFORM_VERSION || "0.0.1" + }, + { + capabilities: { + tools: {} + } + } + ); + emptyServer.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] })); + emptyServer.setRequestHandler(CallToolRequestSchema, () => { + throw new Error("No MCP servers connected to this endpoint"); + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + return { server: emptyServer, transport }; + } + + // Create cipher pair for decryption + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: endpoint.projectId + }); + + // Connect to each server and get their tools + const mcpClientTools = await Promise.all( + validServers.map(async (mcpServer) => { + // Get the database tool records for this server (to map tool names to IDs) + const dbServerTools = await aiMcpServerToolDAL.find({ aiMcpServerId: mcpServer.id }); + // Create a map from tool name to database tool ID for this specific server + const toolNameToDbId = new Map(dbServerTools.map((t) => [t.name, t.id])); + + if (!mcpServer.encryptedCredentials) { + return { client: null, server: mcpServer, tools: [], toolNameToDbId }; + } + + const decryptedCredentials = JSON.parse( + decryptor({ cipherTextBlob: mcpServer.encryptedCredentials }).toString() + ) as TAiMcpServerCredentials; + + // Get access token from credentials + let accessToken: string | undefined; + if ("accessToken" in decryptedCredentials) { + accessToken = decryptedCredentials.accessToken; + } else if ("token" in decryptedCredentials) { + accessToken = decryptedCredentials.token; + } + + const headers: Record = {}; + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + try { + const client = new Client({ + name: `infisical-mcp-client-${mcpServer.name}`, + version: "1.0.0" + }); + + const clientTransport = new StreamableHTTPClientTransport(new URL(mcpServer.url), { + requestInit: { headers } + }); + + await client.connect(clientTransport); + + // Get tools from this server + const { tools } = await client.listTools(); + + return { + client, + server: mcpServer, + tools: tools as Array<{ name: string; description?: string; inputSchema?: Record }>, + toolNameToDbId + }; + } catch { + // If connection fails, return empty tools for this server + return { client: null, server: mcpServer, tools: [], toolNameToDbId }; + } + }) + ); + + // Filter tools to only include explicitly enabled ones (least privilege principle) + // If no tools are explicitly enabled, no tools will be available + const enabledMcpClientTools = mcpClientTools.map((clientTool) => ({ + ...clientTool, + tools: clientTool.tools.filter((tool) => { + // Get the database ID for this tool (specific to this server) + const dbToolId = clientTool.toolNameToDbId.get(tool.name); + // Only include if the database tool ID is explicitly enabled + return dbToolId !== undefined && enabledToolIds.has(dbToolId); + }) + })); + + // Create the aggregating MCP server + const server = new RawMcpServer( + { + name: "infisical-mcp-endpoint", + version: appCfg.INFISICAL_PLATFORM_VERSION || "0.0.1" + }, + { + capabilities: { + tools: {} + } + } + ); + + // Handle ListTools request - aggregate tools from all connected servers + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: enabledMcpClientTools.flatMap((el) => el.tools) + })); + + // Handle CallTool request - route to the appropriate server + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + // Find the server that has this tool + const selectedMcpClient = enabledMcpClientTools.find((el) => el.tools.find((t) => t.name === name)); + if (!selectedMcpClient || !selectedMcpClient.client) { + throw new Error(`Unknown tool: ${name}`); + } + + try { + const result = await selectedMcpClient.client.callTool({ + name, + arguments: args + }); + + return result; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` + } + ], + isError: true + }; + } + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + return { server, transport }; + }; + const createMcpEndpoint = async ({ projectId, name, description, serverIds }: TCreateAiMcpEndpointDTO) => { const endpoint = await aiMcpEndpointDAL.create({ projectId, @@ -224,7 +477,183 @@ export const aiMcpEndpointServiceFactory = ({ return results; }; + // OAuth 2.0 Methods + const oauthRegisterClient = async ({ + endpointId, + client_name, + client_uri, + grant_types, + redirect_uris, + response_types, + token_endpoint_auth_method + }: TOAuthRegisterClientDTO) => { + // Verify the endpoint exists + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const clientId = `mcp_client_${crypto.randomBytes(32).toString("hex")}`; + const now = Math.floor(Date.now() / 1000); + + const payload = { + client_id: clientId, + client_name, + client_uri, + grant_types, + redirect_uris, + response_types, + token_endpoint_auth_method, + client_id_issued_at: now + }; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId), + OAUTH_FLOW_EXPIRY_IN_SECS, + JSON.stringify(payload) + ); + + return payload; + }; + + const oauthAuthorizeClient = async ({ clientId, state }: TOAuthAuthorizeClientDTO) => { + const oauthClientCache = await keyStore.getItem(KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId)); + if (!oauthClientCache) { + throw new UnauthorizedError({ message: `MCP OAuth client with id ${clientId} not found` }); + } + + // Update with state + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId), + OAUTH_FLOW_EXPIRY_IN_SECS, + JSON.stringify({ ...JSON.parse(oauthClientCache), state }) + ); + }; + + const oauthFinalize = async ({ + endpointId, + clientId, + codeChallenge, + codeChallengeMethod, + redirectUri, + expiry, + tokenId, + userInfo, + permission, + userAgent, + userIp + }: TOAuthFinalizeDTO) => { + const oauthClientCache = await keyStore.getItem(KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId)); + if (!oauthClientCache) { + throw new UnauthorizedError({ message: `MCP OAuth client with id ${clientId} not found` }); + } + + const oauthClient = await DynamicClientInfoSchema.parseAsync(JSON.parse(oauthClientCache)); + const isValidRedirectUri = oauthClient.redirect_uris.some((el) => new URL(el).toString() === redirectUri); + if (!isValidRedirectUri) throw new BadRequestError({ message: "Redirect URI mismatch" }); + + // Verify endpoint exists + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const code = crypto.randomBytes(32).toString("hex"); + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AiMcpEndpointOAuthCode(clientId, code), + OAUTH_FLOW_EXPIRY_IN_SECS, + JSON.stringify({ + codeChallenge, + codeChallengeMethod, + userId: permission.id, + endpointId, + expiry, + redirectUri, + userInfo: { + tokenId, + orgId: permission.orgId, + authMethod: permission.authMethod, + email: userInfo.email || "", + actorIp: userIp, + actorName: `${userInfo.firstName || ""} ${userInfo.lastName || ""}`.trim(), + actorUserAgent: userAgent + } + }) + ); + + const url = new URL(redirectUri); + url.searchParams.set("code", code); + if (oauthClient.state) url.searchParams.set("state", String(oauthClient.state)); + return url; + }; + + const oauthTokenExchange = async (dto: TOAuthTokenExchangeDTO) => { + const appCfg = getConfig(); + + if (dto.grant_type !== "authorization_code") { + throw new BadRequestError({ message: "Only authorization_code grant type is supported" }); + } + + const oauthClientCache = await keyStore.getItem(KeyStorePrefixes.AiMcpEndpointOAuthClient(dto.client_id)); + if (!oauthClientCache) { + throw new UnauthorizedError({ message: `MCP OAuth client with id ${dto.client_id} not found` }); + } + + const oauthAuthorizeSessionCache = await keyStore.getItem( + KeyStorePrefixes.AiMcpEndpointOAuthCode(dto.client_id, dto.code) + ); + if (!oauthAuthorizeSessionCache) { + throw new UnauthorizedError({ message: "MCP OAuth session not found" }); + } + + const oauthAuthorizeInfo = await OauthChallengeCodeSchema.parseAsync(JSON.parse(oauthAuthorizeSessionCache)); + const isInvalidRedirectUri = dto.redirect_uri !== oauthAuthorizeInfo.redirectUri; + if (isInvalidRedirectUri) throw new BadRequestError({ message: "Redirect URI mismatch" }); + + // Delete the code (one-time use) + await keyStore.deleteItem(KeyStorePrefixes.AiMcpEndpointOAuthCode(dto.client_id, dto.code)); + + // Verify PKCE challenge + const challenge = computePkceChallenge(dto.code_verifier); + if (challenge !== oauthAuthorizeInfo.codeChallenge) { + throw new BadRequestError({ message: "PKCE challenge mismatch" }); + } + + // Verify user session is still valid + const tokenSession = await authTokenService.getUserTokenSessionById( + oauthAuthorizeInfo.userInfo.tokenId, + oauthAuthorizeInfo.userId + ); + if (!tokenSession) throw new UnauthorizedError({ message: "User session not found" }); + + // Generate MCP access token + const accessToken = cryptoModule.jwt().sign( + { + authMethod: oauthAuthorizeInfo.userInfo.authMethod, + authTokenType: AuthTokenType.ACCESS_TOKEN, + userId: oauthAuthorizeInfo.userId, + tokenVersionId: tokenSession.id, + accessVersion: tokenSession.accessVersion, + organizationId: oauthAuthorizeInfo.userInfo.orgId, + isMfaVerified: true, + mcp: { + endpointId: oauthAuthorizeInfo.endpointId + } + }, + appCfg.AUTH_SECRET, + { expiresIn: oauthAuthorizeInfo.expiry } + ); + + return { + access_token: accessToken, + token_type: "Bearer", + expires_in: Math.floor(ms(oauthAuthorizeInfo.expiry) / 1000), + scope: "openid" + }; + }; + return { + interactWithMcp, createMcpEndpoint, listMcpEndpoints, getMcpEndpointById, @@ -233,6 +662,10 @@ export const aiMcpEndpointServiceFactory = ({ listEndpointTools, enableEndpointTool, disableEndpointTool, - bulkUpdateEndpointTools + bulkUpdateEndpointTools, + oauthRegisterClient, + oauthAuthorizeClient, + oauthFinalize, + oauthTokenExchange }; }; diff --git a/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-types.ts b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-types.ts index 79225f287f..2ecb2df515 100644 --- a/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-types.ts +++ b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-types.ts @@ -20,6 +20,10 @@ export type TGetAiMcpEndpointDTO = { endpointId: string; }; +export type TInteractWithMcpDTO = { + endpointId: string; +}; + export type TListAiMcpEndpointsDTO = { projectId: string; }; @@ -64,3 +68,56 @@ export type TEndpointToolConfig = { aiMcpServerToolId: string; isEnabled: boolean; }; + +// OAuth 2.0 Types +export type TOAuthRegisterClientDTO = { + endpointId: string; + redirect_uris: string[]; + token_endpoint_auth_method: string; + grant_types: string[]; + response_types: string[]; + client_name: string; + client_uri?: string; +}; + +export type TOAuthAuthorizeClientDTO = { + clientId: string; + state?: string; +}; + +export type TOAuthFinalizeDTO = { + endpointId: string; + clientId: string; + codeChallenge: string; + codeChallengeMethod: string; + redirectUri: string; + resource: string; + responseType: string; + projectId: string; + path?: string; + expiry: string; + tokenId: string; + userInfo: { + id: string; + email?: string | null; + firstName?: string | null; + lastName?: string | null; + }; + permission: { + type: string; + id: string; + orgId: string; + authMethod: string | null; + }; + userAgent: string; + userIp: string; +}; + +export type TOAuthTokenExchangeDTO = { + endpointId: string; + grant_type: "authorization_code"; + code: string; + redirect_uri: string; + code_verifier: string; + client_id: string; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index e5cf702499..11e89640fa 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -81,7 +81,11 @@ export const KeyStorePrefixes = { PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const, - AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const + AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const, + + // AI MCP Endpoint OAuth + AiMcpEndpointOAuthClient: (clientId: string) => `ai-mcp-endpoint-oauth-client:${clientId}` as const, + AiMcpEndpointOAuthCode: (clientId: string, code: string) => `ai-mcp-endpoint-oauth-code:${clientId}:${code}` as const }; export const KeyStoreTtls = { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 74257f4327..3eb704ca5b 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -15,7 +15,7 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service"; export type TAuthMode = | { - authMode: AuthMode.JWT; + authMode: AuthMode.JWT | AuthMode.MCP_JWT; actor: ActorType.USER; userId: string; tokenVersionId: string; // the session id of token used @@ -91,12 +91,21 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { const decodedToken = crypto.jwt().verify(authTokenValue, jwtSecret) as JwtPayload; switch (decodedToken.authTokenType) { - case AuthTokenType.ACCESS_TOKEN: + case AuthTokenType.ACCESS_TOKEN: { + if (decodedToken?.mcp) { + return { + authMode: AuthMode.MCP_JWT, + token: decodedToken as AuthModeJwtTokenPayload, + actor: ActorType.USER + } as const; + } + return { authMode: AuthMode.JWT, token: decodedToken as AuthModeJwtTokenPayload, actor: ActorType.USER } as const; + } case AuthTokenType.API_KEY: // throw new Error("API Key auth is no longer supported."); return { authMode: AuthMode.API_KEY, token: decodedToken, actor: ActorType.USER } as const; @@ -183,6 +192,27 @@ export const injectIdentity = fp( }; break; } + case AuthMode.MCP_JWT: { + const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } = + await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); + requestContext.set("orgId", orgId); + requestContext.set("orgName", orgName); + requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" }); + req.auth = { + authMode: AuthMode.MCP_JWT, + user, + userId: user.id, + tokenVersionId, + actor, + orgId, + rootOrgId, + parentOrgId, + authMethod: token.authMethod, + isMfaVerified: token.isMfaVerified, + token + }; + break; + } case AuthMode.IDENTITY_ACCESS_TOKEN: { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( token, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index fb678291bd..d728649e78 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -4,6 +4,10 @@ import { Knex } from "knex"; import { monitorEventLoopDelay } from "perf_hooks"; import { z } from "zod"; +import { + registerMcpEndpointAuthServerMetadataRouter, + registerMcpEndpointMetadataRouter +} from "@app/ee/routes/ai/mcp-endpoint-metadata-router"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; import { registerV1EERoutes } from "@app/ee/routes/v1"; import { registerV2EERoutes } from "@app/ee/routes/v2"; @@ -2461,7 +2465,12 @@ export const registerRoutes = async ( const aiMcpEndpointService = aiMcpEndpointServiceFactory({ aiMcpEndpointDAL, aiMcpEndpointServerDAL, - aiMcpEndpointServerToolDAL + aiMcpEndpointServerToolDAL, + aiMcpServerDAL, + aiMcpServerToolDAL, + kmsService, + keyStore, + authTokenService: tokenService }); const migrationService = externalMigrationServiceFactory({ @@ -2760,6 +2769,10 @@ export const registerRoutes = async ( // register special routes await server.register(registerCertificateEstRouter, { prefix: "/.well-known/est" }); + await server.register(registerMcpEndpointMetadataRouter, { prefix: "/mcp-endpoints" }); + await server.register(registerMcpEndpointAuthServerMetadataRouter, { + prefix: "/.well-known/oauth-authorization-server" + }); // register routes for v1 await server.register( diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index ef54ac0be7..301e7f1ddd 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -30,7 +30,8 @@ export enum AuthMode { SERVICE_TOKEN = "serviceToken", API_KEY = "apiKey", IDENTITY_ACCESS_TOKEN = "identityAccessToken", - SCIM_TOKEN = "scimToken" + SCIM_TOKEN = "scimToken", + MCP_JWT = "mcpJwt" } export enum ActorType { // would extend to AWS, Azure, ... @@ -57,6 +58,9 @@ export type AuthModeJwtTokenPayload = { organizationId?: string; isMfaVerified?: boolean; mfaMethod?: MfaMethod; + mcp?: { + endpointId: string; + }; }; export type AuthModeMfaJwtTokenPayload = { diff --git a/frontend/src/hooks/api/aiMcpEndpoints/index.ts b/frontend/src/hooks/api/aiMcpEndpoints/index.ts index 9086d8b60d..f7ad39276b 100644 --- a/frontend/src/hooks/api/aiMcpEndpoints/index.ts +++ b/frontend/src/hooks/api/aiMcpEndpoints/index.ts @@ -4,6 +4,7 @@ export { useDeleteAiMcpEndpoint, useDisableEndpointTool, useEnableEndpointTool, + useFinalizeMcpEndpointOAuth, useUpdateAiMcpEndpoint } from "./mutations"; export { diff --git a/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx b/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx index 6014830cc3..2c9318c778 100644 --- a/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx +++ b/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx @@ -11,6 +11,7 @@ import { TDeleteAiMcpEndpointDTO, TDisableEndpointToolDTO, TEnableEndpointToolDTO, + TFinalizeMcpEndpointOAuthDTO, TUpdateAiMcpEndpointDTO } from "./types"; @@ -124,3 +125,15 @@ export const useBulkUpdateEndpointTools = () => { } }); }; + +export const useFinalizeMcpEndpointOAuth = () => { + return useMutation({ + mutationFn: async ({ endpointId, ...body }: TFinalizeMcpEndpointOAuthDTO) => { + const { data } = await apiRequest.post<{ callbackUrl: string }>( + `/api/v1/ai/mcp-endpoints/${endpointId}/oauth/finalize`, + body + ); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/aiMcpEndpoints/types.ts b/frontend/src/hooks/api/aiMcpEndpoints/types.ts index b033c7a524..89ed8c23d5 100644 --- a/frontend/src/hooks/api/aiMcpEndpoints/types.ts +++ b/frontend/src/hooks/api/aiMcpEndpoints/types.ts @@ -68,3 +68,14 @@ export type TBulkUpdateEndpointToolsDTO = { isEnabled: boolean; }>; }; + +export type TFinalizeMcpEndpointOAuthDTO = { + endpointId: string; + response_type: string; + client_id: string; + code_challenge: string; + code_challenge_method: string; + redirect_uri: string; + resource: string; + expireIn: string; +}; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx index 036f2cf30f..f1104a39a4 100644 --- a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx @@ -1,4 +1,9 @@ -import { GenericFieldLabel } from "@app/components/v2"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; import { TAiMcpEndpointWithServerIds } from "@app/hooks/api"; type Props = { @@ -6,8 +11,18 @@ type Props = { }; export const MCPEndpointConnectionSection = ({ endpoint }: Props) => { - // Generate a mock endpoint URL based on the endpoint name - const endpointUrl = `mcp://${endpoint.name.toLowerCase().replace(/\s+/g, "-")}.infisical.com:8080`; + const [isCopied, setIsCopied] = useToggle(false); + const endpointUrl = `${window.location.origin}/api/v1/ai/mcp-endpoints/${endpoint.id}/connect`; + + const handleCopy = () => { + navigator.clipboard.writeText(endpointUrl); + setIsCopied.on(); + createNotification({ + text: "Endpoint URL copied to clipboard", + type: "info" + }); + setTimeout(() => setIsCopied.off(), 2000); + }; return (
@@ -15,10 +30,24 @@ export const MCPEndpointConnectionSection = ({ endpoint }: Props) => {

Connection

- - - {endpointUrl} - + +
+
+ + {endpointUrl} + +
+ + + + + +
diff --git a/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServerList.tsx b/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServerList.tsx index 4da55dc415..ca7dd0e20a 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServerList.tsx +++ b/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServerList.tsx @@ -39,10 +39,10 @@ export const MCPServerList = ({ onEditServer, onDeleteServer }: Props) => { - {isLoading && } + {isLoading && } {!isLoading && (!servers || servers.length === 0) && ( - + diff --git a/frontend/src/pages/organization/McpEndpointFinalizePage/McpEndpointFinalizePage.tsx b/frontend/src/pages/organization/McpEndpointFinalizePage/McpEndpointFinalizePage.tsx new file mode 100644 index 0000000000..2e8c499bab --- /dev/null +++ b/frontend/src/pages/organization/McpEndpointFinalizePage/McpEndpointFinalizePage.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; +import { Helmet } from "react-helmet"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle, faPlug, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Link, useSearch } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useFinalizeMcpEndpointOAuth, useGetAiMcpEndpointById } from "@app/hooks/api"; + +const FinalizeFormSchema = z.object({ + expireIn: z.string().min(1, "Expiration is required") +}); + +type FormData = z.infer; + +export const McpEndpointFinalizePage = () => { + const [isRedirecting, setIsRedirecting] = useState(false); + + const search = useSearch({ + from: "/_authenticate/organization/mcp-endpoint-finalize" + }); + + const { data: endpoint, isLoading: isEndpointLoading } = useGetAiMcpEndpointById({ + endpointId: search.endpointId + }); + + const { mutateAsync: finalizeOAuth, isPending } = useFinalizeMcpEndpointOAuth(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(FinalizeFormSchema), + defaultValues: { + expireIn: "30d" + } + }); + + const onSubmit = async ({ expireIn }: FormData) => { + try { + const { callbackUrl } = await finalizeOAuth({ + endpointId: search.endpointId, + response_type: search.response_type, + client_id: search.client_id, + code_challenge: search.code_challenge, + code_challenge_method: search.code_challenge_method, + redirect_uri: search.redirect_uri, + resource: search.resource, + expireIn + }); + + setIsRedirecting(true); + window.location.href = callbackUrl; + + // Fallback: try to close the window after 3 seconds if redirect doesn't navigate away + setTimeout(() => { + window.close(); + }, 3000); + } catch (error) { + console.error("Failed to authorize:", error); + createNotification({ + text: "Failed to authorize MCP endpoint access", + type: "error" + }); + } + }; + + if (isRedirecting) { + return ( +
+
+
+ +
+

Authorization Successful

+

+ + Redirecting back to the application... +

+
+
+ ); + } + + return ( +
+ + Authorize MCP Endpoint + + + +
+ + Infisical logo + + +
+
+ +
+

Authorize MCP Access

+

+ An external application is requesting access to your MCP endpoint +

+
+ + {isEndpointLoading && ( +
+
+
+
+ )} + {!isEndpointLoading && endpoint && ( +
+

Endpoint

+

{endpoint.name}

+ {endpoint.description && ( +

{endpoint.description}

+ )} +
+ {endpoint.connectedServers} server(s) + {endpoint.activeTools} tool(s) +
+
+ )} + {!isEndpointLoading && !endpoint && ( +
+

Endpoint not found

+
+ )} + +
+ ( + + + + )} + /> + +
+ + + + +
+ + +

+ By authorizing, you grant the external application access to interact with the tools + available through this MCP endpoint. +

+
+
+ ); +}; diff --git a/frontend/src/pages/organization/McpEndpointFinalizePage/route.tsx b/frontend/src/pages/organization/McpEndpointFinalizePage/route.tsx new file mode 100644 index 0000000000..b85cdc947b --- /dev/null +++ b/frontend/src/pages/organization/McpEndpointFinalizePage/route.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { McpEndpointFinalizePage } from "./McpEndpointFinalizePage"; + +const McpEndpointFinalizePageQuerySchema = z.object({ + response_type: z.string(), + client_id: z.string(), + code_challenge: z.string(), + code_challenge_method: z.string(), + redirect_uri: z.string(), + resource: z.string(), + state: z.string().optional(), + scope: z.string().optional(), + endpointId: z.string() +}); + +export const Route = createFileRoute("/_authenticate/organization/mcp-endpoint-finalize")({ + component: McpEndpointFinalizePage, + validateSearch: zodValidator(McpEndpointFinalizePageQuerySchema) +}); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 1b4f1bd971..5acc82c3f6 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as authLoginLdapPageRouteImport } from './pages/auth/LoginLdapPag import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route' import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route' import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route' +import { Route as organizationMcpEndpointFinalizePageRouteImport } from './pages/organization/McpEndpointFinalizePage/route' import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route' import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route' import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect' @@ -539,6 +540,13 @@ const organizationNoOrgPageRouteRoute = organizationNoOrgPageRouteImport.update( } as any, ) +const organizationMcpEndpointFinalizePageRouteRoute = + organizationMcpEndpointFinalizePageRouteImport.update({ + id: '/organization/mcp-endpoint-finalize', + path: '/organization/mcp-endpoint-finalize', + getParentRoute: () => middlewaresAuthenticateRoute, + } as any) + const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({ id: '/', path: '/', @@ -2455,6 +2463,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof authSignUpPageRouteImport parentRoute: typeof RestrictLoginSignupSignupImport } + '/_authenticate/organization/mcp-endpoint-finalize': { + id: '/_authenticate/organization/mcp-endpoint-finalize' + path: '/organization/mcp-endpoint-finalize' + fullPath: '/organization/mcp-endpoint-finalize' + preLoaderRoute: typeof organizationMcpEndpointFinalizePageRouteImport + parentRoute: typeof middlewaresAuthenticateImport + } '/_authenticate/organizations/none': { id: '/_authenticate/organizations/none' path: '/organizations/none' @@ -5219,6 +5234,7 @@ interface middlewaresAuthenticateRouteChildren { authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren + organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute } @@ -5229,6 +5245,8 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren middlewaresInjectOrgDetailsRouteWithChildren, AuthenticatePersonalSettingsRoute: AuthenticatePersonalSettingsRouteWithChildren, + organizationMcpEndpointFinalizePageRouteRoute: + organizationMcpEndpointFinalizePageRouteRoute, organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute, } @@ -5324,6 +5342,7 @@ export interface FileRoutesByFullPath { '/signup': typeof RestrictLoginSignupSignupRouteWithChildren '/login/': typeof authLoginPageRouteRoute '/signup/': typeof authSignUpPageRouteRoute + '/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute '/organizations/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute '/login/admin': typeof authAdminLoginPageRouteRoute @@ -5576,6 +5595,7 @@ export interface FileRoutesByTo { '/personal-settings': typeof userPersonalSettingsPageRouteRoute '/login': typeof authLoginPageRouteRoute '/signup': typeof authSignUpPageRouteRoute + '/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute '/organizations/none': typeof organizationNoOrgPageRouteRoute '/admin/signup': typeof adminSignUpPageRouteRoute '/login/admin': typeof authAdminLoginPageRouteRoute @@ -5820,6 +5840,7 @@ export interface FileRoutesById { '/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren '/_restrict-login-signup/login/': typeof authLoginPageRouteRoute '/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute + '/_authenticate/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute '/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute '/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute '/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute @@ -6086,6 +6107,7 @@ export interface FileRouteTypes { | '/signup' | '/login/' | '/signup/' + | '/organization/mcp-endpoint-finalize' | '/organizations/none' | '/admin/signup' | '/login/admin' @@ -6337,6 +6359,7 @@ export interface FileRouteTypes { | '/personal-settings' | '/login' | '/signup' + | '/organization/mcp-endpoint-finalize' | '/organizations/none' | '/admin/signup' | '/login/admin' @@ -6579,6 +6602,7 @@ export interface FileRouteTypes { | '/_restrict-login-signup/signup' | '/_restrict-login-signup/login/' | '/_restrict-login-signup/signup/' + | '/_authenticate/organization/mcp-endpoint-finalize' | '/_authenticate/organizations/none' | '/_restrict-login-signup/admin/signup' | '/_restrict-login-signup/login/admin' @@ -6890,6 +6914,7 @@ export const routeTree = rootRoute "/_authenticate/password-setup", "/_authenticate/_inject-org-details", "/_authenticate/personal-settings", + "/_authenticate/organization/mcp-endpoint-finalize", "/_authenticate/organizations/none" ] }, @@ -6976,6 +7001,10 @@ export const routeTree = rootRoute "filePath": "auth/SignUpPage/route.tsx", "parent": "/_restrict-login-signup/signup" }, + "/_authenticate/organization/mcp-endpoint-finalize": { + "filePath": "organization/McpEndpointFinalizePage/route.tsx", + "parent": "/_authenticate" + }, "/_authenticate/organizations/none": { "filePath": "organization/NoOrgPage/route.tsx", "parent": "/_authenticate" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index aff7304f9c..ab9241fba5 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -445,6 +445,7 @@ export const routes = rootRoute("root.tsx", [ layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")]) ]), route("/organizations/none", "organization/NoOrgPage/route.tsx"), + route("/organization/mcp-endpoint-finalize", "organization/McpEndpointFinalizePage/route.tsx"), middleware("inject-org-details.tsx", [ route("/organization/$", "redirects/organization-redirect.tsx"), route("/projects/$", "redirects/project-redirect.tsx"), diff --git a/nginx/default.dev.conf b/nginx/default.dev.conf index c92199a6ee..01f4a259db 100644 --- a/nginx/default.dev.conf +++ b/nginx/default.dev.conf @@ -11,7 +11,7 @@ server { location ~ ^/(api|secret-scanning/webhooks) { proxy_set_header X-Real-RIP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - + proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; @@ -24,7 +24,7 @@ server { location /runtime-ui-env.js { proxy_set_header X-Real-RIP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - + proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; @@ -39,7 +39,7 @@ server { proxy_set_header X-Real-RIP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - + proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; @@ -49,7 +49,7 @@ server { proxy_cookie_path / "/; HttpOnly; SameSite=strict"; } - location /.well-known/est { + location ~ /\.well-known { proxy_set_header X-Real-RIP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -70,13 +70,13 @@ server { location / { include /etc/nginx/mime.types; - + proxy_set_header X-Real-RIP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - + proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; - + proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";