diff --git a/backend/src/db/migrations/20251204160117_add-ai-mcp-product.ts b/backend/src/db/migrations/20251204160117_add-ai-mcp-product.ts index 09152eddbb..5eab7ab951 100644 --- a/backend/src/db/migrations/20251204160117_add-ai-mcp-product.ts +++ b/backend/src/db/migrations/20251204160117_add-ai-mcp-product.ts @@ -9,7 +9,7 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.string("name").notNullable(); t.string("url").notNullable(); - t.string("description"); + t.text("description"); t.string("status"); t.string("credentialMode"); t.string("authMethod"); @@ -38,7 +38,7 @@ export async function up(knex: Knex): Promise { await knex.schema.createTable(TableName.AiMcpEndpoint, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.string("name").notNullable(); - t.string("description"); + t.text("description"); t.string("status"); t.boolean("piiFiltering").defaultTo(false).notNullable(); t.string("projectId").notNullable(); @@ -104,7 +104,7 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - // await dropOnUpdateTrigger(knex, TableName.AiMcpServerUserCredential); + await dropOnUpdateTrigger(knex, TableName.AiMcpServerUserCredential); await knex.schema.dropTableIfExists(TableName.AiMcpServerUserCredential); await knex.schema.dropTableIfExists(TableName.AiMcpEndpointServerTool); diff --git a/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts b/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts index 429c8227bf..aee3369ec4 100644 --- a/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts +++ b/backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts @@ -6,12 +6,12 @@ import { readLimit } from "@app/server/config/rateLimiter"; const getMcpUrls = (siteUrl: string, endpointId: string) => { // The MCP resource/connect URL - const resourceUrl = `${siteUrl}/api/v1/ai/mcp-endpoints/${endpointId}/connect`; + const resourceUrl = `${siteUrl}/api/v1/ai/mcp/endpoints/${endpointId}/connect`; // The authorization server issuer (RFC 8414: metadata at /.well-known/oauth-authorization-server/{path}) const authServerIssuer = `${siteUrl}/mcp-endpoints/${endpointId}`; // OAuth endpoint URLs - const apiBaseUrl = `${siteUrl}/api/v1/ai/mcp-endpoints/${endpointId}`; + const apiBaseUrl = `${siteUrl}/api/v1/ai/mcp/endpoints/${endpointId}`; const tokenEndpointUrl = `${apiBaseUrl}/oauth/token`; const authorizeEndpointUrl = `${apiBaseUrl}/oauth/authorize`; const registrationEndpointUrl = `${apiBaseUrl}/oauth/register`; diff --git a/backend/src/ee/routes/v1/ai-mcp-server-router.ts b/backend/src/ee/routes/v1/ai-mcp-server-router.ts index 89e2ea135d..ccf622ce1d 100644 --- a/backend/src/ee/routes/v1/ai-mcp-server-router.ts +++ b/backend/src/ee/routes/v1/ai-mcp-server-router.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import { AiMcpServerToolsSchema } from "@app/db/schemas/ai-mcp-server-tools"; import { AiMcpServersSchema } from "@app/db/schemas/ai-mcp-servers"; -import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { AiMcpServerAuthMethod, AiMcpServerCredentialMode } from "@app/ee/services/ai-mcp-server/ai-mcp-server-enum"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; 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"; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 297e905748..762e99773a 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -229,9 +229,9 @@ 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" }); - await aiRouter.register(registerAiMcpActivityLogRouter, { prefix: "/mcp-activity-logs" }); + await aiRouter.register(registerAiMcpServerRouter, { prefix: "/mcp/servers" }); + await aiRouter.register(registerAiMcpEndpointRouter, { prefix: "/mcp/endpoints" }); + await aiRouter.register(registerAiMcpActivityLogRouter, { prefix: "/mcp/activity-logs" }); }, { prefix: "/ai" } ); diff --git a/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts index d108d9ffdf..57a435dbd3 100644 --- a/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts +++ b/backend/src/ee/services/ai-mcp-endpoint/ai-mcp-endpoint-service.ts @@ -27,6 +27,7 @@ import { AiMcpServerCredentialMode } from "../ai-mcp-server/ai-mcp-server-enum"; import { TAiMcpServerServiceFactory } from "../ai-mcp-server/ai-mcp-server-service"; import { TAiMcpServerToolDALFactory } from "../ai-mcp-server/ai-mcp-server-tool-dal"; import { TAiMcpServerUserCredentialDALFactory } from "../ai-mcp-server/ai-mcp-server-user-credential-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { ProjectPermissionMcpEndpointActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAiMcpEndpointDALFactory } from "./ai-mcp-endpoint-dal"; @@ -72,6 +73,7 @@ type TAiMcpEndpointServiceFactoryDep = { authTokenService: Pick; userDAL: TUserDALFactory; permissionService: Pick; + licenseService: Pick; }; // OAuth schemas for parsing cached data @@ -129,57 +131,9 @@ export const aiMcpEndpointServiceFactory = ({ keyStore, authTokenService, userDAL, - permissionService + permissionService, + licenseService }: TAiMcpEndpointServiceFactoryDep) => { - // PII filtering utility - redacts sensitive information - const applyPiiFiltering = (data: unknown): unknown => { - if (typeof data === "string") { - let filtered = data; - - // Redact SSN (matches formats: 123-45-6789, 123456789) - filtered = filtered.replace(/\b\d{3}-?\d{2}-?\d{4}\b/g, ""); - - // Redact Phone Numbers (matches various formats) - // Matches: (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 123 456 7890 - filtered = filtered.replace(/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, ""); - - // Redact Email Addresses - filtered = filtered.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, ""); - - // Redact Passport Numbers (matches common formats) - // Matches: US (9 digits), international alphanumeric (e.g., AB1234567, P12345678) - filtered = filtered.replace(/\b[A-Z]{1,2}\d{6,9}\b/g, ""); - filtered = filtered.replace(/\bP\d{8}\b/g, ""); - - // Redact Driver's License Numbers (matches common US state formats) - // Matches various state formats: alphanumeric combinations typically 6-20 characters - // Examples: CA: A1234567, TX: 12345678, NY: 123456789, FL: A123-456-78-901-0 - filtered = filtered.replace(/\b[A-Z]{1,2}[-\s]?\d{6,8}\b/g, ""); - filtered = filtered.replace(/\b\d{7,9}\b/g, ""); - filtered = filtered.replace(/\b[A-Z]\d{3}-\d{3}-\d{2}-\d{3}-\d\b/g, ""); - - // Redact State ID Numbers (similar patterns to driver's licenses) - // Matches alphanumeric state ID formats - filtered = filtered.replace(/\b[A-Z]{1,3}\d{5,12}\b/g, ""); - - return filtered; - } - - if (Array.isArray(data)) { - return data.map((item) => applyPiiFiltering(item)); - } - - if (data && typeof data === "object") { - const filtered: Record = {}; - for (const [key, value] of Object.entries(data)) { - filtered[key] = applyPiiFiltering(value); - } - return filtered; - } - - return data; - }; - const interactWithMcp = async ({ endpointId, userId, @@ -349,34 +303,58 @@ export const aiMcpEndpointServiceFactory = ({ } try { - // Apply PII filtering to arguments if enabled - const filteredArgs = (endpoint.piiFiltering ? applyPiiFiltering(args) : args) as Record; - const result = await selectedMcpClient.client.callTool({ name, - arguments: filteredArgs + arguments: args }); - // Apply PII filtering to result if enabled - const filteredResult = endpoint.piiFiltering ? applyPiiFiltering(result) : result; - await aiMcpActivityLogService.createActivityLog({ endpointName: endpoint.name, serverName: selectedMcpClient.server.name, toolName: name, actor: user.email || "", - request: filteredArgs, // Log filtered args - response: filteredResult, // Log filtered response + request: args, + response: result, projectId: endpoint.projectId }); - return filteredResult as Record; + return result as Record; } catch (error) { + // Log the full error internally for system administrators + logger.error( + { + error, + endpointName: endpoint.name, + serverName: selectedMcpClient.server.name, + toolName: name, + actor: user.email || "", + projectId: endpoint.projectId + }, + "Tool call failed" + ); + + // Log failed activity with full error details for user visibility in activity logs + const errorMessage = error instanceof Error ? error.message : String(error); + await aiMcpActivityLogService + .createActivityLog({ + endpointName: endpoint.name, + serverName: selectedMcpClient.server.name, + toolName: name, + actor: user.email || "", + request: args, + response: { error: errorMessage }, + projectId: endpoint.projectId + }) + .catch((logError) => { + logger.error({ error: logError }, "Failed to log tool call error activity"); + }); + + // Return generic error to client to avoid information leakage return { content: [ { type: "text", - text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` + text: "Tool execution failed" } ], isError: true @@ -402,6 +380,13 @@ export const aiMcpEndpointServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateAiMcpEndpointDTO) => { + const orgLicensePlan = await licenseService.getPlan(actorOrgId); + if (!orgLicensePlan.ai) { + throw new BadRequestError({ + message: "AI operation failed due to organization plan restrictions." + }); + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, diff --git a/backend/src/ee/services/ai-mcp-server/ai-mcp-server-service.ts b/backend/src/ee/services/ai-mcp-server/ai-mcp-server-service.ts index 7d04a7f30e..744d9bd0fa 100644 --- a/backend/src/ee/services/ai-mcp-server/ai-mcp-server-service.ts +++ b/backend/src/ee/services/ai-mcp-server/ai-mcp-server-service.ts @@ -10,6 +10,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import axios, { AxiosError } from "axios"; import { ActionProjectType } from "@app/db/schemas"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; @@ -19,6 +20,7 @@ import { ActorType, AuthMethod } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAiMcpServerDALFactory } from "./ai-mcp-server-dal"; @@ -51,6 +53,7 @@ type TAiMcpServerServiceFactoryDep = { kmsService: Pick; keyStore: Pick; permissionService: Pick; + licenseService: Pick; }; export type TAiMcpServerServiceFactory = ReturnType; @@ -137,7 +140,8 @@ export const aiMcpServerServiceFactory = ({ aiMcpServerUserCredentialDAL, kmsService, keyStore, - permissionService + permissionService, + licenseService }: TAiMcpServerServiceFactoryDep) => { /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-redundant-type-constituents */ const fetchMcpTools = async (serverUrl: string, accessToken: string): Promise => { @@ -200,6 +204,9 @@ export const aiMcpServerServiceFactory = ({ }> => { let resourceMetadataUrl: string | null = null; + const url = new URL(mcpUrl); + await verifyHostInputValidity(url.hostname, true); + // 1. Try to access the MCP server to get WWW-Authenticate header try { await request.get(mcpUrl); @@ -306,6 +313,9 @@ export const aiMcpServerServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.McpServers); } + const urlObj = new URL(url); + await verifyHostInputValidity(urlObj.hostname, true); + // 1. Discover OAuth metadata following RFC 9728 flow const { protectedResource, authServer } = await discoverOAuthMetadata(url); @@ -313,7 +323,7 @@ export const aiMcpServerServiceFactory = ({ const sessionId = crypto.randomUUID(); // 3. Build redirect URI - const redirectUri = `${appCfg.SITE_URL}/api/v1/ai/mcp-servers/oauth/callback`; + const redirectUri = `${appCfg.SITE_URL}/api/v1/ai/mcp/servers/oauth/callback`; // 4. Get client credentials - either from DCR or hardcoded let resolvedClientId: string; @@ -517,6 +527,13 @@ export const aiMcpServerServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateAiMcpServerDTO) => { + const orgLicensePlan = await licenseService.getPlan(actorOrgId); + if (!orgLicensePlan.ai) { + throw new BadRequestError({ + message: "AI operation failed due to organization plan restrictions." + }); + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 09ff9e1081..799a78d335 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -113,7 +113,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ eventSubscriptions: false, machineIdentityAuthTemplates: false, pkiLegacyTemplates: false, - pam: false + pam: false, + ai: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 75bf4b0674..d4392e9467 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -93,6 +93,7 @@ export type TFeatureSet = { fips: false; eventSubscriptions: false; pam: false; + ai: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index d327462627..8b7e7efa89 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -141,7 +141,7 @@ export const injectIdentity = fp( return; } - if (req.url === "/api/v1/ai/mcp-servers/oauth/callback") { + if (req.url === "/api/v1/ai/mcp/servers/oauth/callback") { return; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3df42032e2..528c51f071 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2499,7 +2499,8 @@ export const registerRoutes = async ( aiMcpServerUserCredentialDAL, kmsService, keyStore, - permissionService + permissionService, + licenseService }); const aiMcpActivityLogService = aiMcpActivityLogServiceFactory({ @@ -2520,7 +2521,8 @@ export const registerRoutes = async ( authTokenService: tokenService, aiMcpActivityLogService, userDAL, - permissionService + permissionService, + licenseService }); const migrationService = externalMigrationServiceFactory({ diff --git a/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx b/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx index 44ee88104b..1a029f2e21 100644 --- a/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx +++ b/frontend/src/hooks/api/aiMcpActivityLogs/queries.tsx @@ -22,7 +22,7 @@ export const useListAiMcpActivityLogs = ( queryFn: async ({ pageParam }) => { try { const { data } = await apiRequest.get<{ activityLogs: TAiMcpActivityLog[] }>( - "/api/v1/ai/mcp-activity-logs", + "/api/v1/ai/mcp/activity-logs", { params: { projectId: filters.projectId, diff --git a/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx b/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx index a239fed554..8fcdf1fddf 100644 --- a/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx +++ b/frontend/src/hooks/api/aiMcpEndpoints/mutations.tsx @@ -23,7 +23,7 @@ export const useCreateAiMcpEndpoint = () => { return useMutation({ mutationFn: async (dto: TCreateAiMcpEndpointDTO) => { const { data } = await apiRequest.post<{ endpoint: TAiMcpEndpoint }>( - "/api/v1/ai/mcp-endpoints", + "/api/v1/ai/mcp/endpoints", dto ); return data.endpoint; @@ -42,7 +42,7 @@ export const useUpdateAiMcpEndpoint = () => { return useMutation({ mutationFn: async ({ endpointId, ...dto }: TUpdateAiMcpEndpointDTO) => { const { data } = await apiRequest.patch<{ endpoint: TAiMcpEndpoint }>( - `/api/v1/ai/mcp-endpoints/${endpointId}`, + `/api/v1/ai/mcp/endpoints/${endpointId}`, dto ); return data.endpoint; @@ -64,7 +64,7 @@ export const useDeleteAiMcpEndpoint = () => { return useMutation({ mutationFn: async ({ endpointId }: TDeleteAiMcpEndpointDTO) => { const { data } = await apiRequest.delete<{ endpoint: TAiMcpEndpoint }>( - `/api/v1/ai/mcp-endpoints/${endpointId}` + `/api/v1/ai/mcp/endpoints/${endpointId}` ); return data.endpoint; }, @@ -82,7 +82,7 @@ export const useEnableEndpointTool = () => { return useMutation({ mutationFn: async ({ endpointId, serverToolId }: TEnableEndpointToolDTO) => { const { data } = await apiRequest.post<{ tool: TAiMcpEndpointToolConfig }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/tools/${serverToolId}` + `/api/v1/ai/mcp/endpoints/${endpointId}/tools/${serverToolId}` ); return data.tool; }, @@ -99,7 +99,7 @@ export const useDisableEndpointTool = () => { return useMutation({ mutationFn: async ({ endpointId, serverToolId }: TDisableEndpointToolDTO) => { - await apiRequest.delete(`/api/v1/ai/mcp-endpoints/${endpointId}/tools/${serverToolId}`); + await apiRequest.delete(`/api/v1/ai/mcp/endpoints/${endpointId}/tools/${serverToolId}`); }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ @@ -115,7 +115,7 @@ export const useBulkUpdateEndpointTools = () => { return useMutation({ mutationFn: async ({ endpointId, tools }: TBulkUpdateEndpointToolsDTO) => { const { data } = await apiRequest.patch<{ tools: TAiMcpEndpointToolConfig[] }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/tools/bulk`, + `/api/v1/ai/mcp/endpoints/${endpointId}/tools/bulk`, { tools } ); return data.tools; @@ -132,7 +132,7 @@ export const useFinalizeMcpEndpointOAuth = () => { return useMutation({ mutationFn: async ({ endpointId, ...body }: TFinalizeMcpEndpointOAuthDTO) => { const { data } = await apiRequest.post<{ callbackUrl: string }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/oauth/finalize`, + `/api/v1/ai/mcp/endpoints/${endpointId}/oauth/finalize`, body ); return data; @@ -144,7 +144,7 @@ export const useInitiateServerOAuth = () => { return useMutation({ mutationFn: async ({ endpointId, serverId }: TInitiateServerOAuthDTO) => { const { data } = await apiRequest.post<{ authUrl: string; sessionId: string }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/servers/${serverId}/oauth/initiate` + `/api/v1/ai/mcp/endpoints/${endpointId}/servers/${serverId}/oauth/initiate` ); return data; } @@ -157,7 +157,7 @@ export const useSaveUserServerCredential = () => { return useMutation({ mutationFn: async ({ endpointId, serverId, ...body }: TSaveUserServerCredentialDTO) => { const { data } = await apiRequest.post<{ success: boolean }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/servers/${serverId}/credentials`, + `/api/v1/ai/mcp/endpoints/${endpointId}/servers/${serverId}/credentials`, body ); return data; diff --git a/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx b/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx index 492dec26f6..2f5dff5035 100644 --- a/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx +++ b/frontend/src/hooks/api/aiMcpEndpoints/queries.tsx @@ -26,7 +26,7 @@ export const useListAiMcpEndpoints = ({ projectId }: TListAiMcpEndpointsDTO) => const { data } = await apiRequest.get<{ endpoints: TAiMcpEndpoint[]; totalCount: number; - }>("/api/v1/ai/mcp-endpoints", { + }>("/api/v1/ai/mcp/endpoints", { params: { projectId } }); return data; @@ -40,7 +40,7 @@ export const useGetAiMcpEndpointById = ({ endpointId }: { endpointId: string }) queryKey: aiMcpEndpointKeys.byId(endpointId), queryFn: async () => { const { data } = await apiRequest.get<{ endpoint: TAiMcpEndpointWithServerIds }>( - `/api/v1/ai/mcp-endpoints/${endpointId}` + `/api/v1/ai/mcp/endpoints/${endpointId}` ); return data.endpoint; }, @@ -53,7 +53,7 @@ export const useListEndpointTools = ({ endpointId }: { endpointId: string }) => queryKey: aiMcpEndpointKeys.tools(endpointId), queryFn: async () => { const { data } = await apiRequest.get<{ tools: TAiMcpEndpointToolConfig[] }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/tools` + `/api/v1/ai/mcp/endpoints/${endpointId}/tools` ); return data.tools; }, @@ -66,7 +66,7 @@ export const useGetServersRequiringAuth = ({ endpointId }: { endpointId: string queryKey: aiMcpEndpointKeys.serversRequiringAuth(endpointId), queryFn: async () => { const { data } = await apiRequest.get<{ servers: TServerAuthStatus[] }>( - `/api/v1/ai/mcp-endpoints/${endpointId}/servers-requiring-auth` + `/api/v1/ai/mcp/endpoints/${endpointId}/servers-requiring-auth` ); return data.servers; }, diff --git a/frontend/src/hooks/api/aiMcpServers/mutations.tsx b/frontend/src/hooks/api/aiMcpServers/mutations.tsx index f342b530f5..2d58586f50 100644 --- a/frontend/src/hooks/api/aiMcpServers/mutations.tsx +++ b/frontend/src/hooks/api/aiMcpServers/mutations.tsx @@ -21,7 +21,7 @@ export const useCreateAiMcpServer = () => { mutationFn: async (data) => { const { data: response } = await apiRequest.post<{ server: TAiMcpServer; - }>("/api/v1/ai/mcp-servers", data); + }>("/api/v1/ai/mcp/servers", data); return response.server; }, onSuccess: (_, { projectId }) => { @@ -39,7 +39,7 @@ export const useUpdateAiMcpServer = () => { mutationFn: async ({ serverId, ...data }) => { const { data: response } = await apiRequest.patch<{ server: TAiMcpServer; - }>(`/api/v1/ai/mcp-servers/${serverId}`, data); + }>(`/api/v1/ai/mcp/servers/${serverId}`, data); return response.server; }, onSuccess: (server, { serverId }) => { @@ -60,7 +60,7 @@ export const useDeleteAiMcpServer = () => { mutationFn: async ({ serverId }) => { const { data: response } = await apiRequest.delete<{ server: TAiMcpServer; - }>(`/api/v1/ai/mcp-servers/${serverId}`); + }>(`/api/v1/ai/mcp/servers/${serverId}`); return response.server; }, onSuccess: (server, { serverId }) => { @@ -78,7 +78,7 @@ export const useInitiateOAuth = () => { return useMutation({ mutationFn: async (data) => { const { data: response } = await apiRequest.post( - "/api/v1/ai/mcp-servers/oauth/initiate", + "/api/v1/ai/mcp/servers/oauth/initiate", data ); return response; @@ -93,7 +93,7 @@ export const useSyncAiMcpServerTools = () => { mutationFn: async ({ serverId }) => { const { data: response } = await apiRequest.post<{ tools: TAiMcpServerTool[]; - }>(`/api/v1/ai/mcp-servers/${serverId}/tools/sync`); + }>(`/api/v1/ai/mcp/servers/${serverId}/tools/sync`); return response; }, onSuccess: (_, { serverId }) => { diff --git a/frontend/src/hooks/api/aiMcpServers/queries.tsx b/frontend/src/hooks/api/aiMcpServers/queries.tsx index a0949f9b09..664f436fba 100644 --- a/frontend/src/hooks/api/aiMcpServers/queries.tsx +++ b/frontend/src/hooks/api/aiMcpServers/queries.tsx @@ -30,7 +30,7 @@ export const useListAiMcpServers = ({ const { data } = await apiRequest.get<{ servers: TAiMcpServer[]; totalCount: number; - }>("/api/v1/ai/mcp-servers", { + }>("/api/v1/ai/mcp/servers", { params: { projectId, limit, offset } }); return data; @@ -48,7 +48,7 @@ export const useGetAiMcpServerById = ( queryFn: async () => { const { data } = await apiRequest.get<{ server: TAiMcpServer; - }>(`/api/v1/ai/mcp-servers/${serverId}`); + }>(`/api/v1/ai/mcp/servers/${serverId}`); return data.server; }, enabled: Boolean(serverId), @@ -62,7 +62,7 @@ export const useListAiMcpServerTools = ({ serverId }: TListAiMcpServerToolsDTO) queryFn: async () => { const { data } = await apiRequest.get<{ tools: TAiMcpServerTool[]; - }>(`/api/v1/ai/mcp-servers/${serverId}/tools`); + }>(`/api/v1/ai/mcp/servers/${serverId}/tools`); return data; }, enabled: Boolean(serverId) @@ -77,7 +77,7 @@ export const useGetOAuthStatus = ( queryKey: aiMcpServerKeys.oauthStatus(sessionId), queryFn: async () => { const { data } = await apiRequest.get( - `/api/v1/ai/mcp-servers/oauth/status/${sessionId}` + `/api/v1/ai/mcp/servers/oauth/status/${sessionId}` ); return data; }, diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 43ee68c3e1..96f4aeb9d7 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -63,4 +63,5 @@ export type SubscriptionPlan = { cardDeclinedDays?: number; machineIdentityAuthTemplates: boolean; pam: boolean; + ai: boolean; }; diff --git a/frontend/src/layouts/AILayout/AILayout.tsx b/frontend/src/layouts/AILayout/AILayout.tsx index 49fd71bab6..5ec778bd92 100644 --- a/frontend/src/layouts/AILayout/AILayout.tsx +++ b/frontend/src/layouts/AILayout/AILayout.tsx @@ -1,87 +1,112 @@ +import { useEffect } from "react"; import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { Tab, TabList, Tabs } from "@app/components/v2"; -import { useOrganization, useProject, useProjectPermission } from "@app/context"; +import { useOrganization, useProject, useProjectPermission, useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; export const AILayout = () => { const { currentOrg } = useOrganization(); const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { assumedPrivilegeDetails } = useProjectPermission(); - const location = useLocation(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); + + useEffect(() => { + if (subscription && !subscription.ai) { + handlePopUpOpen("upgradePlan", { + description: + "Your current plan does not provide access to Infisical AI. To unlock this feature, please upgrade to Infisical Enterprise plan.", + isEnterpriseFeature: true + }); + } + }, [subscription]); + return ( -
-
- - - + <> +
+
+ + + +
+ {assumedPrivilegeDetails && } +
+ +
- {assumedPrivilegeDetails && } -
- -
-
+ { + handlePopUpToggle("upgradePlan", isOpen); + }} + text={popUp.upgradePlan.data?.description} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} + /> + ); }; diff --git a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx index ea7c130c60..68c85e00fe 100644 --- a/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx +++ b/frontend/src/pages/ai/MCPEndpointDetailPage/components/MCPEndpointConnectionSection.tsx @@ -39,7 +39,7 @@ export const MCPEndpointConnectionSection = ({ endpoint }: Props) => { const [copiedTokenId, setCopiedTokenId] = useState(null); const [hasLoaded, setHasLoaded] = useState(false); - const endpointUrl = `${window.location.origin}/api/v1/ai/mcp-endpoints/${endpoint.id}/connect`; + const endpointUrl = `${window.location.origin}/api/v1/ai/mcp/endpoints/${endpoint.id}/connect`; const storageKey = `${STORAGE_KEY_PREFIX}${endpoint.id}`; // Load tokens from localStorage on mount diff --git a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts index c0a27cff42..54ebbe8d8e 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts +++ b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/AddMCPEndpointModal/AddMCPEndpointForm.schema.ts @@ -3,7 +3,7 @@ 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([]) + serverIds: z.array(z.string().uuid()).default([]) }); export type TAddMCPEndpointForm = z.infer; diff --git a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/EditMCPEndpointModal.tsx b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/EditMCPEndpointModal.tsx index bdf5f535b1..0f25a89c4a 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/EditMCPEndpointModal.tsx +++ b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/EditMCPEndpointModal.tsx @@ -25,7 +25,7 @@ import { 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([]) + serverIds: z.array(z.string().uuid()).default([]) }); type TEditMCPEndpointForm = z.infer; diff --git a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/MCPEndpointsTab.tsx b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/MCPEndpointsTab.tsx index abcfeeb7a6..6baa357d17 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/MCPEndpointsTab.tsx +++ b/frontend/src/pages/ai/MCPPage/components/MCPEndpointsTab/MCPEndpointsTab.tsx @@ -2,10 +2,16 @@ import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { ProjectPermissionMcpEndpointActions, ProjectPermissionSub } from "@app/context"; +import { + ProjectPermissionMcpEndpointActions, + ProjectPermissionSub, + useSubscription +} from "@app/context"; +import { usePopUp } from "@app/hooks"; import { TAiMcpEndpoint, useDeleteAiMcpEndpoint } from "@app/hooks/api"; import { AddMCPEndpointModal } from "./AddMCPEndpointModal"; @@ -18,9 +24,18 @@ export const MCPEndpointsTab = () => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedEndpoint, setSelectedEndpoint] = useState(null); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); const deleteEndpoint = useDeleteAiMcpEndpoint(); const handleCreateEndpoint = () => { + if (subscription && !subscription.ai) { + handlePopUpOpen("upgradePlan", { + text: "Your current plan does not include access to Infisical AI. To unlock this feature, please upgrade to Infisical Enterprise plan.", + isEnterpriseFeature: true + }); + return; + } setIsCreateModalOpen(true); }; @@ -115,6 +130,13 @@ export const MCPEndpointsTab = () => { onDeleteApproved={handleDeleteConfirm} /> )} + + handlePopUpToggle("upgradePlan", isOpen)} + text={popUp.upgradePlan.data?.text} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} + />
); }; diff --git a/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServersTab.tsx b/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServersTab.tsx index 3692b17a61..43c82875c0 100644 --- a/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServersTab.tsx +++ b/frontend/src/pages/ai/MCPPage/components/MCPServersTab/MCPServersTab.tsx @@ -2,10 +2,12 @@ import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { TAiMcpServer, useDeleteAiMcpServer } from "@app/hooks/api"; import { AddMCPServerModal } from "./AddMCPServerModal"; @@ -18,9 +20,18 @@ export const MCPServersTab = () => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedServer, setSelectedServer] = useState(null); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); const deleteServer = useDeleteAiMcpServer(); const handleAddServer = () => { + if (subscription && !subscription.ai) { + handlePopUpOpen("upgradePlan", { + text: "Your current plan does not include access to Infisical AI. To unlock this feature, please upgrade to Infisical Enterprise plan.", + isEnterpriseFeature: true + }); + return; + } setIsAddModalOpen(true); }; @@ -112,6 +123,13 @@ export const MCPServersTab = () => { onDeleteApproved={handleDeleteConfirm} /> )} + + handlePopUpToggle("upgradePlan", isOpen)} + text={popUp.upgradePlan.data?.text} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} + /> ); };