diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index edfeab0230..c1780c17d5 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { Cluster, Redis } from "ioredis"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { TAiMcpEndpointServiceFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service"; import { TAiMcpServerServiceFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-service"; import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-types"; import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; @@ -363,6 +364,7 @@ declare module "fastify" { subOrganization: TSubOrgServiceFactory; pkiAlertV2: TPkiAlertV2ServiceFactory; aiMcpServer: TAiMcpServerServiceFactory; + aiMcpEndpoint: TAiMcpEndpointServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts b/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts new file mode 100644 index 0000000000..a747a78894 --- /dev/null +++ b/backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts @@ -0,0 +1,281 @@ +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 { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectId: z.string().trim().min(1), + name: z.string().trim().min(1).max(64), + description: z.string().trim().max(256).optional(), + serverIds: z.array(z.string().uuid()).default([]) + }), + response: { + 200: z.object({ + endpoint: AiMcpEndpointsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const endpoint = await server.services.aiMcpEndpoint.createMcpEndpoint({ + projectId: req.body.projectId, + name: req.body.name, + description: req.body.description, + serverIds: req.body.serverIds + }); + + return { endpoint }; + } + }); + + server.route({ + url: "/", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + endpoints: z.array( + AiMcpEndpointsSchema.extend({ + connectedServers: z.number(), + activeTools: z.number() + }) + ), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const endpoints = await server.services.aiMcpEndpoint.listMcpEndpoints({ + projectId: req.query.projectId + }); + + return { + endpoints, + totalCount: endpoints.length + }; + } + }); + + server.route({ + url: "/:endpointId", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + endpoint: AiMcpEndpointsSchema.extend({ + connectedServers: z.number(), + activeTools: z.number(), + serverIds: z.array(z.string()) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const endpoint = await server.services.aiMcpEndpoint.getMcpEndpointById({ + endpointId: req.params.endpointId + }); + + return { endpoint }; + } + }); + + server.route({ + url: "/:endpointId", + method: "PATCH", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + body: z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().trim().max(256).optional(), + serverIds: z.array(z.string().uuid()).optional() + }), + response: { + 200: z.object({ + endpoint: AiMcpEndpointsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const endpoint = await server.services.aiMcpEndpoint.updateMcpEndpoint({ + endpointId: req.params.endpointId, + ...req.body + }); + + return { endpoint }; + } + }); + + server.route({ + url: "/:endpointId", + method: "DELETE", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + endpoint: AiMcpEndpointsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const endpoint = await server.services.aiMcpEndpoint.deleteMcpEndpoint({ + endpointId: req.params.endpointId + }); + + return { endpoint }; + } + }); + + server.route({ + url: "/:endpointId/tools", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + tools: z.array(AiMcpEndpointServerToolsSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const tools = await server.services.aiMcpEndpoint.listEndpointTools({ + endpointId: req.params.endpointId + }); + + return { tools }; + } + }); + + server.route({ + url: "/:endpointId/tools/:serverToolId", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1), + serverToolId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + tool: AiMcpEndpointServerToolsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const tool = await server.services.aiMcpEndpoint.enableEndpointTool({ + endpointId: req.params.endpointId, + serverToolId: req.params.serverToolId + }); + + return { tool }; + } + }); + + server.route({ + url: "/:endpointId/tools/:serverToolId", + method: "DELETE", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1), + serverToolId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.aiMcpEndpoint.disableEndpointTool({ + endpointId: req.params.endpointId, + serverToolId: req.params.serverToolId + }); + + return { message: "Tool disabled" }; + } + }); + + server.route({ + url: "/:endpointId/tools/bulk", + method: "PATCH", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + endpointId: z.string().trim().min(1) + }), + body: z.object({ + tools: z.array( + z.object({ + serverToolId: z.string().uuid(), + isEnabled: z.boolean() + }) + ) + }), + response: { + 200: z.object({ + tools: z.array(AiMcpEndpointServerToolsSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const tools = await server.services.aiMcpEndpoint.bulkUpdateEndpointTools({ + endpointId: req.params.endpointId, + tools: req.body.tools + }); + + return { tools }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 4e7925e457..122c0129ee 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -2,6 +2,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAiMcpEndpointRouter } from "./ai-mcp-endpoint-router"; import { registerAiMcpServerRouter } from "./ai-mcp-server-router"; import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; @@ -217,6 +218,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (aiRouter) => { await aiRouter.register(registerAiMcpServerRouter, { prefix: "/mcp-servers" }); + await aiRouter.register(registerAiMcpEndpointRouter, { prefix: "/mcp-endpoints" }); }, { prefix: "/ai" } ); 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 new file mode 100644 index 0000000000..b71359a23b --- /dev/null +++ b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts @@ -0,0 +1,238 @@ +import { NotFoundError } from "@app/lib/errors"; + +import { TAiMcpEndpointDALFactory } from "./ai-mcp-endpoint-dal"; +import { TAiMcpEndpointServerDALFactory } from "./ai-mcp-endpoint-server-dal"; +import { TAiMcpEndpointServerToolDALFactory } from "./ai-mcp-endpoint-server-tool-dal"; +import { + TAiMcpEndpointWithServers, + TBulkUpdateEndpointToolsDTO, + TCreateAiMcpEndpointDTO, + TDeleteAiMcpEndpointDTO, + TDisableEndpointToolDTO, + TEnableEndpointToolDTO, + TGetAiMcpEndpointDTO, + TListAiMcpEndpointsDTO, + TListEndpointToolsDTO, + TUpdateAiMcpEndpointDTO +} from "./ai-mcp-endpoint-types"; + +type TAiMcpEndpointServiceFactoryDep = { + aiMcpEndpointDAL: TAiMcpEndpointDALFactory; + aiMcpEndpointServerDAL: TAiMcpEndpointServerDALFactory; + aiMcpEndpointServerToolDAL: TAiMcpEndpointServerToolDALFactory; +}; + +export type TAiMcpEndpointServiceFactory = ReturnType; + +export const aiMcpEndpointServiceFactory = ({ + aiMcpEndpointDAL, + aiMcpEndpointServerDAL, + aiMcpEndpointServerToolDAL +}: TAiMcpEndpointServiceFactoryDep) => { + const createMcpEndpoint = async ({ projectId, name, description, serverIds }: TCreateAiMcpEndpointDTO) => { + const endpoint = await aiMcpEndpointDAL.create({ + projectId, + name, + description, + status: "active" + }); + + // Connect servers if provided + if (serverIds && serverIds.length > 0) { + await aiMcpEndpointServerDAL.insertMany( + serverIds.map((serverId) => ({ + aiMcpEndpointId: endpoint.id, + aiMcpServerId: serverId + })) + ); + } + + return endpoint; + }; + + const listMcpEndpoints = async ({ projectId }: TListAiMcpEndpointsDTO): Promise => { + const endpoints = await aiMcpEndpointDAL.find({ projectId }); + + // Get connected servers count and tools count for each endpoint + const endpointsWithStats = await Promise.all( + endpoints.map(async (endpoint) => { + const connectedServers = await aiMcpEndpointServerDAL.find({ aiMcpEndpointId: endpoint.id }); + const tools = await aiMcpEndpointServerToolDAL.find({ aiMcpEndpointId: endpoint.id }); + + return { + ...endpoint, + connectedServers: connectedServers.length, + activeTools: tools.length + }; + }) + ); + + return endpointsWithStats; + }; + + const getMcpEndpointById = async ({ endpointId }: TGetAiMcpEndpointDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const connectedServers = await aiMcpEndpointServerDAL.find({ aiMcpEndpointId: endpointId }); + const tools = await aiMcpEndpointServerToolDAL.find({ aiMcpEndpointId: endpointId }); + + return { + ...endpoint, + connectedServers: connectedServers.length, + activeTools: tools.length, + serverIds: connectedServers.map((s) => s.aiMcpServerId) + }; + }; + + const updateMcpEndpoint = async ({ endpointId, name, description, serverIds }: TUpdateAiMcpEndpointDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const updateData: { name?: string; description?: string } = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + + const updatedEndpoint = await aiMcpEndpointDAL.updateById(endpointId, updateData); + + // Update server connections if provided + if (serverIds !== undefined) { + // Delete existing connections + await aiMcpEndpointServerDAL.delete({ aiMcpEndpointId: endpointId }); + + // Add new connections + if (serverIds.length > 0) { + await aiMcpEndpointServerDAL.insertMany( + serverIds.map((serverId) => ({ + aiMcpEndpointId: endpointId, + aiMcpServerId: serverId + })) + ); + } + } + + return updatedEndpoint; + }; + + const deleteMcpEndpoint = async ({ endpointId }: TDeleteAiMcpEndpointDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + // Delete endpoint + await aiMcpEndpointDAL.deleteById(endpointId); + + return endpoint; + }; + + const listEndpointTools = async ({ endpointId }: TListEndpointToolsDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const toolConfigs = await aiMcpEndpointServerToolDAL.find({ aiMcpEndpointId: endpointId }); + return toolConfigs; + }; + + const enableEndpointTool = async ({ endpointId, serverToolId }: TEnableEndpointToolDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const existingConfig = await aiMcpEndpointServerToolDAL.findOne({ + aiMcpEndpointId: endpointId, + aiMcpServerToolId: serverToolId + }); + + if (existingConfig) { + return existingConfig; + } + + return aiMcpEndpointServerToolDAL.create({ + aiMcpEndpointId: endpointId, + aiMcpServerToolId: serverToolId, + isEnabled: true + }); + }; + + const disableEndpointTool = async ({ endpointId, serverToolId }: TDisableEndpointToolDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + const existingConfig = await aiMcpEndpointServerToolDAL.findOne({ + aiMcpEndpointId: endpointId, + aiMcpServerToolId: serverToolId + }); + + if (existingConfig) { + await aiMcpEndpointServerToolDAL.deleteById(existingConfig.id); + } + }; + + const bulkUpdateEndpointTools = async ({ endpointId, tools }: TBulkUpdateEndpointToolsDTO) => { + const endpoint = await aiMcpEndpointDAL.findById(endpointId); + if (!endpoint) { + throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` }); + } + + // Separate tools to enable and disable + const toEnable = tools.filter((t) => t.isEnabled); + const toDisable = tools.filter((t) => !t.isEnabled); + + // Delete disabled tools + if (toDisable.length > 0) { + await Promise.all( + toDisable.map(async ({ serverToolId }) => { + const existing = await aiMcpEndpointServerToolDAL.findOne({ + aiMcpEndpointId: endpointId, + aiMcpServerToolId: serverToolId + }); + if (existing) { + await aiMcpEndpointServerToolDAL.deleteById(existing.id); + } + }) + ); + } + + // Create enabled tools (if not already existing) + const results = await Promise.all( + toEnable.map(async ({ serverToolId }) => { + const existing = await aiMcpEndpointServerToolDAL.findOne({ + aiMcpEndpointId: endpointId, + aiMcpServerToolId: serverToolId + }); + if (existing) { + return existing; + } + return aiMcpEndpointServerToolDAL.create({ + aiMcpEndpointId: endpointId, + aiMcpServerToolId: serverToolId, + isEnabled: true + }); + }) + ); + + return results; + }; + + return { + createMcpEndpoint, + listMcpEndpoints, + getMcpEndpointById, + updateMcpEndpoint, + deleteMcpEndpoint, + listEndpointTools, + enableEndpointTool, + disableEndpointTool, + bulkUpdateEndpointTools + }; +}; 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 new file mode 100644 index 0000000000..79225f287f --- /dev/null +++ b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-types.ts @@ -0,0 +1,66 @@ +export type TCreateAiMcpEndpointDTO = { + projectId: string; + name: string; + description?: string; + serverIds?: string[]; +}; + +export type TUpdateAiMcpEndpointDTO = { + endpointId: string; + name?: string; + description?: string; + serverIds?: string[]; +}; + +export type TDeleteAiMcpEndpointDTO = { + endpointId: string; +}; + +export type TGetAiMcpEndpointDTO = { + endpointId: string; +}; + +export type TListAiMcpEndpointsDTO = { + projectId: string; +}; + +export type TAiMcpEndpointWithServers = { + id: string; + name: string; + description?: string | null; + status?: string | null; + projectId: string; + createdAt: Date; + updatedAt: Date; + connectedServers: number; + activeTools: number; +}; + +export type TListEndpointToolsDTO = { + endpointId: string; +}; + +export type TEnableEndpointToolDTO = { + endpointId: string; + serverToolId: string; +}; + +export type TDisableEndpointToolDTO = { + endpointId: string; + serverToolId: string; +}; + +export type TBulkUpdateEndpointToolsDTO = { + endpointId: string; + tools: Array<{ + serverToolId: string; + isEnabled: boolean; + }>; +}; + +export type TEndpointToolConfig = { + id: string; + aiMcpEndpointId: string; + aiMcpServerToolId: string; + isEnabled: boolean; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 345d7df3a1..fb678291bd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -17,6 +17,10 @@ import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-appr import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; +import { aiMcpEndpointDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-dal"; +import { aiMcpEndpointServerDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-server-dal"; +import { aiMcpEndpointServerToolDALFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-server-tool-dal"; +import { aiMcpEndpointServiceFactory } from "@app/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service"; import { aiMcpServerDALFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-dal"; import { aiMcpServerServiceFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-service"; import { aiMcpServerToolDALFactory } from "@app/ee/services/ai-mcp-server/ai-mcp-server-tool-dal"; @@ -2402,6 +2406,9 @@ export const registerRoutes = async ( const pamSessionDAL = pamSessionDALFactory(db); const aiMcpServerDAL = aiMcpServerDALFactory(db); const aiMcpServerToolDAL = aiMcpServerToolDALFactory(db); + const aiMcpEndpointDAL = aiMcpEndpointDALFactory(db); + const aiMcpEndpointServerDAL = aiMcpEndpointServerDALFactory(db); + const aiMcpEndpointServerToolDAL = aiMcpEndpointServerToolDALFactory(db); const pamFolderService = pamFolderServiceFactory({ pamFolderDAL, @@ -2451,6 +2458,12 @@ export const registerRoutes = async ( keyStore }); + const aiMcpEndpointService = aiMcpEndpointServiceFactory({ + aiMcpEndpointDAL, + aiMcpEndpointServerDAL, + aiMcpEndpointServerToolDAL + }); + const migrationService = externalMigrationServiceFactory({ externalMigrationQueue, userDAL, @@ -2643,7 +2656,8 @@ export const registerRoutes = async ( identityProject: identityProjectService, convertor: convertorService, pkiAlertV2: pkiAlertV2Service, - aiMcpServer: aiMcpServerService + aiMcpServer: aiMcpServerService, + aiMcpEndpoint: aiMcpEndpointService }); const cronJobs: CronJob[] = []; diff --git a/frontend/src/hooks/api/aiMcpEndpoints/index.ts b/frontend/src/hooks/api/aiMcpEndpoints/index.ts new file mode 100644 index 0000000000..9086d8b60d --- /dev/null +++ b/frontend/src/hooks/api/aiMcpEndpoints/index.ts @@ -0,0 +1,15 @@ +export { + useBulkUpdateEndpointTools, + useCreateAiMcpEndpoint, + useDeleteAiMcpEndpoint, + useDisableEndpointTool, + useEnableEndpointTool, + useUpdateAiMcpEndpoint +} from "./mutations"; +export { + aiMcpEndpointKeys, + useGetAiMcpEndpointById, + useListAiMcpEndpoints, + useListEndpointTools +} from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx b/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx new file mode 100644 index 0000000000..6014830cc3 --- /dev/null +++ b/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx @@ -0,0 +1,126 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { aiMcpEndpointKeys } from "./queries"; +import { + TAiMcpEndpoint, + TAiMcpEndpointToolConfig, + TBulkUpdateEndpointToolsDTO, + TCreateAiMcpEndpointDTO, + TDeleteAiMcpEndpointDTO, + TDisableEndpointToolDTO, + TEnableEndpointToolDTO, + TUpdateAiMcpEndpointDTO +} from "./types"; + +export const useCreateAiMcpEndpoint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: TCreateAiMcpEndpointDTO) => { + const { data } = await apiRequest.post<{ endpoint: TAiMcpEndpoint }>( + "/api/v1/ai/mcp-endpoints", + dto + ); + return data.endpoint; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.list(variables.projectId) + }); + } + }); +}; + +export const useUpdateAiMcpEndpoint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ endpointId, ...dto }: TUpdateAiMcpEndpointDTO) => { + const { data } = await apiRequest.patch<{ endpoint: TAiMcpEndpoint }>( + `/api/v1/ai/mcp-endpoints/${endpointId}`, + dto + ); + return data.endpoint; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.list(data.projectId) + }); + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.byId(data.id) + }); + } + }); +}; + +export const useDeleteAiMcpEndpoint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ endpointId }: TDeleteAiMcpEndpointDTO) => { + const { data } = await apiRequest.delete<{ endpoint: TAiMcpEndpoint }>( + `/api/v1/ai/mcp-endpoints/${endpointId}` + ); + return data.endpoint; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.list(data.projectId) + }); + } + }); +}; + +export const useEnableEndpointTool = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ endpointId, serverToolId }: TEnableEndpointToolDTO) => { + const { data } = await apiRequest.post<{ tool: TAiMcpEndpointToolConfig }>( + `/api/v1/ai/mcp-endpoints/${endpointId}/tools/${serverToolId}` + ); + return data.tool; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.tools(variables.endpointId) + }); + } + }); +}; + +export const useDisableEndpointTool = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ endpointId, serverToolId }: TDisableEndpointToolDTO) => { + await apiRequest.delete(`/api/v1/ai/mcp-endpoints/${endpointId}/tools/${serverToolId}`); + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.tools(variables.endpointId) + }); + } + }); +}; + +export const useBulkUpdateEndpointTools = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ endpointId, tools }: TBulkUpdateEndpointToolsDTO) => { + const { data } = await apiRequest.patch<{ tools: TAiMcpEndpointToolConfig[] }>( + `/api/v1/ai/mcp-endpoints/${endpointId}/tools/bulk`, + { tools } + ); + return data.tools; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: aiMcpEndpointKeys.tools(variables.endpointId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx b/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx new file mode 100644 index 0000000000..a0cc35e6c7 --- /dev/null +++ b/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TAiMcpEndpoint, + TAiMcpEndpointToolConfig, + TAiMcpEndpointWithServerIds, + TListAiMcpEndpointsDTO +} from "./types"; + +export const aiMcpEndpointKeys = { + all: ["aiMcpEndpoints"] as const, + list: (projectId: string) => [...aiMcpEndpointKeys.all, "list", projectId] as const, + byId: (endpointId: string) => [...aiMcpEndpointKeys.all, "byId", endpointId] as const, + tools: (endpointId: string) => [...aiMcpEndpointKeys.all, "tools", endpointId] as const +}; + +export const useListAiMcpEndpoints = ({ projectId }: TListAiMcpEndpointsDTO) => { + return useQuery({ + queryKey: aiMcpEndpointKeys.list(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + endpoints: TAiMcpEndpoint[]; + totalCount: number; + }>("/api/v1/ai/mcp-endpoints", { + params: { projectId } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetAiMcpEndpointById = ({ endpointId }: { endpointId: string }) => { + return useQuery({ + queryKey: aiMcpEndpointKeys.byId(endpointId), + queryFn: async () => { + const { data } = await apiRequest.get<{ endpoint: TAiMcpEndpointWithServerIds }>( + `/api/v1/ai/mcp-endpoints/${endpointId}` + ); + return data.endpoint; + }, + enabled: Boolean(endpointId) + }); +}; + +export const useListEndpointTools = ({ endpointId }: { endpointId: string }) => { + return useQuery({ + queryKey: aiMcpEndpointKeys.tools(endpointId), + queryFn: async () => { + const { data } = await apiRequest.get<{ tools: TAiMcpEndpointToolConfig[] }>( + `/api/v1/ai/mcp-endpoints/${endpointId}/tools` + ); + return data.tools; + }, + enabled: Boolean(endpointId) + }); +}; diff --git a/frontend/src/hooks/api/aiMcpEndpoints/types.ts b/frontend/src/hooks/api/aiMcpEndpoints/types.ts new file mode 100644 index 0000000000..b033c7a524 --- /dev/null +++ b/frontend/src/hooks/api/aiMcpEndpoints/types.ts @@ -0,0 +1,70 @@ +export type TAiMcpEndpoint = { + id: string; + name: string; + description: string | null; + status: string | null; + projectId: string; + createdAt: string; + updatedAt: string; + connectedServers: number; + activeTools: number; +}; + +export type TAiMcpEndpointWithServerIds = TAiMcpEndpoint & { + serverIds: string[]; +}; + +export type TCreateAiMcpEndpointDTO = { + projectId: string; + name: string; + description?: string; + serverIds?: string[]; +}; + +export type TUpdateAiMcpEndpointDTO = { + endpointId: string; + name?: string; + description?: string; + serverIds?: string[]; +}; + +export type TDeleteAiMcpEndpointDTO = { + endpointId: string; +}; + +export type TListAiMcpEndpointsDTO = { + projectId: string; +}; + +export type TGetAiMcpEndpointDTO = { + endpointId: string; +}; + +export type TAiMcpEndpointToolConfig = { + id: string; + aiMcpEndpointId: string; + aiMcpServerToolId: string; + isEnabled: boolean; +}; + +export type TListEndpointToolsDTO = { + endpointId: string; +}; + +export type TEnableEndpointToolDTO = { + endpointId: string; + serverToolId: string; +}; + +export type TDisableEndpointToolDTO = { + endpointId: string; + serverToolId: string; +}; + +export type TBulkUpdateEndpointToolsDTO = { + endpointId: string; + tools: Array<{ + serverToolId: string; + isEnabled: boolean; + }>; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index e0f541590e..cbb4bd07b0 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,5 +1,6 @@ export * from "./accessApproval"; export * from "./admin"; +export * from "./aiMcpEndpoints"; export * from "./aiMcpServers"; export * from "./apiKeys"; export * from "./assumePrivileges"; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/MCPEndpointDetailPage.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/MCPEndpointDetailPage.tsx new file mode 100644 index 0000000000..94f0470361 --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/MCPEndpointDetailPage.tsx @@ -0,0 +1,204 @@ +import { useState } from "react"; +import { Helmet } from "react-helmet"; +import { + faBan, + faChevronLeft, + faEllipsisV, + faNetworkWired +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useParams } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + ContentLoader, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState +} from "@app/components/v2"; +import { useDeleteAiMcpEndpoint, useGetAiMcpEndpointById } from "@app/hooks/api"; + +import { EditMCPEndpointModal } from "../MCPPage/components/MCPEndpointsTab/EditMCPEndpointModal"; +import { + MCPEndpointConnectedServersSection, + MCPEndpointConnectionSection, + MCPEndpointDetailsSection, + MCPEndpointToolSelectionSection +} from "./components"; + +const MCPEndpointStatusBadge = ({ status }: { status: string | null }) => { + const statusConfig: Record = { + active: { color: "bg-emerald-500", label: "Active" }, + inactive: { color: "bg-red-500", label: "Inactive" } + }; + + const config = statusConfig[status || "inactive"] || statusConfig.inactive; + + return ( +
+
+ {config.label} +
+ ); +}; + +const PageContent = () => { + const navigate = useNavigate(); + const params = useParams({ + strict: false + }) as { endpointId?: string; projectId?: string; orgId?: string }; + + const { endpointId, projectId, orgId } = params; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const { data: mcpEndpoint, isPending } = useGetAiMcpEndpointById({ + endpointId: endpointId! + }); + + const deleteEndpoint = useDeleteAiMcpEndpoint(); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!mcpEndpoint) { + return ( +
+ +
+ ); + } + + const handleBack = () => { + navigate({ + to: "/organizations/$orgId/projects/ai/$projectId/overview", + params: { orgId: orgId!, projectId: projectId! } + }); + }; + + const handleDeleteConfirm = async () => { + if (!mcpEndpoint) return; + + try { + await deleteEndpoint.mutateAsync({ endpointId: mcpEndpoint.id }); + createNotification({ + text: `MCP endpoint "${mcpEndpoint.name}" deleted successfully`, + type: "success" + }); + handleBack(); + } catch (error) { + console.error("Failed to delete MCP endpoint:", error); + createNotification({ + text: "Failed to delete MCP endpoint", + type: "error" + }); + } + }; + + return ( +
+ + +
+
+
+ +
+
+

{mcpEndpoint.name}

+

MCP Endpoint

+
+
+
+ + + + + + + setIsEditModalOpen(true)}> + Edit Endpoint + + setIsDeleteModalOpen(true)} className="text-red-500"> + Delete Endpoint + + + +
+
+ +
+ {/* Left Column - Details, Connection, Connected Servers */} +
+ setIsEditModalOpen(true)} + /> + + +
+ + {/* Right Column - Tool Selection */} +
+ +
+
+ + + + setIsDeleteModalOpen(isOpen)} + deleteKey={mcpEndpoint.name} + onDeleteApproved={handleDeleteConfirm} + /> +
+ ); +}; + +export const MCPEndpointDetailPage = () => { + return ( + <> + + MCP Endpoint | Infisical + + + + + ); +}; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectedServersSection.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectedServersSection.tsx new file mode 100644 index 0000000000..e3338513da --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectedServersSection.tsx @@ -0,0 +1,49 @@ +import { faServer } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useListAiMcpServers } from "@app/hooks/api"; + +type Props = { + projectId: string; + serverIds: string[]; +}; + +export const MCPEndpointConnectedServersSection = ({ projectId, serverIds }: Props) => { + const { data: serversData } = useListAiMcpServers({ projectId }); + + const connectedServers = + serversData?.servers.filter((server) => serverIds.includes(server.id)) || []; + + return ( +
+
+

Connected MCP Servers

+
+
+ {connectedServers.length === 0 ? ( +

No servers connected

+ ) : ( + connectedServers.map((server) => ( +
+ +
+

{server.name}

+ {server.description && ( +

{server.description}

+ )} +
+
+
+ )) + )} +
+
+ ); +}; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx new file mode 100644 index 0000000000..036f2cf30f --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx @@ -0,0 +1,26 @@ +import { GenericFieldLabel } from "@app/components/v2"; +import { TAiMcpEndpointWithServerIds } from "@app/hooks/api"; + +type Props = { + endpoint: TAiMcpEndpointWithServerIds; +}; + +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`; + + return ( +
+
+

Connection

+
+
+ + + {endpointUrl} + + +
+
+ ); +}; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointDetailsSection.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointDetailsSection.tsx new file mode 100644 index 0000000000..c386da2002 --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointDetailsSection.tsx @@ -0,0 +1,60 @@ +import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { GenericFieldLabel, IconButton } from "@app/components/v2"; +import { TAiMcpEndpointWithServerIds } from "@app/hooks/api"; + +type Props = { + endpoint: TAiMcpEndpointWithServerIds; + onEdit: VoidFunction; +}; + +const getStatusLabel = (status: string | null) => { + const labels: Record = { + active: "Active", + inactive: "Inactive" + }; + return labels[status || "inactive"] || "Unknown"; +}; + +const getStatusColor = (status: string | null) => { + const colors: Record = { + active: "bg-emerald-500", + inactive: "bg-red-500" + }; + return colors[status || "inactive"] || "bg-red-500"; +}; + +export const MCPEndpointDetailsSection = ({ endpoint, onEdit }: Props) => { + return ( +
+
+

Details

+ + + +
+
+ {endpoint.name} + + {endpoint.description || No description} + + +
+
+ {getStatusLabel(endpoint.status)} +
+ + + {format(new Date(endpoint.createdAt), "yyyy-MM-dd, hh:mm aaa")} + +
+
+ ); +}; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointToolSelectionSection.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointToolSelectionSection.tsx new file mode 100644 index 0000000000..348e9dd092 --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointToolSelectionSection.tsx @@ -0,0 +1,221 @@ +import { useMemo, useState } from "react"; +import { + faChevronDown, + faChevronUp, + faInfoCircle, + faMagnifyingGlass, + faServer +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Input, Switch, Tooltip } from "@app/components/v2"; +import { + TAiMcpEndpointToolConfig, + useDisableEndpointTool, + useEnableEndpointTool, + useListAiMcpServers, + useListAiMcpServerTools, + useListEndpointTools +} from "@app/hooks/api"; + +type Props = { + endpointId: string; + projectId: string; + serverIds: string[]; +}; + +type ServerToolsSectionProps = { + serverId: string; + serverName: string; + serverStatus: string; + searchQuery: string; + toolConfigs: TAiMcpEndpointToolConfig[]; + onToolToggle: (serverToolId: string, isEnabled: boolean) => void; + isUpdating: boolean; +}; + +const ServerToolsSection = ({ + serverId, + serverName, + serverStatus, + searchQuery, + toolConfigs, + onToolToggle, + isUpdating +}: ServerToolsSectionProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const { data: toolsData } = useListAiMcpServerTools({ serverId }); + const tools = toolsData?.tools || []; + + // Create a set of enabled tool IDs for quick lookup + // Presence in the list = enabled, absence = disabled + const enabledToolIds = useMemo(() => { + return new Set(toolConfigs.map((config) => config.aiMcpServerToolId)); + }, [toolConfigs]); + + // Filter tools based on search query + const filteredTools = tools.filter( + (tool) => + tool.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + // Count enabled tools + const enabledCount = tools.filter((tool) => enabledToolIds.has(tool.id)).length; + const totalCount = tools.length; + + // Check if tool is enabled + const isToolEnabled = (toolId: string) => { + return enabledToolIds.has(toolId); + }; + + if (filteredTools.length === 0 && searchQuery) { + return null; // Hide section if no tools match search + } + + return ( +
+ + + {isExpanded && filteredTools.length > 0 && ( +
+
+ Tool Name + Enabled +
+
+ {filteredTools.map((tool) => ( +
+
+ {tool.name} + {tool.description && ( + + + + )} +
+ onToolToggle(tool.id, checked)} + isDisabled={isUpdating} + /> +
+ ))} +
+
+ )} + + {isExpanded && tools.length === 0 && ( +
+ No tools available from this server +
+ )} +
+ ); +}; + +export const MCPEndpointToolSelectionSection = ({ endpointId, projectId, serverIds }: Props) => { + const [searchQuery, setSearchQuery] = useState(""); + + const { data: serversData } = useListAiMcpServers({ projectId }); + const { data: toolConfigs = [] } = useListEndpointTools({ endpointId }); + const enableTool = useEnableEndpointTool(); + const disableTool = useDisableEndpointTool(); + + const connectedServers = + serversData?.servers.filter((server) => serverIds.includes(server.id)) || []; + + const handleToolToggle = async (serverToolId: string, isEnabled: boolean) => { + try { + if (isEnabled) { + await enableTool.mutateAsync({ endpointId, serverToolId }); + } else { + await disableTool.mutateAsync({ endpointId, serverToolId }); + } + } catch (error) { + console.error("Failed to update tool:", error); + createNotification({ + text: "Failed to update tool configuration", + type: "error" + }); + } + }; + + return ( +
+
+

Tool Selection

+

+ Control which tools from connected MCP servers are available through this endpoint +

+
+ +
+ + setSearchQuery(e.target.value)} + placeholder="Search tools..." + className="pl-10" + /> +
+ +
+ {connectedServers.length === 0 ? ( +
+ +

No MCP servers connected to this endpoint

+

+ Connect servers to configure available tools +

+
+ ) : ( + connectedServers.map((server) => ( + + )) + )} +
+
+ ); +}; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/index.ts b/frontend/src/pages/ai/MCPEndpointDetailPage/components/index.ts new file mode 100644 index 0000000000..477f0e8c9e --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/index.ts @@ -0,0 +1,4 @@ +export { MCPEndpointConnectedServersSection } from "./MCPEndpointConnectedServersSection"; +export { MCPEndpointConnectionSection } from "./MCPEndpointConnectionSection"; +export { MCPEndpointDetailsSection } from "./MCPEndpointDetailsSection"; +export { MCPEndpointToolSelectionSection } from "./MCPEndpointToolSelectionSection"; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/index.ts b/frontend/src/pages/ai/MCPEndpointDetailPage/index.ts new file mode 100644 index 0000000000..b0db7aec80 --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/index.ts @@ -0,0 +1 @@ +export { MCPEndpointDetailPage } from "./MCPEndpointDetailPage"; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/route.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/route.tsx new file mode 100644 index 0000000000..53dee2e5de --- /dev/null +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/route.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, linkOptions } from "@tanstack/react-router"; + +import { MCPEndpointDetailPage } from "./MCPEndpointDetailPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId" +)({ + component: MCPEndpointDetailPage, + beforeLoad: ({ context, params }) => { + return { + breadcrumbs: [ + ...context.breadcrumbs, + { + label: "MCP Endpoints", + link: linkOptions({ + to: "/organizations/$orgId/projects/ai/$projectId/overview", + params: { orgId: params.orgId, projectId: params.projectId } + }) + }, + { + label: "Endpoint Details" + } + ] + }; + } +}); diff --git a/frontend/src/pages/ai/MCPPage/MCPPage.tsx b/frontend/src/pages/ai/MCPPage/MCPPage.tsx index b4ae3b7ad6..dd98739a61 100644 --- a/frontend/src/pages/ai/MCPPage/MCPPage.tsx +++ b/frontend/src/pages/ai/MCPPage/MCPPage.tsx @@ -6,6 +6,7 @@ import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/co import { useProject } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; +import { MCPEndpointsTab } from "./components/MCPEndpointsTab"; import { MCPServersTab } from "./components/MCPServersTab"; enum TabSections { @@ -48,7 +49,7 @@ export const MCPPage = () => { -
MCP Endpoints - Coming soon
+
diff --git a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts new file mode 100644 index 0000000000..c0a27cff42 --- /dev/null +++ b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const AddMCPEndpointFormSchema = z.object({ + name: z.string().trim().min(1, "Name is required").max(64, "Name cannot exceed 64 characters"), + description: z.string().trim().max(256, "Description cannot exceed 256 characters").optional(), + serverIds: z.array(z.string()).default([]) +}); + +export type TAddMCPEndpointForm = z.infer; diff --git a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointModal.tsx b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointModal.tsx new file mode 100644 index 0000000000..fd807f7138 --- /dev/null +++ b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointModal.tsx @@ -0,0 +1,226 @@ +import { Controller, useForm } from "react-hook-form"; +import { faServer } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + TextArea +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useCreateAiMcpEndpoint, useListAiMcpServers } from "@app/hooks/api"; + +import { AddMCPEndpointFormSchema, TAddMCPEndpointForm } from "./AddMCPEndpointForm.schema"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const AddMCPEndpointModal = ({ isOpen, onOpenChange }: Props) => { + const { currentProject } = useProject(); + const createEndpoint = useCreateAiMcpEndpoint(); + + // Fetch available MCP servers for selection + const { data: serversData, isLoading: isLoadingServers } = useListAiMcpServers({ + projectId: currentProject?.id || "" + }); + + const servers = serversData?.servers || []; + + const { + control, + handleSubmit, + reset, + watch, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(AddMCPEndpointFormSchema), + defaultValues: { + name: "", + description: "", + serverIds: [] + } + }); + + const selectedServerIds = watch("serverIds"); + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + const handleServerToggle = (serverId: string) => { + const current = selectedServerIds || []; + if (current.includes(serverId)) { + setValue( + "serverIds", + current.filter((id) => id !== serverId) + ); + } else { + setValue("serverIds", [...current, serverId]); + } + }; + + const onSubmit = async (data: TAddMCPEndpointForm) => { + if (!currentProject?.id) { + createNotification({ + text: "No project selected", + type: "error" + }); + return; + } + + try { + await createEndpoint.mutateAsync({ + projectId: currentProject.id, + name: data.name, + description: data.description || undefined, + serverIds: data.serverIds + }); + + createNotification({ + text: `Successfully created MCP endpoint "${data.name}"`, + type: "success" + }); + + handleClose(); + } catch (error) { + console.error("Failed to create MCP endpoint:", error); + createNotification({ + text: "Failed to create MCP endpoint", + type: "error" + }); + } + }; + + return ( + + +
+

+ Create an endpoint to aggregate multiple MCP servers into a single entrypoint. +

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