mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-08 23:18:05 -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 { z } from "zod";
|
||||||
|
|
||||||
import { AiMcpEndpointServerToolsSchema } from "@app/db/schemas/ai-mcp-endpoint-server-tools";
|
import { AiMcpEndpointServerToolsSchema } from "@app/db/schemas/ai-mcp-endpoint-server-tools";
|
||||||
import { AiMcpEndpointsSchema } from "@app/db/schemas/ai-mcp-endpoints";
|
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 { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
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) => {
|
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({
|
server.route({
|
||||||
url: "/",
|
url: "/",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -278,4 +386,166 @@ export const registerAiMcpEndpointRouter = async (server: FastifyZodProvider) =>
|
|||||||
return { tools };
|
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 { TAiMcpEndpointDALFactory } from "./ai-mcp-endpoint-dal";
|
||||||
import { TAiMcpEndpointServerDALFactory } from "./ai-mcp-endpoint-server-dal";
|
import { TAiMcpEndpointServerDALFactory } from "./ai-mcp-endpoint-server-dal";
|
||||||
import { TAiMcpEndpointServerToolDALFactory } from "./ai-mcp-endpoint-server-tool-dal";
|
import { TAiMcpEndpointServerToolDALFactory } from "./ai-mcp-endpoint-server-tool-dal";
|
||||||
@@ -11,8 +31,13 @@ import {
|
|||||||
TDisableEndpointToolDTO,
|
TDisableEndpointToolDTO,
|
||||||
TEnableEndpointToolDTO,
|
TEnableEndpointToolDTO,
|
||||||
TGetAiMcpEndpointDTO,
|
TGetAiMcpEndpointDTO,
|
||||||
|
TInteractWithMcpDTO,
|
||||||
TListAiMcpEndpointsDTO,
|
TListAiMcpEndpointsDTO,
|
||||||
TListEndpointToolsDTO,
|
TListEndpointToolsDTO,
|
||||||
|
TOAuthAuthorizeClientDTO,
|
||||||
|
TOAuthFinalizeDTO,
|
||||||
|
TOAuthRegisterClientDTO,
|
||||||
|
TOAuthTokenExchangeDTO,
|
||||||
TUpdateAiMcpEndpointDTO
|
TUpdateAiMcpEndpointDTO
|
||||||
} from "./ai-mcp-endpoint-types";
|
} from "./ai-mcp-endpoint-types";
|
||||||
|
|
||||||
@@ -20,15 +45,243 @@ type TAiMcpEndpointServiceFactoryDep = {
|
|||||||
aiMcpEndpointDAL: TAiMcpEndpointDALFactory;
|
aiMcpEndpointDAL: TAiMcpEndpointDALFactory;
|
||||||
aiMcpEndpointServerDAL: TAiMcpEndpointServerDALFactory;
|
aiMcpEndpointServerDAL: TAiMcpEndpointServerDALFactory;
|
||||||
aiMcpEndpointServerToolDAL: TAiMcpEndpointServerToolDALFactory;
|
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>;
|
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 = ({
|
export const aiMcpEndpointServiceFactory = ({
|
||||||
aiMcpEndpointDAL,
|
aiMcpEndpointDAL,
|
||||||
aiMcpEndpointServerDAL,
|
aiMcpEndpointServerDAL,
|
||||||
aiMcpEndpointServerToolDAL
|
aiMcpEndpointServerToolDAL,
|
||||||
|
aiMcpServerDAL,
|
||||||
|
aiMcpServerToolDAL,
|
||||||
|
kmsService,
|
||||||
|
keyStore,
|
||||||
|
authTokenService
|
||||||
}: TAiMcpEndpointServiceFactoryDep) => {
|
}: 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 createMcpEndpoint = async ({ projectId, name, description, serverIds }: TCreateAiMcpEndpointDTO) => {
|
||||||
const endpoint = await aiMcpEndpointDAL.create({
|
const endpoint = await aiMcpEndpointDAL.create({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -224,7 +477,183 @@ export const aiMcpEndpointServiceFactory = ({
|
|||||||
return results;
|
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 {
|
return {
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: Math.floor(ms(oauthAuthorizeInfo.expiry) / 1000),
|
||||||
|
scope: "openid"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
interactWithMcp,
|
||||||
createMcpEndpoint,
|
createMcpEndpoint,
|
||||||
listMcpEndpoints,
|
listMcpEndpoints,
|
||||||
getMcpEndpointById,
|
getMcpEndpointById,
|
||||||
@@ -233,6 +662,10 @@ export const aiMcpEndpointServiceFactory = ({
|
|||||||
listEndpointTools,
|
listEndpointTools,
|
||||||
enableEndpointTool,
|
enableEndpointTool,
|
||||||
disableEndpointTool,
|
disableEndpointTool,
|
||||||
bulkUpdateEndpointTools
|
bulkUpdateEndpointTools,
|
||||||
|
oauthRegisterClient,
|
||||||
|
oauthAuthorizeClient,
|
||||||
|
oauthFinalize,
|
||||||
|
oauthTokenExchange
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export type TGetAiMcpEndpointDTO = {
|
|||||||
endpointId: string;
|
endpointId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TInteractWithMcpDTO = {
|
||||||
|
endpointId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TListAiMcpEndpointsDTO = {
|
export type TListAiMcpEndpointsDTO = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
@@ -64,3 +68,56 @@ export type TEndpointToolConfig = {
|
|||||||
aiMcpServerToolId: string;
|
aiMcpServerToolId: string;
|
||||||
isEnabled: boolean;
|
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,
|
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 = {
|
export const KeyStoreTtls = {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
|||||||
|
|
||||||
export type TAuthMode =
|
export type TAuthMode =
|
||||||
| {
|
| {
|
||||||
authMode: AuthMode.JWT;
|
authMode: AuthMode.JWT | AuthMode.MCP_JWT;
|
||||||
actor: ActorType.USER;
|
actor: ActorType.USER;
|
||||||
userId: string;
|
userId: string;
|
||||||
tokenVersionId: string; // the session id of token used
|
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;
|
const decodedToken = crypto.jwt().verify(authTokenValue, jwtSecret) as JwtPayload;
|
||||||
|
|
||||||
switch (decodedToken.authTokenType) {
|
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 {
|
return {
|
||||||
authMode: AuthMode.JWT,
|
authMode: AuthMode.JWT,
|
||||||
token: decodedToken as AuthModeJwtTokenPayload,
|
token: decodedToken as AuthModeJwtTokenPayload,
|
||||||
actor: ActorType.USER
|
actor: ActorType.USER
|
||||||
} as const;
|
} as const;
|
||||||
|
}
|
||||||
case AuthTokenType.API_KEY:
|
case AuthTokenType.API_KEY:
|
||||||
// throw new Error("API Key auth is no longer supported.");
|
// throw new Error("API Key auth is no longer supported.");
|
||||||
return { authMode: AuthMode.API_KEY, token: decodedToken, actor: ActorType.USER } as const;
|
return { authMode: AuthMode.API_KEY, token: decodedToken, actor: ActorType.USER } as const;
|
||||||
@@ -183,6 +192,27 @@ export const injectIdentity = fp(
|
|||||||
};
|
};
|
||||||
break;
|
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: {
|
case AuthMode.IDENTITY_ACCESS_TOKEN: {
|
||||||
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(
|
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { Knex } from "knex";
|
|||||||
import { monitorEventLoopDelay } from "perf_hooks";
|
import { monitorEventLoopDelay } from "perf_hooks";
|
||||||
import { z } from "zod";
|
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 { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router";
|
||||||
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
||||||
import { registerV2EERoutes } from "@app/ee/routes/v2";
|
import { registerV2EERoutes } from "@app/ee/routes/v2";
|
||||||
@@ -2461,7 +2465,12 @@ export const registerRoutes = async (
|
|||||||
const aiMcpEndpointService = aiMcpEndpointServiceFactory({
|
const aiMcpEndpointService = aiMcpEndpointServiceFactory({
|
||||||
aiMcpEndpointDAL,
|
aiMcpEndpointDAL,
|
||||||
aiMcpEndpointServerDAL,
|
aiMcpEndpointServerDAL,
|
||||||
aiMcpEndpointServerToolDAL
|
aiMcpEndpointServerToolDAL,
|
||||||
|
aiMcpServerDAL,
|
||||||
|
aiMcpServerToolDAL,
|
||||||
|
kmsService,
|
||||||
|
keyStore,
|
||||||
|
authTokenService: tokenService
|
||||||
});
|
});
|
||||||
|
|
||||||
const migrationService = externalMigrationServiceFactory({
|
const migrationService = externalMigrationServiceFactory({
|
||||||
@@ -2760,6 +2769,10 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
// register special routes
|
// register special routes
|
||||||
await server.register(registerCertificateEstRouter, { prefix: "/.well-known/est" });
|
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
|
// register routes for v1
|
||||||
await server.register(
|
await server.register(
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export enum AuthMode {
|
|||||||
SERVICE_TOKEN = "serviceToken",
|
SERVICE_TOKEN = "serviceToken",
|
||||||
API_KEY = "apiKey",
|
API_KEY = "apiKey",
|
||||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
||||||
SCIM_TOKEN = "scimToken"
|
SCIM_TOKEN = "scimToken",
|
||||||
|
MCP_JWT = "mcpJwt"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ActorType { // would extend to AWS, Azure, ...
|
export enum ActorType { // would extend to AWS, Azure, ...
|
||||||
@@ -57,6 +58,9 @@ export type AuthModeJwtTokenPayload = {
|
|||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
isMfaVerified?: boolean;
|
isMfaVerified?: boolean;
|
||||||
mfaMethod?: MfaMethod;
|
mfaMethod?: MfaMethod;
|
||||||
|
mcp?: {
|
||||||
|
endpointId: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthModeMfaJwtTokenPayload = {
|
export type AuthModeMfaJwtTokenPayload = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
useDeleteAiMcpEndpoint,
|
useDeleteAiMcpEndpoint,
|
||||||
useDisableEndpointTool,
|
useDisableEndpointTool,
|
||||||
useEnableEndpointTool,
|
useEnableEndpointTool,
|
||||||
|
useFinalizeMcpEndpointOAuth,
|
||||||
useUpdateAiMcpEndpoint
|
useUpdateAiMcpEndpoint
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TDeleteAiMcpEndpointDTO,
|
TDeleteAiMcpEndpointDTO,
|
||||||
TDisableEndpointToolDTO,
|
TDisableEndpointToolDTO,
|
||||||
TEnableEndpointToolDTO,
|
TEnableEndpointToolDTO,
|
||||||
|
TFinalizeMcpEndpointOAuthDTO,
|
||||||
TUpdateAiMcpEndpointDTO
|
TUpdateAiMcpEndpointDTO
|
||||||
} from "./types";
|
} 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;
|
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";
|
import { TAiMcpEndpointWithServerIds } from "@app/hooks/api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -6,8 +11,18 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MCPEndpointConnectionSection = ({ endpoint }: Props) => {
|
export const MCPEndpointConnectionSection = ({ endpoint }: Props) => {
|
||||||
// Generate a mock endpoint URL based on the endpoint name
|
const [isCopied, setIsCopied] = useToggle(false);
|
||||||
const endpointUrl = `mcp://${endpoint.name.toLowerCase().replace(/\s+/g, "-")}.infisical.com:8080`;
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
<div className="flex 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>
|
<h3 className="font-medium text-mineshaft-100">Connection</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<GenericFieldLabel label="Endpoint">
|
<GenericFieldLabel label="Endpoint URL">
|
||||||
<code className="rounded bg-mineshaft-700 px-2 py-1 font-mono text-sm text-mineshaft-200">
|
<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}
|
{endpointUrl}
|
||||||
</code>
|
</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>
|
</GenericFieldLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export const MCPServerList = ({ onEditServer, onDeleteServer }: Props) => {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{isLoading && <TableSkeleton columns={4} innerKey="mcp-servers" />}
|
{isLoading && <TableSkeleton columns={3} innerKey="mcp-servers" />}
|
||||||
{!isLoading && (!servers || servers.length === 0) && (
|
{!isLoading && (!servers || servers.length === 0) && (
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td colSpan={4}>
|
<Td colSpan={3}>
|
||||||
<EmptyState title="No MCP Servers" />
|
<EmptyState title="No MCP Servers" />
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</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 authAdminLoginPageRouteImport } from './pages/auth/AdminLoginPage/route'
|
||||||
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
|
import { Route as adminSignUpPageRouteImport } from './pages/admin/SignUpPage/route'
|
||||||
import { Route as organizationNoOrgPageRouteImport } from './pages/organization/NoOrgPage/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 authSignUpPageRouteImport } from './pages/auth/SignUpPage/route'
|
||||||
import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route'
|
import { Route as authLoginPageRouteImport } from './pages/auth/LoginPage/route'
|
||||||
import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect'
|
import { Route as redirectsProjectRedirectImport } from './pages/redirects/project-redirect'
|
||||||
@@ -539,6 +540,13 @@ const organizationNoOrgPageRouteRoute = organizationNoOrgPageRouteImport.update(
|
|||||||
} as any,
|
} as any,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const organizationMcpEndpointFinalizePageRouteRoute =
|
||||||
|
organizationMcpEndpointFinalizePageRouteImport.update({
|
||||||
|
id: '/organization/mcp-endpoint-finalize',
|
||||||
|
path: '/organization/mcp-endpoint-finalize',
|
||||||
|
getParentRoute: () => middlewaresAuthenticateRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({
|
const authSignUpPageRouteRoute = authSignUpPageRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -2455,6 +2463,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof authSignUpPageRouteImport
|
preLoaderRoute: typeof authSignUpPageRouteImport
|
||||||
parentRoute: typeof RestrictLoginSignupSignupImport
|
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': {
|
'/_authenticate/organizations/none': {
|
||||||
id: '/_authenticate/organizations/none'
|
id: '/_authenticate/organizations/none'
|
||||||
path: '/organizations/none'
|
path: '/organizations/none'
|
||||||
@@ -5219,6 +5234,7 @@ interface middlewaresAuthenticateRouteChildren {
|
|||||||
authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute
|
authPasswordSetupPageRouteRoute: typeof authPasswordSetupPageRouteRoute
|
||||||
middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren
|
middlewaresInjectOrgDetailsRoute: typeof middlewaresInjectOrgDetailsRouteWithChildren
|
||||||
AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren
|
AuthenticatePersonalSettingsRoute: typeof AuthenticatePersonalSettingsRouteWithChildren
|
||||||
|
organizationMcpEndpointFinalizePageRouteRoute: typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||||
organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute
|
organizationNoOrgPageRouteRoute: typeof organizationNoOrgPageRouteRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5229,6 +5245,8 @@ const middlewaresAuthenticateRouteChildren: middlewaresAuthenticateRouteChildren
|
|||||||
middlewaresInjectOrgDetailsRouteWithChildren,
|
middlewaresInjectOrgDetailsRouteWithChildren,
|
||||||
AuthenticatePersonalSettingsRoute:
|
AuthenticatePersonalSettingsRoute:
|
||||||
AuthenticatePersonalSettingsRouteWithChildren,
|
AuthenticatePersonalSettingsRouteWithChildren,
|
||||||
|
organizationMcpEndpointFinalizePageRouteRoute:
|
||||||
|
organizationMcpEndpointFinalizePageRouteRoute,
|
||||||
organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute,
|
organizationNoOrgPageRouteRoute: organizationNoOrgPageRouteRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5324,6 +5342,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
'/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
||||||
'/login/': typeof authLoginPageRouteRoute
|
'/login/': typeof authLoginPageRouteRoute
|
||||||
'/signup/': typeof authSignUpPageRouteRoute
|
'/signup/': typeof authSignUpPageRouteRoute
|
||||||
|
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||||
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||||
'/admin/signup': typeof adminSignUpPageRouteRoute
|
'/admin/signup': typeof adminSignUpPageRouteRoute
|
||||||
'/login/admin': typeof authAdminLoginPageRouteRoute
|
'/login/admin': typeof authAdminLoginPageRouteRoute
|
||||||
@@ -5576,6 +5595,7 @@ export interface FileRoutesByTo {
|
|||||||
'/personal-settings': typeof userPersonalSettingsPageRouteRoute
|
'/personal-settings': typeof userPersonalSettingsPageRouteRoute
|
||||||
'/login': typeof authLoginPageRouteRoute
|
'/login': typeof authLoginPageRouteRoute
|
||||||
'/signup': typeof authSignUpPageRouteRoute
|
'/signup': typeof authSignUpPageRouteRoute
|
||||||
|
'/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||||
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
'/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||||
'/admin/signup': typeof adminSignUpPageRouteRoute
|
'/admin/signup': typeof adminSignUpPageRouteRoute
|
||||||
'/login/admin': typeof authAdminLoginPageRouteRoute
|
'/login/admin': typeof authAdminLoginPageRouteRoute
|
||||||
@@ -5820,6 +5840,7 @@ export interface FileRoutesById {
|
|||||||
'/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
'/_restrict-login-signup/signup': typeof RestrictLoginSignupSignupRouteWithChildren
|
||||||
'/_restrict-login-signup/login/': typeof authLoginPageRouteRoute
|
'/_restrict-login-signup/login/': typeof authLoginPageRouteRoute
|
||||||
'/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute
|
'/_restrict-login-signup/signup/': typeof authSignUpPageRouteRoute
|
||||||
|
'/_authenticate/organization/mcp-endpoint-finalize': typeof organizationMcpEndpointFinalizePageRouteRoute
|
||||||
'/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute
|
'/_authenticate/organizations/none': typeof organizationNoOrgPageRouteRoute
|
||||||
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
|
'/_restrict-login-signup/admin/signup': typeof adminSignUpPageRouteRoute
|
||||||
'/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute
|
'/_restrict-login-signup/login/admin': typeof authAdminLoginPageRouteRoute
|
||||||
@@ -6086,6 +6107,7 @@ export interface FileRouteTypes {
|
|||||||
| '/signup'
|
| '/signup'
|
||||||
| '/login/'
|
| '/login/'
|
||||||
| '/signup/'
|
| '/signup/'
|
||||||
|
| '/organization/mcp-endpoint-finalize'
|
||||||
| '/organizations/none'
|
| '/organizations/none'
|
||||||
| '/admin/signup'
|
| '/admin/signup'
|
||||||
| '/login/admin'
|
| '/login/admin'
|
||||||
@@ -6337,6 +6359,7 @@ export interface FileRouteTypes {
|
|||||||
| '/personal-settings'
|
| '/personal-settings'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/organization/mcp-endpoint-finalize'
|
||||||
| '/organizations/none'
|
| '/organizations/none'
|
||||||
| '/admin/signup'
|
| '/admin/signup'
|
||||||
| '/login/admin'
|
| '/login/admin'
|
||||||
@@ -6579,6 +6602,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_restrict-login-signup/signup'
|
| '/_restrict-login-signup/signup'
|
||||||
| '/_restrict-login-signup/login/'
|
| '/_restrict-login-signup/login/'
|
||||||
| '/_restrict-login-signup/signup/'
|
| '/_restrict-login-signup/signup/'
|
||||||
|
| '/_authenticate/organization/mcp-endpoint-finalize'
|
||||||
| '/_authenticate/organizations/none'
|
| '/_authenticate/organizations/none'
|
||||||
| '/_restrict-login-signup/admin/signup'
|
| '/_restrict-login-signup/admin/signup'
|
||||||
| '/_restrict-login-signup/login/admin'
|
| '/_restrict-login-signup/login/admin'
|
||||||
@@ -6890,6 +6914,7 @@ export const routeTree = rootRoute
|
|||||||
"/_authenticate/password-setup",
|
"/_authenticate/password-setup",
|
||||||
"/_authenticate/_inject-org-details",
|
"/_authenticate/_inject-org-details",
|
||||||
"/_authenticate/personal-settings",
|
"/_authenticate/personal-settings",
|
||||||
|
"/_authenticate/organization/mcp-endpoint-finalize",
|
||||||
"/_authenticate/organizations/none"
|
"/_authenticate/organizations/none"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -6976,6 +7001,10 @@ export const routeTree = rootRoute
|
|||||||
"filePath": "auth/SignUpPage/route.tsx",
|
"filePath": "auth/SignUpPage/route.tsx",
|
||||||
"parent": "/_restrict-login-signup/signup"
|
"parent": "/_restrict-login-signup/signup"
|
||||||
},
|
},
|
||||||
|
"/_authenticate/organization/mcp-endpoint-finalize": {
|
||||||
|
"filePath": "organization/McpEndpointFinalizePage/route.tsx",
|
||||||
|
"parent": "/_authenticate"
|
||||||
|
},
|
||||||
"/_authenticate/organizations/none": {
|
"/_authenticate/organizations/none": {
|
||||||
"filePath": "organization/NoOrgPage/route.tsx",
|
"filePath": "organization/NoOrgPage/route.tsx",
|
||||||
"parent": "/_authenticate"
|
"parent": "/_authenticate"
|
||||||
|
|||||||
@@ -445,6 +445,7 @@ export const routes = rootRoute("root.tsx", [
|
|||||||
layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")])
|
layout("user/layout.tsx", [index("user/PersonalSettingsPage/route.tsx")])
|
||||||
]),
|
]),
|
||||||
route("/organizations/none", "organization/NoOrgPage/route.tsx"),
|
route("/organizations/none", "organization/NoOrgPage/route.tsx"),
|
||||||
|
route("/organization/mcp-endpoint-finalize", "organization/McpEndpointFinalizePage/route.tsx"),
|
||||||
middleware("inject-org-details.tsx", [
|
middleware("inject-org-details.tsx", [
|
||||||
route("/organization/$", "redirects/organization-redirect.tsx"),
|
route("/organization/$", "redirects/organization-redirect.tsx"),
|
||||||
route("/projects/$", "redirects/project-redirect.tsx"),
|
route("/projects/$", "redirects/project-redirect.tsx"),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ server {
|
|||||||
proxy_cookie_path / "/; HttpOnly; SameSite=strict";
|
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-Real-RIP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
Reference in New Issue
Block a user