mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-05 05:34:17 -05:00
feat: client registration against MCP endpoint
This commit is contained in:
97
backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts
Normal file
97
backend/src/ee/routes/ai/mcp-endpoint-metadata-router.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
|
||||
const getMcpUrls = (siteUrl: string, endpointId: string) => {
|
||||
// The MCP resource/connect URL
|
||||
const resourceUrl = `${siteUrl}/api/v1/ai/mcp-endpoints/${endpointId}/connect`;
|
||||
// The authorization server issuer (RFC 8414: metadata at /.well-known/oauth-authorization-server/{path})
|
||||
const authServerIssuer = `${siteUrl}/mcp-endpoints/${endpointId}`;
|
||||
|
||||
// OAuth endpoint URLs
|
||||
const apiBaseUrl = `${siteUrl}/api/v1/ai/mcp-endpoints/${endpointId}`;
|
||||
const tokenEndpointUrl = `${apiBaseUrl}/oauth/token`;
|
||||
const authorizeEndpointUrl = `${apiBaseUrl}/oauth/authorize`;
|
||||
const registrationEndpointUrl = `${apiBaseUrl}/oauth/register`;
|
||||
|
||||
return {
|
||||
resourceUrl,
|
||||
authServerIssuer,
|
||||
tokenEndpointUrl,
|
||||
authorizeEndpointUrl,
|
||||
registrationEndpointUrl
|
||||
};
|
||||
};
|
||||
|
||||
export const registerMcpEndpointMetadataRouter = async (server: FastifyZodProvider) => {
|
||||
const appCfg = getConfig();
|
||||
const siteUrl = removeTrailingSlash(appCfg.SITE_URL || "");
|
||||
const siteHost = new URL(siteUrl).host;
|
||||
const scopeAccess = `https://${siteHost}/mcp:access`;
|
||||
|
||||
// OAuth 2.1: Protected Resource metadata
|
||||
// GET /mcp-endpoints/:endpointId/.well-known/oauth-protected-resource
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:endpointId/.well-known/oauth-protected-resource",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
})
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { resourceUrl, authServerIssuer } = getMcpUrls(siteUrl, req.params.endpointId);
|
||||
return reply.send({
|
||||
resource: resourceUrl,
|
||||
authorization_servers: [authServerIssuer],
|
||||
scopes_supported: ["openid", scopeAccess],
|
||||
bearer_methods_supported: ["header"]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// RFC 8414 compliant OAuth Authorization Server metadata
|
||||
// GET /.well-known/oauth-authorization-server/mcp-endpoints/:endpointId
|
||||
export const registerMcpEndpointAuthServerMetadataRouter = async (server: FastifyZodProvider) => {
|
||||
const appCfg = getConfig();
|
||||
const siteUrl = removeTrailingSlash(appCfg.SITE_URL || "");
|
||||
const siteHost = new URL(siteUrl).host;
|
||||
const scopeAccess = `https://${siteHost}/mcp:access`;
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/mcp-endpoints/:endpointId",
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
})
|
||||
},
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { authServerIssuer, authorizeEndpointUrl, tokenEndpointUrl, registrationEndpointUrl } = getMcpUrls(
|
||||
siteUrl,
|
||||
req.params.endpointId
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
issuer: authServerIssuer,
|
||||
authorization_endpoint: authorizeEndpointUrl,
|
||||
token_endpoint: tokenEndpointUrl,
|
||||
registration_endpoint: registrationEndpointUrl,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["openid", scopeAccess]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,12 +1,120 @@
|
||||
import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AiMcpEndpointServerToolsSchema } from "@app/db/schemas/ai-mcp-endpoint-server-tools";
|
||||
import { AiMcpEndpointsSchema } from "@app/db/schemas/ai-mcp-endpoints";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
const sendWwwAuthenticate = (reply: FastifyReply, endpointId: string, description?: string) => {
|
||||
const appCfg = getConfig();
|
||||
const protectedResourceMetadataUrl = `${appCfg.SITE_URL}/mcp-endpoints/${endpointId}/.well-known/oauth-protected-resource`;
|
||||
let header = `Bearer resource_metadata="${protectedResourceMetadataUrl}", scope="openid"`;
|
||||
if (description) header = `${header}, error_description="${description}"`;
|
||||
void reply.header("WWW-Authenticate", header);
|
||||
};
|
||||
|
||||
// Custom onRequest hook to enforce auth while returning proper WWW-Authenticate hint for MCP clients
|
||||
const requireMcpAuthHook = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
done: HookHandlerDoneFunction,
|
||||
endpointId: string
|
||||
) => {
|
||||
const { auth } = req;
|
||||
if (!auth) {
|
||||
sendWwwAuthenticate(reply, endpointId, "Missing authorization header");
|
||||
void reply.status(401).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = auth.authMode === AuthMode.MCP_JWT;
|
||||
if (!allowed) {
|
||||
void reply.status(403).send();
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.authMode === AuthMode.MCP_JWT && !req.permission.orgId) {
|
||||
void reply.status(401).send({ message: "Unauthorized: organization context required" });
|
||||
return;
|
||||
}
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
})
|
||||
},
|
||||
url: "/:endpointId/connect",
|
||||
onRequest: (req, reply, done) => requireMcpAuthHook(req, reply, done, req.params.endpointId),
|
||||
handler: async (req, res) => {
|
||||
await res.hijack(); // allow manual control of the underlying res
|
||||
|
||||
if (req.auth.authMode !== AuthMode.MCP_JWT) {
|
||||
throw new UnauthorizedError({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (req.params.endpointId !== req.auth.token.mcp?.endpointId) {
|
||||
throw new UnauthorizedError({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { server: mcpServer, transport } = await server.services.aiMcpEndpoint.interactWithMcp({
|
||||
endpointId: req.params.endpointId
|
||||
});
|
||||
|
||||
// Close transport when client disconnects
|
||||
res.raw.on("close", () => {
|
||||
void transport.close().catch((err) => {
|
||||
logger.error(err, "Failed to close transport for mcp endpoint");
|
||||
});
|
||||
});
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
await transport.handleRequest(req.raw, res.raw, req.body);
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: ["GET", "DELETE"],
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
url: "/:endpointId/connect",
|
||||
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
})
|
||||
},
|
||||
onRequest: (req, reply, done) => requireMcpAuthHook(req, reply, done, req.params.endpointId),
|
||||
handler: async (_req, res) => {
|
||||
void res
|
||||
.status(405)
|
||||
.header("Allow", "POST")
|
||||
.send({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Method not allowed"
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "POST",
|
||||
@@ -278,4 +386,166 @@ export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) =>
|
||||
return { tools };
|
||||
}
|
||||
});
|
||||
|
||||
// OAUTH 2.0
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:endpointId/oauth/register",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
}),
|
||||
body: z.object({
|
||||
redirect_uris: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.string(),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
client_name: z.string(),
|
||||
client_uri: z.string().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
client_name: z.string(),
|
||||
client_uri: z.string().optional(),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.string(),
|
||||
client_id_issued_at: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const payload = await server.services.aiMcpEndpoint.oauthRegisterClient({
|
||||
endpointId: req.params.endpointId,
|
||||
...req.body
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth authorize - redirect to scope selection page
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:endpointId/oauth/authorize",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
}),
|
||||
querystring: z.object({
|
||||
response_type: z.string(),
|
||||
client_id: z.string(),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.enum(["S256"]),
|
||||
redirect_uri: z.string(),
|
||||
resource: z.string(),
|
||||
state: z.string().optional()
|
||||
})
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
await server.services.aiMcpEndpoint.oauthAuthorizeClient({
|
||||
clientId: req.query.client_id,
|
||||
state: req.query.state
|
||||
});
|
||||
const query = new URLSearchParams({
|
||||
...req.query,
|
||||
endpointId: req.params.endpointId
|
||||
}).toString();
|
||||
|
||||
void res.redirect(`/organization/mcp-endpoint-finalize?${query}`);
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:endpointId/oauth/finalize",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
}),
|
||||
body: z.object({
|
||||
response_type: z.string(),
|
||||
client_id: z.string(),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.enum(["S256"]),
|
||||
redirect_uri: z.string(),
|
||||
resource: z.string(),
|
||||
expireIn: z.string().refine((val) => ms(val) > 0, "Max TTL must be a positive number")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
callbackUrl: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const userInfo = req.auth.authMode === AuthMode.JWT ? req.auth.user : null;
|
||||
if (!userInfo) throw new BadRequestError({ message: "User info not found" });
|
||||
|
||||
const redirectUri = await server.services.aiMcpEndpoint.oauthFinalize({
|
||||
endpointId: req.params.endpointId,
|
||||
clientId: req.body.client_id,
|
||||
codeChallenge: req.body.code_challenge,
|
||||
codeChallengeMethod: req.body.code_challenge_method,
|
||||
redirectUri: req.body.redirect_uri,
|
||||
resource: req.body.resource,
|
||||
responseType: req.body.response_type,
|
||||
projectId: "",
|
||||
tokenId: req.auth.authMode === AuthMode.JWT ? req.auth.tokenVersionId : "",
|
||||
userInfo,
|
||||
expiry: req.body.expireIn,
|
||||
permission: req.permission,
|
||||
userAgent: req.auditLogInfo.userAgent || "",
|
||||
userIp: req.auditLogInfo.ipAddress || ""
|
||||
});
|
||||
|
||||
return { callbackUrl: redirectUri.toString() };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:endpointId/oauth/token",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
endpointId: z.string().trim().min(1)
|
||||
}),
|
||||
body: z.object({
|
||||
grant_type: z.literal("authorization_code"),
|
||||
code: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
code_verifier: z.string(),
|
||||
client_id: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number(),
|
||||
scope: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const payload = await server.services.aiMcpEndpoint.oauthTokenExchange({
|
||||
endpointId: req.params.endpointId,
|
||||
...req.body
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { Server as RawMcpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto as cryptoModule } from "@app/lib/crypto";
|
||||
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { AuthTokenType } from "@app/services/auth/auth-type";
|
||||
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TAiMcpServerDALFactory } from "../ai-mcp-server/ai-mcp-server-dal";
|
||||
import { TAiMcpServerToolDALFactory } from "../ai-mcp-server/ai-mcp-server-tool-dal";
|
||||
import { TAiMcpServerCredentials } from "../ai-mcp-server/ai-mcp-server-types";
|
||||
import { TAiMcpEndpointDALFactory } from "./ai-mcp-endpoint-dal";
|
||||
import { TAiMcpEndpointServerDALFactory } from "./ai-mcp-endpoint-server-dal";
|
||||
import { TAiMcpEndpointServerToolDALFactory } from "./ai-mcp-endpoint-server-tool-dal";
|
||||
@@ -11,8 +31,13 @@ import {
|
||||
TDisableEndpointToolDTO,
|
||||
TEnableEndpointToolDTO,
|
||||
TGetAiMcpEndpointDTO,
|
||||
TInteractWithMcpDTO,
|
||||
TListAiMcpEndpointsDTO,
|
||||
TListEndpointToolsDTO,
|
||||
TOAuthAuthorizeClientDTO,
|
||||
TOAuthFinalizeDTO,
|
||||
TOAuthRegisterClientDTO,
|
||||
TOAuthTokenExchangeDTO,
|
||||
TUpdateAiMcpEndpointDTO
|
||||
} from "./ai-mcp-endpoint-types";
|
||||
|
||||
@@ -20,15 +45,243 @@ type TAiMcpEndpointServiceFactoryDep = {
|
||||
aiMcpEndpointDAL: TAiMcpEndpointDALFactory;
|
||||
aiMcpEndpointServerDAL: TAiMcpEndpointServerDALFactory;
|
||||
aiMcpEndpointServerToolDAL: TAiMcpEndpointServerToolDALFactory;
|
||||
aiMcpServerDAL: TAiMcpServerDALFactory;
|
||||
aiMcpServerToolDAL: TAiMcpServerToolDALFactory;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem">;
|
||||
authTokenService: Pick<TAuthTokenServiceFactory, "getUserTokenSessionById">;
|
||||
};
|
||||
|
||||
// OAuth schemas for parsing cached data
|
||||
const DynamicClientInfoSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uris: z.array(z.string()),
|
||||
client_name: z.string(),
|
||||
client_uri: z.string().optional(),
|
||||
grant_types: z.array(z.string()),
|
||||
response_types: z.array(z.string()),
|
||||
token_endpoint_auth_method: z.string(),
|
||||
client_id_issued_at: z.number(),
|
||||
state: z.string().optional()
|
||||
});
|
||||
|
||||
const OauthChallengeCodeSchema = z.object({
|
||||
codeChallenge: z.string(),
|
||||
codeChallengeMethod: z.string(),
|
||||
userId: z.string(),
|
||||
endpointId: z.string(),
|
||||
expiry: z.string(),
|
||||
redirectUri: z.string(),
|
||||
userInfo: z.object({
|
||||
tokenId: z.string(),
|
||||
orgId: z.string(),
|
||||
authMethod: z.string().nullable(),
|
||||
email: z.string(),
|
||||
actorIp: z.string(),
|
||||
actorName: z.string(),
|
||||
actorUserAgent: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
const OAUTH_FLOW_EXPIRY_IN_SECS = 5 * 60;
|
||||
|
||||
// PKCE challenge computation
|
||||
const computePkceChallenge = (codeVerifier: string) => {
|
||||
const sha256 = crypto.createHash("sha256").update(codeVerifier).digest();
|
||||
return Buffer.from(sha256).toString("base64url");
|
||||
};
|
||||
|
||||
export type TAiMcpEndpointServiceFactory = ReturnType<typeof aiMcpEndpointServiceFactory>;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */
|
||||
export const aiMcpEndpointServiceFactory = ({
|
||||
aiMcpEndpointDAL,
|
||||
aiMcpEndpointServerDAL,
|
||||
aiMcpEndpointServerToolDAL
|
||||
aiMcpEndpointServerToolDAL,
|
||||
aiMcpServerDAL,
|
||||
aiMcpServerToolDAL,
|
||||
kmsService,
|
||||
keyStore,
|
||||
authTokenService
|
||||
}: TAiMcpEndpointServiceFactoryDep) => {
|
||||
const interactWithMcp = async ({ endpointId }: TInteractWithMcpDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
// Get the endpoint
|
||||
const endpoint = await aiMcpEndpointDAL.findById(endpointId);
|
||||
if (!endpoint) {
|
||||
throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` });
|
||||
}
|
||||
|
||||
// Get connected servers for this endpoint
|
||||
const connectedServerLinks = await aiMcpEndpointServerDAL.find({ aiMcpEndpointId: endpointId });
|
||||
|
||||
// Get enabled tools for this endpoint
|
||||
const enabledToolConfigs = await aiMcpEndpointServerToolDAL.find({ aiMcpEndpointId: endpointId });
|
||||
const enabledToolIds = new Set(enabledToolConfigs.map((t) => t.aiMcpServerToolId));
|
||||
|
||||
// Get the actual server details
|
||||
const serverIds = connectedServerLinks.map((link) => link.aiMcpServerId);
|
||||
const servers = await Promise.all(serverIds.map((id) => aiMcpServerDAL.findById(id)));
|
||||
const validServers = servers.filter((s) => s !== null && s !== undefined);
|
||||
|
||||
if (validServers.length === 0) {
|
||||
// Return an empty MCP server if no servers are connected
|
||||
const emptyServer = new RawMcpServer(
|
||||
{
|
||||
name: "infisical-mcp-endpoint",
|
||||
version: appCfg.INFISICAL_PLATFORM_VERSION || "0.0.1"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
emptyServer.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
||||
emptyServer.setRequestHandler(CallToolRequestSchema, () => {
|
||||
throw new Error("No MCP servers connected to this endpoint");
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
return { server: emptyServer, transport };
|
||||
}
|
||||
|
||||
// Create cipher pair for decryption
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: endpoint.projectId
|
||||
});
|
||||
|
||||
// Connect to each server and get their tools
|
||||
const mcpClientTools = await Promise.all(
|
||||
validServers.map(async (mcpServer) => {
|
||||
// Get the database tool records for this server (to map tool names to IDs)
|
||||
const dbServerTools = await aiMcpServerToolDAL.find({ aiMcpServerId: mcpServer.id });
|
||||
// Create a map from tool name to database tool ID for this specific server
|
||||
const toolNameToDbId = new Map(dbServerTools.map((t) => [t.name, t.id]));
|
||||
|
||||
if (!mcpServer.encryptedCredentials) {
|
||||
return { client: null, server: mcpServer, tools: [], toolNameToDbId };
|
||||
}
|
||||
|
||||
const decryptedCredentials = JSON.parse(
|
||||
decryptor({ cipherTextBlob: mcpServer.encryptedCredentials }).toString()
|
||||
) as TAiMcpServerCredentials;
|
||||
|
||||
// Get access token from credentials
|
||||
let accessToken: string | undefined;
|
||||
if ("accessToken" in decryptedCredentials) {
|
||||
accessToken = decryptedCredentials.accessToken;
|
||||
} else if ("token" in decryptedCredentials) {
|
||||
accessToken = decryptedCredentials.token;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new Client({
|
||||
name: `infisical-mcp-client-${mcpServer.name}`,
|
||||
version: "1.0.0"
|
||||
});
|
||||
|
||||
const clientTransport = new StreamableHTTPClientTransport(new URL(mcpServer.url), {
|
||||
requestInit: { headers }
|
||||
});
|
||||
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// Get tools from this server
|
||||
const { tools } = await client.listTools();
|
||||
|
||||
return {
|
||||
client,
|
||||
server: mcpServer,
|
||||
tools: tools as Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }>,
|
||||
toolNameToDbId
|
||||
};
|
||||
} catch {
|
||||
// If connection fails, return empty tools for this server
|
||||
return { client: null, server: mcpServer, tools: [], toolNameToDbId };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter tools to only include explicitly enabled ones (least privilege principle)
|
||||
// If no tools are explicitly enabled, no tools will be available
|
||||
const enabledMcpClientTools = mcpClientTools.map((clientTool) => ({
|
||||
...clientTool,
|
||||
tools: clientTool.tools.filter((tool) => {
|
||||
// Get the database ID for this tool (specific to this server)
|
||||
const dbToolId = clientTool.toolNameToDbId.get(tool.name);
|
||||
// Only include if the database tool ID is explicitly enabled
|
||||
return dbToolId !== undefined && enabledToolIds.has(dbToolId);
|
||||
})
|
||||
}));
|
||||
|
||||
// Create the aggregating MCP server
|
||||
const server = new RawMcpServer(
|
||||
{
|
||||
name: "infisical-mcp-endpoint",
|
||||
version: appCfg.INFISICAL_PLATFORM_VERSION || "0.0.1"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Handle ListTools request - aggregate tools from all connected servers
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: enabledMcpClientTools.flatMap((el) => el.tools)
|
||||
}));
|
||||
|
||||
// Handle CallTool request - route to the appropriate server
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
// Find the server that has this tool
|
||||
const selectedMcpClient = enabledMcpClientTools.find((el) => el.tools.find((t) => t.name === name));
|
||||
if (!selectedMcpClient || !selectedMcpClient.client) {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await selectedMcpClient.client.callTool({
|
||||
name,
|
||||
arguments: args
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true
|
||||
});
|
||||
|
||||
return { server, transport };
|
||||
};
|
||||
|
||||
const createMcpEndpoint = async ({ projectId, name, description, serverIds }: TCreateAiMcpEndpointDTO) => {
|
||||
const endpoint = await aiMcpEndpointDAL.create({
|
||||
projectId,
|
||||
@@ -224,7 +477,183 @@ export const aiMcpEndpointServiceFactory = ({
|
||||
return results;
|
||||
};
|
||||
|
||||
// OAuth 2.0 Methods
|
||||
const oauthRegisterClient = async ({
|
||||
endpointId,
|
||||
client_name,
|
||||
client_uri,
|
||||
grant_types,
|
||||
redirect_uris,
|
||||
response_types,
|
||||
token_endpoint_auth_method
|
||||
}: TOAuthRegisterClientDTO) => {
|
||||
// Verify the endpoint exists
|
||||
const endpoint = await aiMcpEndpointDAL.findById(endpointId);
|
||||
if (!endpoint) {
|
||||
throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` });
|
||||
}
|
||||
|
||||
const clientId = `mcp_client_${crypto.randomBytes(32).toString("hex")}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload = {
|
||||
client_id: clientId,
|
||||
client_name,
|
||||
client_uri,
|
||||
grant_types,
|
||||
redirect_uris,
|
||||
response_types,
|
||||
token_endpoint_auth_method,
|
||||
client_id_issued_at: now
|
||||
};
|
||||
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId),
|
||||
OAUTH_FLOW_EXPIRY_IN_SECS,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const oauthAuthorizeClient = async ({ clientId, state }: TOAuthAuthorizeClientDTO) => {
|
||||
const oauthClientCache = await keyStore.getItem(KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId));
|
||||
if (!oauthClientCache) {
|
||||
throw new UnauthorizedError({ message: `MCP OAuth client with id ${clientId} not found` });
|
||||
}
|
||||
|
||||
// Update with state
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId),
|
||||
OAUTH_FLOW_EXPIRY_IN_SECS,
|
||||
JSON.stringify({ ...JSON.parse(oauthClientCache), state })
|
||||
);
|
||||
};
|
||||
|
||||
const oauthFinalize = async ({
|
||||
endpointId,
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
redirectUri,
|
||||
expiry,
|
||||
tokenId,
|
||||
userInfo,
|
||||
permission,
|
||||
userAgent,
|
||||
userIp
|
||||
}: TOAuthFinalizeDTO) => {
|
||||
const oauthClientCache = await keyStore.getItem(KeyStorePrefixes.AiMcpEndpointOAuthClient(clientId));
|
||||
if (!oauthClientCache) {
|
||||
throw new UnauthorizedError({ message: `MCP OAuth client with id ${clientId} not found` });
|
||||
}
|
||||
|
||||
const oauthClient = await DynamicClientInfoSchema.parseAsync(JSON.parse(oauthClientCache));
|
||||
const isValidRedirectUri = oauthClient.redirect_uris.some((el) => new URL(el).toString() === redirectUri);
|
||||
if (!isValidRedirectUri) throw new BadRequestError({ message: "Redirect URI mismatch" });
|
||||
|
||||
// Verify endpoint exists
|
||||
const endpoint = await aiMcpEndpointDAL.findById(endpointId);
|
||||
if (!endpoint) {
|
||||
throw new NotFoundError({ message: `MCP endpoint with ID '${endpointId}' not found` });
|
||||
}
|
||||
|
||||
const code = crypto.randomBytes(32).toString("hex");
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.AiMcpEndpointOAuthCode(clientId, code),
|
||||
OAUTH_FLOW_EXPIRY_IN_SECS,
|
||||
JSON.stringify({
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
userId: permission.id,
|
||||
endpointId,
|
||||
expiry,
|
||||
redirectUri,
|
||||
userInfo: {
|
||||
tokenId,
|
||||
orgId: permission.orgId,
|
||||
authMethod: permission.authMethod,
|
||||
email: userInfo.email || "",
|
||||
actorIp: userIp,
|
||||
actorName: `${userInfo.firstName || ""} ${userInfo.lastName || ""}`.trim(),
|
||||
actorUserAgent: userAgent
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set("code", code);
|
||||
if (oauthClient.state) url.searchParams.set("state", String(oauthClient.state));
|
||||
return url;
|
||||
};
|
||||
|
||||
const oauthTokenExchange = async (dto: TOAuthTokenExchangeDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
if (dto.grant_type !== "authorization_code") {
|
||||
throw new BadRequestError({ message: "Only authorization_code grant type is supported" });
|
||||
}
|
||||
|
||||
const oauthClientCache = await keyStore.getItem(KeyStorePrefixes.AiMcpEndpointOAuthClient(dto.client_id));
|
||||
if (!oauthClientCache) {
|
||||
throw new UnauthorizedError({ message: `MCP OAuth client with id ${dto.client_id} not found` });
|
||||
}
|
||||
|
||||
const oauthAuthorizeSessionCache = await keyStore.getItem(
|
||||
KeyStorePrefixes.AiMcpEndpointOAuthCode(dto.client_id, dto.code)
|
||||
);
|
||||
if (!oauthAuthorizeSessionCache) {
|
||||
throw new UnauthorizedError({ message: "MCP OAuth session not found" });
|
||||
}
|
||||
|
||||
const oauthAuthorizeInfo = await OauthChallengeCodeSchema.parseAsync(JSON.parse(oauthAuthorizeSessionCache));
|
||||
const isInvalidRedirectUri = dto.redirect_uri !== oauthAuthorizeInfo.redirectUri;
|
||||
if (isInvalidRedirectUri) throw new BadRequestError({ message: "Redirect URI mismatch" });
|
||||
|
||||
// Delete the code (one-time use)
|
||||
await keyStore.deleteItem(KeyStorePrefixes.AiMcpEndpointOAuthCode(dto.client_id, dto.code));
|
||||
|
||||
// Verify PKCE challenge
|
||||
const challenge = computePkceChallenge(dto.code_verifier);
|
||||
if (challenge !== oauthAuthorizeInfo.codeChallenge) {
|
||||
throw new BadRequestError({ message: "PKCE challenge mismatch" });
|
||||
}
|
||||
|
||||
// Verify user session is still valid
|
||||
const tokenSession = await authTokenService.getUserTokenSessionById(
|
||||
oauthAuthorizeInfo.userInfo.tokenId,
|
||||
oauthAuthorizeInfo.userId
|
||||
);
|
||||
if (!tokenSession) throw new UnauthorizedError({ message: "User session not found" });
|
||||
|
||||
// Generate MCP access token
|
||||
const accessToken = cryptoModule.jwt().sign(
|
||||
{
|
||||
authMethod: oauthAuthorizeInfo.userInfo.authMethod,
|
||||
authTokenType: AuthTokenType.ACCESS_TOKEN,
|
||||
userId: oauthAuthorizeInfo.userId,
|
||||
tokenVersionId: tokenSession.id,
|
||||
accessVersion: tokenSession.accessVersion,
|
||||
organizationId: oauthAuthorizeInfo.userInfo.orgId,
|
||||
isMfaVerified: true,
|
||||
mcp: {
|
||||
endpointId: oauthAuthorizeInfo.endpointId
|
||||
}
|
||||
},
|
||||
appCfg.AUTH_SECRET,
|
||||
{ expiresIn: oauthAuthorizeInfo.expiry }
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: Math.floor(ms(oauthAuthorizeInfo.expiry) / 1000),
|
||||
scope: "openid"
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
interactWithMcp,
|
||||
createMcpEndpoint,
|
||||
listMcpEndpoints,
|
||||
getMcpEndpointById,
|
||||
@@ -233,6 +662,10 @@ export const aiMcpEndpointServiceFactory = ({
|
||||
listEndpointTools,
|
||||
enableEndpointTool,
|
||||
disableEndpointTool,
|
||||
bulkUpdateEndpointTools
|
||||
bulkUpdateEndpointTools,
|
||||
oauthRegisterClient,
|
||||
oauthAuthorizeClient,
|
||||
oauthFinalize,
|
||||
oauthTokenExchange
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,10 @@ export type TGetAiMcpEndpointDTO = {
|
||||
endpointId: string;
|
||||
};
|
||||
|
||||
export type TInteractWithMcpDTO = {
|
||||
endpointId: string;
|
||||
};
|
||||
|
||||
export type TListAiMcpEndpointsDTO = {
|
||||
projectId: string;
|
||||
};
|
||||
@@ -64,3 +68,56 @@ export type TEndpointToolConfig = {
|
||||
aiMcpServerToolId: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
// OAuth 2.0 Types
|
||||
export type TOAuthRegisterClientDTO = {
|
||||
endpointId: string;
|
||||
redirect_uris: string[];
|
||||
token_endpoint_auth_method: string;
|
||||
grant_types: string[];
|
||||
response_types: string[];
|
||||
client_name: string;
|
||||
client_uri?: string;
|
||||
};
|
||||
|
||||
export type TOAuthAuthorizeClientDTO = {
|
||||
clientId: string;
|
||||
state?: string;
|
||||
};
|
||||
|
||||
export type TOAuthFinalizeDTO = {
|
||||
endpointId: string;
|
||||
clientId: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
redirectUri: string;
|
||||
resource: string;
|
||||
responseType: string;
|
||||
projectId: string;
|
||||
path?: string;
|
||||
expiry: string;
|
||||
tokenId: string;
|
||||
userInfo: {
|
||||
id: string;
|
||||
email?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
};
|
||||
permission: {
|
||||
type: string;
|
||||
id: string;
|
||||
orgId: string;
|
||||
authMethod: string | null;
|
||||
};
|
||||
userAgent: string;
|
||||
userIp: string;
|
||||
};
|
||||
|
||||
export type TOAuthTokenExchangeDTO = {
|
||||
endpointId: string;
|
||||
grant_type: "authorization_code";
|
||||
code: string;
|
||||
redirect_uri: string;
|
||||
code_verifier: string;
|
||||
client_id: string;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,11 @@ export const KeyStorePrefixes = {
|
||||
|
||||
PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const,
|
||||
|
||||
AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const
|
||||
AiMcpServerOAuth: (sessionId: string) => `ai-mcp-server-oauth:${sessionId}` as const,
|
||||
|
||||
// AI MCP Endpoint OAuth
|
||||
AiMcpEndpointOAuthClient: (clientId: string) => `ai-mcp-endpoint-oauth-client:${clientId}` as const,
|
||||
AiMcpEndpointOAuthCode: (clientId: string, code: string) => `ai-mcp-endpoint-oauth-code:${clientId}:${code}` as const
|
||||
};
|
||||
|
||||
export const KeyStoreTtls = {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
||||
|
||||
export type TAuthMode =
|
||||
| {
|
||||
authMode: AuthMode.JWT;
|
||||
authMode: AuthMode.JWT | AuthMode.MCP_JWT;
|
||||
actor: ActorType.USER;
|
||||
userId: string;
|
||||
tokenVersionId: string; // the session id of token used
|
||||
@@ -91,12 +91,21 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
||||
const decodedToken = crypto.jwt().verify(authTokenValue, jwtSecret) as JwtPayload;
|
||||
|
||||
switch (decodedToken.authTokenType) {
|
||||
case AuthTokenType.ACCESS_TOKEN:
|
||||
case AuthTokenType.ACCESS_TOKEN: {
|
||||
if (decodedToken?.mcp) {
|
||||
return {
|
||||
authMode: AuthMode.MCP_JWT,
|
||||
token: decodedToken as AuthModeJwtTokenPayload,
|
||||
actor: ActorType.USER
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
authMode: AuthMode.JWT,
|
||||
token: decodedToken as AuthModeJwtTokenPayload,
|
||||
actor: ActorType.USER
|
||||
} as const;
|
||||
}
|
||||
case AuthTokenType.API_KEY:
|
||||
// throw new Error("API Key auth is no longer supported.");
|
||||
return { authMode: AuthMode.API_KEY, token: decodedToken, actor: ActorType.USER } as const;
|
||||
@@ -183,6 +192,27 @@ export const injectIdentity = fp(
|
||||
};
|
||||
break;
|
||||
}
|
||||
case AuthMode.MCP_JWT: {
|
||||
const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } =
|
||||
await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector);
|
||||
requestContext.set("orgId", orgId);
|
||||
requestContext.set("orgName", orgName);
|
||||
requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" });
|
||||
req.auth = {
|
||||
authMode: AuthMode.MCP_JWT,
|
||||
user,
|
||||
userId: user.id,
|
||||
tokenVersionId,
|
||||
actor,
|
||||
orgId,
|
||||
rootOrgId,
|
||||
parentOrgId,
|
||||
authMethod: token.authMethod,
|
||||
isMfaVerified: token.isMfaVerified,
|
||||
token
|
||||
};
|
||||
break;
|
||||
}
|
||||
case AuthMode.IDENTITY_ACCESS_TOKEN: {
|
||||
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(
|
||||
token,
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Knex } from "knex";
|
||||
import { monitorEventLoopDelay } from "perf_hooks";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
registerMcpEndpointAuthServerMetadataRouter,
|
||||
registerMcpEndpointMetadataRouter
|
||||
} from "@app/ee/routes/ai/mcp-endpoint-metadata-router";
|
||||
import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router";
|
||||
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
||||
import { registerV2EERoutes } from "@app/ee/routes/v2";
|
||||
@@ -2461,7 +2465,12 @@ export const registerRoutes = async (
|
||||
const aiMcpEndpointService = aiMcpEndpointServiceFactory({
|
||||
aiMcpEndpointDAL,
|
||||
aiMcpEndpointServerDAL,
|
||||
aiMcpEndpointServerToolDAL
|
||||
aiMcpEndpointServerToolDAL,
|
||||
aiMcpServerDAL,
|
||||
aiMcpServerToolDAL,
|
||||
kmsService,
|
||||
keyStore,
|
||||
authTokenService: tokenService
|
||||
});
|
||||
|
||||
const migrationService = externalMigrationServiceFactory({
|
||||
@@ -2760,6 +2769,10 @@ export const registerRoutes = async (
|
||||
|
||||
// register special routes
|
||||
await server.register(registerCertificateEstRouter, { prefix: "/.well-known/est" });
|
||||
await server.register(registerMcpEndpointMetadataRouter, { prefix: "/mcp-endpoints" });
|
||||
await server.register(registerMcpEndpointAuthServerMetadataRouter, {
|
||||
prefix: "/.well-known/oauth-authorization-server"
|
||||
});
|
||||
|
||||
// register routes for v1
|
||||
await server.register(
|
||||
|
||||
@@ -30,7 +30,8 @@ export enum AuthMode {
|
||||
SERVICE_TOKEN = "serviceToken",
|
||||
API_KEY = "apiKey",
|
||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
||||
SCIM_TOKEN = "scimToken"
|
||||
SCIM_TOKEN = "scimToken",
|
||||
MCP_JWT = "mcpJwt"
|
||||
}
|
||||
|
||||
export enum ActorType { // would extend to AWS, Azure, ...
|
||||
@@ -57,6 +58,9 @@ export type AuthModeJwtTokenPayload = {
|
||||
organizationId?: string;
|
||||
isMfaVerified?: boolean;
|
||||
mfaMethod?: MfaMethod;
|
||||
mcp?: {
|
||||
endpointId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AuthModeMfaJwtTokenPayload = {
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
useDeleteAiMcpEndpoint,
|
||||
useDisableEndpointTool,
|
||||
useEnableEndpointTool,
|
||||
useFinalizeMcpEndpointOAuth,
|
||||
useUpdateAiMcpEndpoint
|
||||
} from "./mutations";
|
||||
export {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TDeleteAiMcpEndpointDTO,
|
||||
TDisableEndpointToolDTO,
|
||||
TEnableEndpointToolDTO,
|
||||
TFinalizeMcpEndpointOAuthDTO,
|
||||
TUpdateAiMcpEndpointDTO
|
||||
} from "./types";
|
||||
|
||||
@@ -124,3 +125,15 @@ export const useBulkUpdateEndpointTools = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useFinalizeMcpEndpointOAuth = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ endpointId, ...body }: TFinalizeMcpEndpointOAuthDTO) => {
|
||||
const { data } = await apiRequest.post<{ callbackUrl: string }>(
|
||||
`/api/v1/ai/mcp-endpoints/${endpointId}/oauth/finalize`,
|
||||
body
|
||||
);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -68,3 +68,14 @@ export type TBulkUpdateEndpointToolsDTO = {
|
||||
isEnabled: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TFinalizeMcpEndpointOAuthDTO = {
|
||||
endpointId: string;
|
||||
response_type: string;
|
||||
client_id: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: string;
|
||||
redirect_uri: string;
|
||||
resource: string;
|
||||
expireIn: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { GenericFieldLabel } from "@app/components/v2";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { TAiMcpEndpointWithServerIds } from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
@@ -6,8 +11,18 @@ type Props = {
|
||||
};
|
||||
|
||||
export const MCPEndpointConnectionSection = ({ endpoint }: Props) => {
|
||||
// Generate a mock endpoint URL based on the endpoint name
|
||||
const endpointUrl = `mcp://${endpoint.name.toLowerCase().replace(/\s+/g, "-")}.infisical.com:8080`;
|
||||
const [isCopied, setIsCopied] = useToggle(false);
|
||||
const endpointUrl = `${window.location.origin}/api/v1/ai/mcp-endpoints/${endpoint.id}/connect`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(endpointUrl);
|
||||
setIsCopied.on();
|
||||
createNotification({
|
||||
text: "Endpoint URL copied to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
setTimeout(() => setIsCopied.off(), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
@@ -15,10 +30,24 @@ export const MCPEndpointConnectionSection = ({ endpoint }: Props) => {
|
||||
<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 label="Endpoint URL">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 overflow-hidden rounded border border-mineshaft-500 bg-mineshaft-700">
|
||||
<code className="block overflow-x-auto px-3 py-2 font-mono text-sm whitespace-nowrap text-mineshaft-200">
|
||||
{endpointUrl}
|
||||
</code>
|
||||
</div>
|
||||
<Tooltip content={isCopied ? "Copied!" : "Copy URL"}>
|
||||
<IconButton
|
||||
ariaLabel="Copy endpoint URL"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</GenericFieldLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,10 +39,10 @@ export const MCPServerList = ({ onEditServer, onDeleteServer }: Props) => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="mcp-servers" />}
|
||||
{isLoading && <TableSkeleton columns={3} innerKey="mcp-servers" />}
|
||||
{!isLoading && (!servers || servers.length === 0) && (
|
||||
<Tr>
|
||||
<Td colSpan={4}>
|
||||
<Td colSpan={3}>
|
||||
<EmptyState title="No MCP Servers" />
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheckCircle, faPlug, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useFinalizeMcpEndpointOAuth, useGetAiMcpEndpointById } from "@app/hooks/api";
|
||||
|
||||
const FinalizeFormSchema = z.object({
|
||||
expireIn: z.string().min(1, "Expiration is required")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof FinalizeFormSchema>;
|
||||
|
||||
export const McpEndpointFinalizePage = () => {
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const search = useSearch({
|
||||
from: "/_authenticate/organization/mcp-endpoint-finalize"
|
||||
});
|
||||
|
||||
const { data: endpoint, isLoading: isEndpointLoading } = useGetAiMcpEndpointById({
|
||||
endpointId: search.endpointId
|
||||
});
|
||||
|
||||
const { mutateAsync: finalizeOAuth, isPending } = useFinalizeMcpEndpointOAuth();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(FinalizeFormSchema),
|
||||
defaultValues: {
|
||||
expireIn: "30d"
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async ({ expireIn }: FormData) => {
|
||||
try {
|
||||
const { callbackUrl } = await finalizeOAuth({
|
||||
endpointId: search.endpointId,
|
||||
response_type: search.response_type,
|
||||
client_id: search.client_id,
|
||||
code_challenge: search.code_challenge,
|
||||
code_challenge_method: search.code_challenge_method,
|
||||
redirect_uri: search.redirect_uri,
|
||||
resource: search.resource,
|
||||
expireIn
|
||||
});
|
||||
|
||||
setIsRedirecting(true);
|
||||
window.location.href = callbackUrl;
|
||||
|
||||
// Fallback: try to close the window after 3 seconds if redirect doesn't navigate away
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("Failed to authorize:", error);
|
||||
createNotification({
|
||||
text: "Failed to authorize MCP endpoint access",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isRedirecting) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800">
|
||||
<div className="w-full max-w-md rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8 text-center shadow-lg">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
|
||||
<FontAwesomeIcon icon={faCheckCircle} className="text-3xl text-green-500" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-mineshaft-100">Authorization Successful</h1>
|
||||
<p className="mt-2 text-sm text-bunker-300">
|
||||
<FontAwesomeIcon icon={faSpinner} className="mr-2 animate-spin" />
|
||||
Redirecting back to the application...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>Authorize MCP Endpoint</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
|
||||
<div className="w-full max-w-md rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8 shadow-lg">
|
||||
<Link to="/" className="mb-6 block">
|
||||
<img src="/images/gradientLogo.svg" className="mx-auto h-16" alt="Infisical logo" />
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<FontAwesomeIcon icon={faPlug} className="text-2xl text-primary" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-mineshaft-100">Authorize MCP Access</h1>
|
||||
<p className="mt-2 text-sm text-bunker-300">
|
||||
An external application is requesting access to your MCP endpoint
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEndpointLoading && (
|
||||
<div className="mb-6 animate-pulse rounded-lg border border-mineshaft-600 bg-mineshaft-700 p-4">
|
||||
<div className="h-4 w-1/3 rounded bg-mineshaft-600" />
|
||||
<div className="mt-2 h-3 w-2/3 rounded bg-mineshaft-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isEndpointLoading && endpoint && (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-700 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-bunker-400 uppercase">Endpoint</p>
|
||||
<p className="mt-1 font-medium text-mineshaft-100">{endpoint.name}</p>
|
||||
{endpoint.description && (
|
||||
<p className="mt-1 text-sm text-bunker-300">{endpoint.description}</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-bunker-400">
|
||||
<span>{endpoint.connectedServers} server(s)</span>
|
||||
<span>{endpoint.activeTools} tool(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isEndpointLoading && !endpoint && (
|
||||
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-center">
|
||||
<p className="text-sm text-red-400">Endpoint not found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expireIn"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Access Duration"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="How long the access token should be valid (e.g., 1h, 7d, 30d)"
|
||||
>
|
||||
<Input {...field} placeholder="30d" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
isLoading={isSubmitting || isPending}
|
||||
isDisabled={isSubmitting || isPending || !endpoint}
|
||||
>
|
||||
Authorize
|
||||
</Button>
|
||||
<Link to="/">
|
||||
<Button variant="outline_bg" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-bunker-400">
|
||||
By authorizing, you grant the external application access to interact with the tools
|
||||
available through this MCP endpoint.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { McpEndpointFinalizePage } from "./McpEndpointFinalizePage";
|
||||
|
||||
const McpEndpointFinalizePageQuerySchema = z.object({
|
||||
response_type: z.string(),
|
||||
client_id: z.string(),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.string(),
|
||||
redirect_uri: z.string(),
|
||||
resource: z.string(),
|
||||
state: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
endpointId: z.string()
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_authenticate/organization/mcp-endpoint-finalize")({
|
||||
component: McpEndpointFinalizePage,
|
||||
validateSearch: zodValidator(McpEndpointFinalizePageQuerySchema)
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import { Route as authLoginLdapPageRouteImport } from './pages/auth/LoginLdapPag
|
||||
import { Route as authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route'
|
||||
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
|
||||
import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/route'
|
||||
import { Route as organizationMcpEndpointFinalizePageRouteImport } from './pages/organization/McpEndpointFinalizePage/route'
|
||||
import { Route as authSignUpPageRouteImport } from './pages/auth/SignUpPage/route'
|
||||
import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route'
|
||||
import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect'
|
||||
@@ -539,6 +540,13 @@ const organizationNoOrgPageRouteRoute = organizationNoOrgPageRouteImport.update(
|
||||
} as any,
|
||||
)
|
||||
|
||||
const organizationMcpEndpointFinalizePageRouteRoute =
|
||||
organizationMcpEndpointFinalizePageRouteImport.update({
|
||||
id: '/organization/mcp-endpoint-finalize',
|
||||
path: '/organization/mcp-endpoint-finalize',
|
||||
getParentRoute: () => middlewaresAuthenticateRoute,
|
||||
} as any)
|
||||
|
||||
const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -2455,6 +2463,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof authSignUpPageRouteImport
|
||||
parentRoute: typeof RestrictLoginSignupSignupImport
|
||||
}
|
||||
'/_authenticate/organization/mcp-endpoint-finalize': {
|
||||
id: '/_authenticate/organization/mcp-endpoint-finalize'
|
||||
path: '/organization/mcp-endpoint-finalize'
|
||||
fullPath: '/organization/mcp-endpoint-finalize'
|
||||
preLoaderRoute: typeof organizationMcpEndpointFinalizePageRouteImport
|
||||
parentRoute: typeof middlewaresAuthenticateImport
|
||||
}
|
||||
'/_authenticate/organizations/none': {
|
||||
id: '/_authenticate/organizations/none'
|
||||
path: '/organizations/none'
|
||||
@@ -5219,6 +5234,7 @@ interface middlewaresAuthenticateRouteChildren {
|
||||
authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute
|
||||
middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren
|
||||
AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren
|
||||
organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute
|
||||
}
|
||||
|
||||
@@ -5229,6 +5245,8 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren
|
||||
middlewaresInjectOrgDetailsRouteWithChildren,
|
||||
AuthenticatePersonalSettingsRoute:
|
||||
AuthenticatePersonalSettingsRouteWithChildren,
|
||||
organizationMcpEndpointFinalizePageRouteRoute:
|
||||
organizationMcpEndpointFinalizePageRouteRoute,
|
||||
organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute,
|
||||
}
|
||||
|
||||
@@ -5324,6 +5342,7 @@ export interface FileRoutesByFullPath {
|
||||
'/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
||||
'/login/': typeof authLoginPageRouteRoute
|
||||
'/signup/': typeof authSignUpPageRouteRoute
|
||||
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||
'/admin/signup': typeof adminSignUpPageRouteRoute
|
||||
'/login/admin': typeof authAdminLoginPageRouteRoute
|
||||
@@ -5576,6 +5595,7 @@ export interface FileRoutesByTo {
|
||||
'/personal-settings': typeof userPersonalSettingsPageRouteRoute
|
||||
'/login': typeof authLoginPageRouteRoute
|
||||
'/signup': typeof authSignUpPageRouteRoute
|
||||
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||
'/admin/signup': typeof adminSignUpPageRouteRoute
|
||||
'/login/admin': typeof authAdminLoginPageRouteRoute
|
||||
@@ -5820,6 +5840,7 @@ export interface FileRoutesById {
|
||||
'/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
||||
'/_restrict-login-signup/login/': typeof authLoginPageRouteRoute
|
||||
'/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute
|
||||
'/_authenticate/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||
'/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
|
||||
'/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute
|
||||
@@ -6086,6 +6107,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/login/'
|
||||
| '/signup/'
|
||||
| '/organization/mcp-endpoint-finalize'
|
||||
| '/organizations/none'
|
||||
| '/admin/signup'
|
||||
| '/login/admin'
|
||||
@@ -6337,6 +6359,7 @@ export interface FileRouteTypes {
|
||||
| '/personal-settings'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/organization/mcp-endpoint-finalize'
|
||||
| '/organizations/none'
|
||||
| '/admin/signup'
|
||||
| '/login/admin'
|
||||
@@ -6579,6 +6602,7 @@ export interface FileRouteTypes {
|
||||
| '/_restrict-login-signup/signup'
|
||||
| '/_restrict-login-signup/login/'
|
||||
| '/_restrict-login-signup/signup/'
|
||||
| '/_authenticate/organization/mcp-endpoint-finalize'
|
||||
| '/_authenticate/organizations/none'
|
||||
| '/_restrict-login-signup/admin/signup'
|
||||
| '/_restrict-login-signup/login/admin'
|
||||
@@ -6890,6 +6914,7 @@ export const routeTree = rootRoute
|
||||
"/_authenticate/password-setup",
|
||||
"/_authenticate/_inject-org-details",
|
||||
"/_authenticate/personal-settings",
|
||||
"/_authenticate/organization/mcp-endpoint-finalize",
|
||||
"/_authenticate/organizations/none"
|
||||
]
|
||||
},
|
||||
@@ -6976,6 +7001,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "auth/SignUpPage/route.tsx",
|
||||
"parent": "/_restrict-login-signup/signup"
|
||||
},
|
||||
"/_authenticate/organization/mcp-endpoint-finalize": {
|
||||
"filePath": "organization/McpEndpointFinalizePage/route.tsx",
|
||||
"parent": "/_authenticate"
|
||||
},
|
||||
"/_authenticate/organizations/none": {
|
||||
"filePath": "organization/NoOrgPage/route.tsx",
|
||||
"parent": "/_authenticate"
|
||||
|
||||
@@ -445,6 +445,7 @@ export const routes = rootRoute("root.tsx", [
|
||||
layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")])
|
||||
]),
|
||||
route("/organizations/none", "organization/NoOrgPage/route.tsx"),
|
||||
route("/organization/mcp-endpoint-finalize", "organization/McpEndpointFinalizePage/route.tsx"),
|
||||
middleware("inject-org-details.tsx", [
|
||||
route("/organization/$", "redirects/organization-redirect.tsx"),
|
||||
route("/projects/$", "redirects/project-redirect.tsx"),
|
||||
|
||||
@@ -11,7 +11,7 @@ server {
|
||||
location ~ ^/(api|secret-scanning/webhooks) {
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
@@ -24,7 +24,7 @@ server {
|
||||
location /runtime-ui-env.js {
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
@@ -39,7 +39,7 @@ server {
|
||||
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
@@ -49,7 +49,7 @@ server {
|
||||
proxy_cookie_path / "/; HttpOnly; SameSite=strict";
|
||||
}
|
||||
|
||||
location /.well-known/est {
|
||||
location ~ /\.well-known {
|
||||
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -70,13 +70,13 @@ server {
|
||||
|
||||
location / {
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user