feat: mcp endpoints

This commit is contained in:
Sheen Capadngan
2025-12-06 01:15:04 +08:00
parent 4126f901d4
commit ff64a83dc2
32 changed files with 2291 additions and 30 deletions

View File

@@ -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

View File

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

View File

@@ -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" }
);

View File

@@ -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<typeof aiMcpEndpointServiceFactory>;
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<TAiMcpEndpointWithServers[]> => {
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
};
};

View File

@@ -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;
};

View File

@@ -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[] = [];

View File

@@ -0,0 +1,15 @@
export {
useBulkUpdateEndpointTools,
useCreateAiMcpEndpoint,
useDeleteAiMcpEndpoint,
useDisableEndpointTool,
useEnableEndpointTool,
useUpdateAiMcpEndpoint
} from "./mutations";
export {
aiMcpEndpointKeys,
useGetAiMcpEndpointById,
useListAiMcpEndpoints,
useListEndpointTools
} from "./queries";
export * from "./types";

View File

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

View File

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

View File

@@ -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;
}>;
};

View File

@@ -1,5 +1,6 @@
export * from "./accessApproval";
export * from "./admin";
export * from "./aiMcpEndpoints";
export * from "./aiMcpServers";
export * from "./apiKeys";
export * from "./assumePrivileges";

View File

@@ -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<string, { color: string; label: string }> = {
active: { color: "bg-emerald-500", label: "Active" },
inactive: { color: "bg-red-500", label: "Inactive" }
};
const config = statusConfig[status || "inactive"] || statusConfig.inactive;
return (
<div className="flex items-center gap-2 rounded-full border border-mineshaft-500 bg-mineshaft-800 px-3 py-1">
<div className={`h-2 w-2 rounded-full ${config.color}`} />
<span className="text-sm text-mineshaft-200">{config.label}</span>
</div>
);
};
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 (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader />
</div>
);
}
if (!mcpEndpoint) {
return (
<div className="flex h-full w-full items-center justify-center px-20">
<EmptyState
className="max-w-2xl rounded-md text-center"
icon={faBan}
title={`Could not find MCP Endpoint with ID ${endpointId}`}
/>
</div>
);
}
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 (
<div className="container mx-auto flex max-w-7xl flex-col px-6 py-6 text-mineshaft-50">
<button
type="button"
onClick={handleBack}
className="mb-4 flex items-center gap-1 text-sm text-bunker-300 hover:text-primary-400"
>
<FontAwesomeIcon icon={faChevronLeft} className="text-xs" />
MCP Endpoints
</button>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-mineshaft-700">
<FontAwesomeIcon icon={faNetworkWired} className="text-xl text-primary" />
</div>
<div>
<h1 className="text-2xl font-semibold text-mineshaft-100">{mcpEndpoint.name}</h1>
<p className="text-sm text-bunker-300">MCP Endpoint</p>
</div>
</div>
<div className="flex items-center gap-3">
<MCPEndpointStatusBadge status={mcpEndpoint.status} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline_bg" size="sm">
<FontAwesomeIcon icon={faEllipsisV} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsEditModalOpen(true)}>
Edit Endpoint
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsDeleteModalOpen(true)} className="text-red-500">
Delete Endpoint
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex gap-6">
{/* Left Column - Details, Connection, Connected Servers */}
<div className="flex w-96 flex-col gap-4">
<MCPEndpointDetailsSection
endpoint={mcpEndpoint}
onEdit={() => setIsEditModalOpen(true)}
/>
<MCPEndpointConnectionSection endpoint={mcpEndpoint} />
<MCPEndpointConnectedServersSection
projectId={mcpEndpoint.projectId}
serverIds={mcpEndpoint.serverIds}
/>
</div>
{/* Right Column - Tool Selection */}
<div className="flex flex-1 flex-col gap-4">
<MCPEndpointToolSelectionSection
endpointId={mcpEndpoint.id}
projectId={mcpEndpoint.projectId}
serverIds={mcpEndpoint.serverIds}
/>
</div>
</div>
<EditMCPEndpointModal
isOpen={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
endpoint={mcpEndpoint}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete MCP Endpoint ${mcpEndpoint.name}?`}
onChange={(isOpen) => setIsDeleteModalOpen(isOpen)}
deleteKey={mcpEndpoint.name}
onDeleteApproved={handleDeleteConfirm}
/>
</div>
);
};
export const MCPEndpointDetailPage = () => {
return (
<>
<Helmet>
<title>MCP Endpoint | Infisical</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<PageContent />
</>
);
};

View File

@@ -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 (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Connected MCP Servers</h3>
</div>
<div className="space-y-2">
{connectedServers.length === 0 ? (
<p className="py-2 text-sm text-bunker-400">No servers connected</p>
) : (
connectedServers.map((server) => (
<div
key={server.id}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-mineshaft-700"
>
<FontAwesomeIcon icon={faServer} className="text-sm text-bunker-400" />
<div className="flex-1">
<p className="text-sm text-mineshaft-200">{server.name}</p>
{server.description && (
<p className="text-xs text-bunker-400">{server.description}</p>
)}
</div>
<div
className={`h-2 w-2 rounded-full ${
server.status === "active" ? "bg-emerald-500" : "bg-red-500"
}`}
/>
</div>
))
)}
</div>
</div>
);
};

View File

@@ -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 (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Connection</h3>
</div>
<div className="space-y-3">
<GenericFieldLabel label="Endpoint">
<code className="rounded bg-mineshaft-700 px-2 py-1 font-mono text-sm text-mineshaft-200">
{endpointUrl}
</code>
</GenericFieldLabel>
</div>
</div>
);
};

View File

@@ -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<string, string> = {
active: "Active",
inactive: "Inactive"
};
return labels[status || "inactive"] || "Unknown";
};
const getStatusColor = (status: string | null) => {
const colors: Record<string, string> = {
active: "bg-emerald-500",
inactive: "bg-red-500"
};
return colors[status || "inactive"] || "bg-red-500";
};
export const MCPEndpointDetailsSection = ({ endpoint, onEdit }: Props) => {
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Details</h3>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="Edit endpoint details"
onClick={onEdit}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</div>
<div className="space-y-3">
<GenericFieldLabel label="Name">{endpoint.name}</GenericFieldLabel>
<GenericFieldLabel label="Description">
{endpoint.description || <span className="text-bunker-400">No description</span>}
</GenericFieldLabel>
<GenericFieldLabel label="Status">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${getStatusColor(endpoint.status)}`} />
{getStatusLabel(endpoint.status)}
</div>
</GenericFieldLabel>
<GenericFieldLabel label="Created">
{format(new Date(endpoint.createdAt), "yyyy-MM-dd, hh:mm aaa")}
</GenericFieldLabel>
</div>
</div>
);
};

View File

@@ -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 (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-mineshaft-700"
>
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faServer} className="text-sm text-bunker-400" />
<span className="text-sm text-mineshaft-200">{serverName}</span>
<div
className={`h-2 w-2 rounded-full ${
serverStatus === "active" ? "bg-emerald-500" : "bg-red-500"
}`}
/>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
{enabledCount}/{totalCount} Enabled
</span>
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-xs text-bunker-400"
/>
</div>
</button>
{isExpanded && filteredTools.length > 0 && (
<div className="border-t border-mineshaft-600">
<div className="grid grid-cols-[1fr_auto] gap-2 border-b border-mineshaft-600 px-4 py-2 text-xs font-medium tracking-wider text-bunker-300 uppercase">
<span>Tool Name</span>
<span>Enabled</span>
</div>
<div className="divide-y divide-mineshaft-600">
{filteredTools.map((tool) => (
<div key={tool.id} className="grid grid-cols-[1fr_auto] items-center gap-2 px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-mineshaft-200">{tool.name}</span>
{tool.description && (
<Tooltip content={tool.description}>
<FontAwesomeIcon
icon={faInfoCircle}
className="text-xs text-bunker-400 hover:text-bunker-300"
/>
</Tooltip>
)}
</div>
<Switch
id={`tool-${tool.id}`}
isChecked={isToolEnabled(tool.id)}
onCheckedChange={(checked) => onToolToggle(tool.id, checked)}
isDisabled={isUpdating}
/>
</div>
))}
</div>
</div>
)}
{isExpanded && tools.length === 0 && (
<div className="border-t border-mineshaft-600 px-4 py-4 text-center text-sm text-bunker-400">
No tools available from this server
</div>
)}
</div>
);
};
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 (
<div className="flex w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-4">
<div>
<h3 className="text-lg font-medium text-mineshaft-100">Tool Selection</h3>
<p className="mt-1 text-sm text-bunker-300">
Control which tools from connected MCP servers are available through this endpoint
</p>
</div>
<div className="relative">
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="absolute top-1/2 left-3 -translate-y-1/2 text-bunker-400"
/>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tools..."
className="pl-10"
/>
</div>
<div className="space-y-3">
{connectedServers.length === 0 ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 text-center">
<FontAwesomeIcon icon={faServer} className="mb-2 text-2xl text-bunker-400" />
<p className="text-sm text-bunker-400">No MCP servers connected to this endpoint</p>
<p className="mt-1 text-xs text-bunker-400">
Connect servers to configure available tools
</p>
</div>
) : (
connectedServers.map((server) => (
<ServerToolsSection
key={server.id}
serverId={server.id}
serverName={server.name}
serverStatus={server.status}
searchQuery={searchQuery}
toolConfigs={toolConfigs}
onToolToggle={handleToolToggle}
isUpdating={enableTool.isPending || disableTool.isPending}
/>
))
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,4 @@
export { MCPEndpointConnectedServersSection } from "./MCPEndpointConnectedServersSection";
export { MCPEndpointConnectionSection } from "./MCPEndpointConnectionSection";
export { MCPEndpointDetailsSection } from "./MCPEndpointDetailsSection";
export { MCPEndpointToolSelectionSection } from "./MCPEndpointToolSelectionSection";

View File

@@ -0,0 +1 @@
export { MCPEndpointDetailPage } from "./MCPEndpointDetailPage";

View File

@@ -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"
}
]
};
}
});

View File

@@ -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 = () => {
</TabList>
<TabPanel value={TabSections.MCPEndpoints}>
<div>MCP Endpoints - Coming soon</div>
<MCPEndpointsTab />
</TabPanel>
<TabPanel value={TabSections.MCPServers}>

View File

@@ -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<typeof AddMCPEndpointFormSchema>;

View File

@@ -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<TAddMCPEndpointForm>({
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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Create MCP Endpoint"
subTitle="Configure a unified entrypoint interface with security rules and governance controls"
className="max-w-2xl"
onClose={handleClose}
>
<form onSubmit={handleSubmit(onSubmit)}>
<p className="mb-4 text-sm text-bunker-300">
Create an endpoint to aggregate multiple MCP servers into a single entrypoint.
</p>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Endpoint Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="production-ai-proxy" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isOptional
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea
{...field}
placeholder="Enter endpoint description"
className="resize-none!"
rows={3}
/>
</FormControl>
)}
/>
<div className="mt-4">
<FormControl label="Connected Servers" isOptional>
<div className="rounded-md border border-mineshaft-600 bg-mineshaft-900 p-3">
{isLoadingServers && <p className="text-sm text-bunker-400">Loading servers...</p>}
{!isLoadingServers && servers.length === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<FontAwesomeIcon icon={faServer} className="mb-2 text-2xl text-bunker-400" />
<p className="text-sm text-bunker-400">No MCP servers available</p>
<p className="text-xs text-bunker-500">
Add MCP servers first to connect them to this endpoint
</p>
</div>
)}
{!isLoadingServers && servers.length > 0 && (
<div className="space-y-2">
{servers.map((server) => (
<div
key={server.id}
className="flex items-start gap-3 rounded-md p-2 transition-colors hover:bg-mineshaft-700"
>
<Checkbox
id={`server-${server.id}`}
className="mt-0.5"
isChecked={selectedServerIds?.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
/>
<label
htmlFor={`server-${server.id}`}
className="flex flex-1 cursor-pointer items-start gap-2"
>
<FontAwesomeIcon
icon={faServer}
className="mt-0.5 text-sm text-bunker-400"
/>
<div className="flex-1">
<p className="text-sm text-mineshaft-200">{server.name}</p>
{server.description && (
<p className="text-xs text-bunker-400">{server.description}</p>
)}
</div>
</label>
<span className="mt-0.5 text-xs text-bunker-400">
{server.toolsCount ?? 0} tools
</span>
</div>
))}
</div>
)}
{selectedServerIds && selectedServerIds.length > 0 && (
<p className="mt-2 text-xs text-bunker-400">
{selectedServerIds.length} server{selectedServerIds.length !== 1 ? "s" : ""}{" "}
selected
</p>
)}
</div>
</FormControl>
</div>
<div className="mt-6 flex justify-end gap-4">
<Button onClick={handleClose} colorSchema="secondary" type="button">
Cancel
</Button>
<Button
type="submit"
colorSchema="primary"
isLoading={isSubmitting || createEndpoint.isPending}
>
Create Endpoint
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1 @@
export * from "./AddMCPEndpointModal";

View File

@@ -0,0 +1,240 @@
import { useEffect } from "react";
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 { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import {
TAiMcpEndpoint,
useGetAiMcpEndpointById,
useListAiMcpServers,
useUpdateAiMcpEndpoint
} from "@app/hooks/api";
const EditMCPEndpointFormSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(64, "Name must be 64 characters or less"),
description: z.string().trim().max(256, "Description must be 256 characters or less").optional(),
serverIds: z.array(z.string()).default([])
});
type TEditMCPEndpointForm = z.infer<typeof EditMCPEndpointFormSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
endpoint: TAiMcpEndpoint | null;
};
export const EditMCPEndpointModal = ({ isOpen, onOpenChange, endpoint }: Props) => {
const updateEndpoint = useUpdateAiMcpEndpoint();
// Fetch full endpoint details including serverIds
const { data: endpointDetails } = useGetAiMcpEndpointById({
endpointId: endpoint?.id || ""
});
// Fetch available MCP servers for selection
const { data: serversData, isLoading: isLoadingServers } = useListAiMcpServers({
projectId: endpoint?.projectId || ""
});
const servers = serversData?.servers || [];
const {
control,
handleSubmit,
reset,
watch,
setValue,
formState: { isSubmitting }
} = useForm<TEditMCPEndpointForm>({
resolver: zodResolver(EditMCPEndpointFormSchema),
defaultValues: {
name: "",
description: "",
serverIds: []
}
});
const selectedServerIds = watch("serverIds");
// Reset form when endpoint details change
useEffect(() => {
if (endpointDetails) {
reset({
name: endpointDetails.name,
description: endpointDetails.description || "",
serverIds: endpointDetails.serverIds || []
});
}
}, [endpointDetails, reset]);
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: TEditMCPEndpointForm) => {
if (!endpoint) return;
try {
await updateEndpoint.mutateAsync({
endpointId: endpoint.id,
name: data.name,
description: data.description || undefined,
serverIds: data.serverIds
});
createNotification({
text: `Successfully updated MCP endpoint "${data.name}"`,
type: "success"
});
handleClose();
} catch (error) {
console.error("Failed to update MCP endpoint:", error);
createNotification({
text: "Failed to update MCP endpoint",
type: "error"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Edit MCP Endpoint"
subTitle="Update the endpoint details and connected servers"
className="max-w-2xl"
onClose={handleClose}
>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Endpoint Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="production-ai-proxy" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
<TextArea
{...field}
placeholder="Optional description for this endpoint"
className="resize-none!"
rows={3}
/>
</FormControl>
)}
/>
<div className="mt-4">
<FormControl label="Connected Servers" isOptional>
<div className="rounded-md border border-mineshaft-600 bg-mineshaft-900 p-3">
{isLoadingServers && <p className="text-sm text-bunker-400">Loading servers...</p>}
{!isLoadingServers && servers.length === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<FontAwesomeIcon icon={faServer} className="mb-2 text-2xl text-bunker-400" />
<p className="text-sm text-bunker-400">No MCP servers available</p>
<p className="text-xs text-bunker-500">
Add MCP servers first to connect them to this endpoint
</p>
</div>
)}
{!isLoadingServers && servers.length > 0 && (
<div className="space-y-2">
{servers.map((server) => (
<div
key={server.id}
className="flex items-start gap-3 rounded-md p-2 transition-colors hover:bg-mineshaft-700"
>
<Checkbox
id={`edit-server-${server.id}`}
className="mt-0.5"
isChecked={selectedServerIds?.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
/>
<label
htmlFor={`edit-server-${server.id}`}
className="flex flex-1 cursor-pointer items-start gap-2"
>
<FontAwesomeIcon
icon={faServer}
className="mt-0.5 text-sm text-bunker-400"
/>
<div className="flex-1">
<p className="text-sm text-mineshaft-200">{server.name}</p>
{server.description && (
<p className="text-xs text-bunker-400">{server.description}</p>
)}
</div>
</label>
<span className="mt-0.5 text-xs text-bunker-400">
{server.toolsCount ?? 0} tools
</span>
</div>
))}
</div>
)}
{selectedServerIds && selectedServerIds.length > 0 && (
<p className="mt-2 text-xs text-bunker-400">
{selectedServerIds.length} server{selectedServerIds.length !== 1 ? "s" : ""}{" "}
selected
</p>
)}
</div>
</FormControl>
</div>
<div className="mt-6 flex justify-end gap-4">
<Button onClick={handleClose} colorSchema="secondary" type="button">
Cancel
</Button>
<Button
type="submit"
colorSchema="primary"
isLoading={isSubmitting || updateEndpoint.isPending}
isDisabled={isSubmitting || updateEndpoint.isPending}
>
Save Changes
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,67 @@
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import { TAiMcpEndpoint, useListAiMcpEndpoints } from "@app/hooks/api";
import { MCPEndpointRow } from "./MCPEndpointRow";
interface Props {
onEditEndpoint: (endpoint: TAiMcpEndpoint) => void;
onDeleteEndpoint: (endpoint: TAiMcpEndpoint) => void;
}
export const MCPEndpointList = ({ onEditEndpoint, onDeleteEndpoint }: Props) => {
const { currentProject } = useProject();
const { data, isLoading } = useListAiMcpEndpoints({
projectId: currentProject?.id || ""
});
const endpoints = data?.endpoints;
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Status</Th>
<Th>Connected Servers</Th>
<Th>Active Tools</Th>
<Th className="w-16" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={5} innerKey="mcp-endpoints" />}
{!isLoading && (!endpoints || endpoints.length === 0) && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No MCP Endpoints" />
</Td>
</Tr>
)}
{!isLoading &&
endpoints &&
endpoints.length > 0 &&
endpoints.map((endpoint) => (
<MCPEndpointRow
key={endpoint.id}
endpoint={endpoint}
onEditEndpoint={onEditEndpoint}
onDeleteEndpoint={onDeleteEndpoint}
/>
))}
</TBody>
</Table>
</TableContainer>
);
};

View File

@@ -0,0 +1,140 @@
import { useCallback } from "react";
import { faCheck, faCopy, faEdit, faEllipsisV, faTrash } 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TAiMcpEndpoint } from "@app/hooks/api";
interface Props {
endpoint: TAiMcpEndpoint;
onEditEndpoint: (endpoint: TAiMcpEndpoint) => void;
onDeleteEndpoint: (endpoint: TAiMcpEndpoint) => void;
}
const getStatusBadge = (status: string | null) => {
const statusConfig: Record<string, { color: string; label: string }> = {
active: {
color: "bg-emerald-500",
label: "Active"
},
inactive: {
color: "bg-red-500",
label: "Inactive"
}
};
const config = statusConfig[status || "inactive"] || statusConfig.inactive;
return (
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${config.color}`} />
<span className="text-sm text-mineshaft-300">{config.label}</span>
</div>
);
};
export const MCPEndpointRow = ({ endpoint, onEditEndpoint, onDeleteEndpoint }: Props) => {
const navigate = useNavigate();
const { orgId, projectId } = useParams({
strict: false
}) as { orgId?: string; projectId?: string };
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(endpoint.id);
createNotification({
text: "Endpoint ID copied to clipboard",
type: "info"
});
setTimeout(() => setIsIdCopied.off(), 2000);
}, [setIsIdCopied, endpoint.id]);
const handleRowClick = () => {
if (orgId && projectId) {
navigate({
to: "/organizations/$orgId/projects/ai/$projectId/mcp-endpoints/$endpointId",
params: { orgId, projectId, endpointId: endpoint.id }
});
}
};
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={handleRowClick}
>
<Td>
<span className="text-mineshaft-300">{endpoint.name}</span>
</Td>
<Td>{getStatusBadge(endpoint.status)}</Td>
<Td>
<span className="text-sm text-mineshaft-300">{endpoint.connectedServers}</span>
</Td>
<Td>
<span className="text-sm text-mineshaft-300">{endpoint.activeTools}</span>
</Td>
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div
className="flex cursor-pointer justify-end hover:text-primary-400 data-[state=open]:text-primary-400"
role="button"
tabIndex={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsisV} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-1">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} className="w-3" />}
onClick={(e) => {
e.stopPropagation();
handleCopyId();
}}
>
Copy Endpoint ID
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEditEndpoint(endpoint);
}}
icon={<FontAwesomeIcon icon={faEdit} className="w-3" />}
>
Edit Endpoint
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteEndpoint(endpoint);
}}
icon={<FontAwesomeIcon icon={faTrash} className="w-3" />}
className="text-red-500 hover:text-red-400"
>
Delete Endpoint
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { TAiMcpEndpoint, useDeleteAiMcpEndpoint } from "@app/hooks/api";
import { AddMCPEndpointModal } from "./AddMCPEndpointModal";
import { EditMCPEndpointModal } from "./EditMCPEndpointModal";
import { MCPEndpointList } from "./MCPEndpointList";
export const MCPEndpointsTab = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedEndpoint, setSelectedEndpoint] = useState<TAiMcpEndpoint | null>(null);
const deleteEndpoint = useDeleteAiMcpEndpoint();
const handleCreateEndpoint = () => {
setIsCreateModalOpen(true);
};
const handleEditEndpoint = (endpoint: TAiMcpEndpoint) => {
setSelectedEndpoint(endpoint);
setIsEditModalOpen(true);
};
const handleDeleteEndpoint = (endpoint: TAiMcpEndpoint) => {
setSelectedEndpoint(endpoint);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!selectedEndpoint) return;
try {
await deleteEndpoint.mutateAsync({ endpointId: selectedEndpoint.id });
createNotification({
text: `MCP endpoint "${selectedEndpoint.name}" deleted successfully`,
type: "success"
});
} catch (error) {
console.error("Failed to delete MCP endpoint:", error);
createNotification({
text: "Failed to delete MCP endpoint",
type: "error"
});
} finally {
setIsDeleteModalOpen(false);
setSelectedEndpoint(null);
}
};
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-mineshaft-100">MCP Endpoints</h2>
<p className="text-sm text-bunker-300">
Unified entrypoint interfaces with security rules and governance controls
</p>
</div>
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreateEndpoint}
>
Create Endpoint
</Button>
</div>
<MCPEndpointList
onEditEndpoint={handleEditEndpoint}
onDeleteEndpoint={handleDeleteEndpoint}
/>
<AddMCPEndpointModal isOpen={isCreateModalOpen} onOpenChange={setIsCreateModalOpen} />
<EditMCPEndpointModal
isOpen={isEditModalOpen}
onOpenChange={(isOpen) => {
setIsEditModalOpen(isOpen);
if (!isOpen) {
setSelectedEndpoint(null);
}
}}
endpoint={selectedEndpoint}
/>
{selectedEndpoint && (
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete MCP Endpoint ${selectedEndpoint.name}?`}
onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen);
if (!isOpen) {
setSelectedEndpoint(null);
}
}}
deleteKey={selectedEndpoint.name}
onDeleteApproved={handleDeleteConfirm}
/>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./MCPEndpointsTab";

View File

@@ -28,30 +28,6 @@ export const MCPServerList = ({ onEditServer, onDeleteServer }: Props) => {
const servers = data?.servers;
if (!currentProject?.id) {
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Status</Th>
<Th>Available Tools</Th>
<Th className="w-16" />
</Tr>
</THead>
<TBody>
<Tr>
<Td colSpan={4}>
<EmptyState title="No Project Selected" />
</Td>
</Tr>
</TBody>
</Table>
</TableContainer>
);
}
return (
<TableContainer>
<Table>
@@ -59,7 +35,6 @@ export const MCPServerList = ({ onEditServer, onDeleteServer }: Props) => {
<Tr>
<Th>Name</Th>
<Th>Status</Th>
<Th>Available Tools</Th>
<Th className="w-16" />
</Tr>
</THead>

View File

@@ -81,9 +81,6 @@ export const MCPServerRow = ({ server, onEditServer, onDeleteServer }: Props) =>
<span className="text-mineshaft-300">{server.name}</span>
</Td>
<Td>{getStatusBadge(server.status)}</Td>
<Td>
<span className="text-sm text-mineshaft-300">{server.toolsCount ?? 0}</span>
</Td>
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">

View File

@@ -163,6 +163,7 @@ import { Route as certManagerPkiSubscriberDetailsByIDPageRouteImport } from './p
import { Route as certManagerPkiSyncDetailsByIDPageRouteImport } from './pages/cert-manager/PkiSyncDetailsByIDPage/route'
import { Route as certManagerCertAuthDetailsByIDPageRouteImport } from './pages/cert-manager/CertAuthDetailsByIDPage/route'
import { Route as aiMCPServerDetailPageRouteImport } from './pages/ai/MCPServerDetailPage/route'
import { Route as aiMCPEndpointDetailPageRouteImport } from './pages/ai/MCPEndpointDetailPage/route'
import { Route as secretScanningSecretScanningDataSourcesPageRouteImport } from './pages/secret-scanning/SecretScanningDataSourcesPage/route'
import { Route as secretManagerIntegrationsListPageRouteImport } from './pages/secret-manager/IntegrationsListPage/route'
import { Route as pamPamSessionsPageRouteImport } from './pages/pam/PamSessionsPage/route'
@@ -1611,6 +1612,13 @@ const aiMCPServerDetailPageRouteRoute = aiMCPServerDetailPageRouteImport.update(
} as any,
)
const aiMCPEndpointDetailPageRouteRoute =
aiMCPEndpointDetailPageRouteImport.update({
id: '/mcp-endpoints/$endpointId',
path: '/mcp-endpoints/$endpointId',
getParentRoute: () => aiLayoutRoute,
} as any)
const secretScanningSecretScanningDataSourcesPageRouteRoute =
secretScanningSecretScanningDataSourcesPageRouteImport.update({
id: '/',
@@ -3315,6 +3323,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof secretScanningSecretScanningDataSourcesPageRouteImport
parentRoute: typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationsOrgIdProjectsSecretScanningProjectIdSecretScanningLayoutDataSourcesImport
}
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId': {
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId'
path: '/mcp-endpoints/$endpointId'
fullPath: '/organizations/$orgId/projects/ai/$projectId/mcp-endpoints/$endpointId'
preLoaderRoute: typeof aiMCPEndpointDetailPageRouteImport
parentRoute: typeof aiLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-servers/$serverId': {
id: '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-servers/$serverId'
path: '/mcp-servers/$serverId'
@@ -4204,6 +4219,7 @@ interface aiLayoutRouteChildren {
aiSettingsPageRouteRoute: typeof aiSettingsPageRouteRoute
projectAccessControlPageRouteAiRoute: typeof projectAccessControlPageRouteAiRoute
projectAuditLogsPageRouteAiRoute: typeof projectAuditLogsPageRouteAiRoute
aiMCPEndpointDetailPageRouteRoute: typeof aiMCPEndpointDetailPageRouteRoute
aiMCPServerDetailPageRouteRoute: typeof aiMCPServerDetailPageRouteRoute
projectGroupDetailsByIDPageRouteAiRoute: typeof projectGroupDetailsByIDPageRouteAiRoute
projectIdentityDetailsByIDPageRouteAiRoute: typeof projectIdentityDetailsByIDPageRouteAiRoute
@@ -4216,6 +4232,7 @@ const aiLayoutRouteChildren: aiLayoutRouteChildren = {
aiSettingsPageRouteRoute: aiSettingsPageRouteRoute,
projectAccessControlPageRouteAiRoute: projectAccessControlPageRouteAiRoute,
projectAuditLogsPageRouteAiRoute: projectAuditLogsPageRouteAiRoute,
aiMCPEndpointDetailPageRouteRoute: aiMCPEndpointDetailPageRouteRoute,
aiMCPServerDetailPageRouteRoute: aiMCPServerDetailPageRouteRoute,
projectGroupDetailsByIDPageRouteAiRoute:
projectGroupDetailsByIDPageRouteAiRoute,
@@ -5421,6 +5438,7 @@ export interface FileRoutesByFullPath {
'/organizations/$orgId/projects/pam/$projectId/sessions/': typeof pamPamSessionsPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/integrations/': typeof secretManagerIntegrationsListPageRouteRoute
'/organizations/$orgId/projects/secret-scanning/$projectId/data-sources/': typeof secretScanningSecretScanningDataSourcesPageRouteRoute
'/organizations/$orgId/projects/ai/$projectId/mcp-endpoints/$endpointId': typeof aiMCPEndpointDetailPageRouteRoute
'/organizations/$orgId/projects/ai/$projectId/mcp-servers/$serverId': typeof aiMCPServerDetailPageRouteRoute
'/organizations/$orgId/projects/cert-management/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute
'/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId': typeof certManagerPkiSyncDetailsByIDPageRouteRoute
@@ -5661,6 +5679,7 @@ export interface FileRoutesByTo {
'/organizations/$orgId/projects/pam/$projectId/sessions': typeof pamPamSessionsPageRouteRoute
'/organizations/$orgId/projects/secret-management/$projectId/integrations': typeof secretManagerIntegrationsListPageRouteRoute
'/organizations/$orgId/projects/secret-scanning/$projectId/data-sources': typeof secretScanningSecretScanningDataSourcesPageRouteRoute
'/organizations/$orgId/projects/ai/$projectId/mcp-endpoints/$endpointId': typeof aiMCPEndpointDetailPageRouteRoute
'/organizations/$orgId/projects/ai/$projectId/mcp-servers/$serverId': typeof aiMCPServerDetailPageRouteRoute
'/organizations/$orgId/projects/cert-management/$projectId/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute
'/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId': typeof certManagerPkiSyncDetailsByIDPageRouteRoute
@@ -5925,6 +5944,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/': typeof pamPamSessionsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/': typeof secretManagerIntegrationsListPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-scanning/$projectId/_secret-scanning-layout/data-sources/': typeof secretScanningSecretScanningDataSourcesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId': typeof aiMCPEndpointDetailPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-servers/$serverId': typeof aiMCPServerDetailPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId': typeof certManagerCertAuthDetailsByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId': typeof certManagerPkiSyncDetailsByIDPageRouteRoute
@@ -6180,6 +6200,7 @@ export interface FileRouteTypes {
| '/organizations/$orgId/projects/pam/$projectId/sessions/'
| '/organizations/$orgId/projects/secret-management/$projectId/integrations/'
| '/organizations/$orgId/projects/secret-scanning/$projectId/data-sources/'
| '/organizations/$orgId/projects/ai/$projectId/mcp-endpoints/$endpointId'
| '/organizations/$orgId/projects/ai/$projectId/mcp-servers/$serverId'
| '/organizations/$orgId/projects/cert-management/$projectId/ca/$caId'
| '/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId'
@@ -6419,6 +6440,7 @@ export interface FileRouteTypes {
| '/organizations/$orgId/projects/pam/$projectId/sessions'
| '/organizations/$orgId/projects/secret-management/$projectId/integrations'
| '/organizations/$orgId/projects/secret-scanning/$projectId/data-sources'
| '/organizations/$orgId/projects/ai/$projectId/mcp-endpoints/$endpointId'
| '/organizations/$orgId/projects/ai/$projectId/mcp-servers/$serverId'
| '/organizations/$orgId/projects/cert-management/$projectId/ca/$caId'
| '/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId'
@@ -6681,6 +6703,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-management/$projectId/_secret-manager-layout/integrations/'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-scanning/$projectId/_secret-scanning-layout/data-sources/'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-servers/$serverId'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId'
| '/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId'
@@ -7311,6 +7334,7 @@ export const routeTree = rootRoute
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/settings",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/access-management",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/audit-logs",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-servers/$serverId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/groups/$groupId",
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/identities/$identityId",
@@ -7733,6 +7757,10 @@ export const routeTree = rootRoute
"filePath": "secret-scanning/SecretScanningDataSourcesPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/secret-scanning/$projectId/_secret-scanning-layout/data-sources"
},
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-endpoints/$endpointId": {
"filePath": "ai/MCPEndpointDetailPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout"
},
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout/mcp-servers/$serverId": {
"filePath": "ai/MCPServerDetailPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/ai/$projectId/_ai-layout"

View File

@@ -302,6 +302,7 @@ const certManagerRoutes = route("/organizations/$orgId/projects/cert-management/
const aiRoutes = route("/organizations/$orgId/projects/ai/$projectId", [
layout("ai-layout", "ai/layout.tsx", [
route("/mcp-servers/$serverId", "ai/MCPServerDetailPage/route.tsx"),
route("/mcp-endpoints/$endpointId", "ai/MCPEndpointDetailPage/route.tsx"),
route("/overview", "ai/MCPPage/route.tsx"),
route("/settings", "ai/SettingsPage/route.tsx"),
route("/audit-logs", "project/AuditLogsPage/route-ai.tsx"),