mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-06 22:23:53 -05:00
feat: mcp endpoints
This commit is contained in:
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@@ -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
|
||||
|
||||
281
backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts
Normal file
281
backend/src/ee/routes/v1/ai-mcp-endpoint-router.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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" }
|
||||
);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[] = [];
|
||||
|
||||
15
frontend/src/hooks/api/aiMcpEndpoints/index.ts
Normal file
15
frontend/src/hooks/api/aiMcpEndpoints/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
useBulkUpdateEndpointTools,
|
||||
useCreateAiMcpEndpoint,
|
||||
useDeleteAiMcpEndpoint,
|
||||
useDisableEndpointTool,
|
||||
useEnableEndpointTool,
|
||||
useUpdateAiMcpEndpoint
|
||||
} from "./mutations";
|
||||
export {
|
||||
aiMcpEndpointKeys,
|
||||
useGetAiMcpEndpointById,
|
||||
useListAiMcpEndpoints,
|
||||
useListEndpointTools
|
||||
} from "./queries";
|
||||
export * from "./types";
|
||||
126
frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx
Normal file
126
frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx
Normal 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)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
59
frontend/src/hooks/api/aiMcpEndpoints/queries.tsx
Normal file
59
frontend/src/hooks/api/aiMcpEndpoints/queries.tsx
Normal 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)
|
||||
});
|
||||
};
|
||||
70
frontend/src/hooks/api/aiMcpEndpoints/types.ts
Normal file
70
frontend/src/hooks/api/aiMcpEndpoints/types.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./accessApproval";
|
||||
export * from "./admin";
|
||||
export * from "./aiMcpEndpoints";
|
||||
export * from "./aiMcpServers";
|
||||
export * from "./apiKeys";
|
||||
export * from "./assumePrivileges";
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { MCPEndpointConnectedServersSection } from "./MCPEndpointConnectedServersSection";
|
||||
export { MCPEndpointConnectionSection } from "./MCPEndpointConnectionSection";
|
||||
export { MCPEndpointDetailsSection } from "./MCPEndpointDetailsSection";
|
||||
export { MCPEndpointToolSelectionSection } from "./MCPEndpointToolSelectionSection";
|
||||
1
frontend/src/pages/ai/MCPEndpointDetailPage/index.ts
Normal file
1
frontend/src/pages/ai/MCPEndpointDetailPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MCPEndpointDetailPage } from "./MCPEndpointDetailPage";
|
||||
26
frontend/src/pages/ai/MCPEndpointDetailPage/route.tsx
Normal file
26
frontend/src/pages/ai/MCPEndpointDetailPage/route.tsx
Normal 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"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AddMCPEndpointModal";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MCPEndpointsTab";
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user