diff --git a/.review/pr-8274 b/.review/pr-8274 new file mode 160000 index 000000000..e46929b8d --- /dev/null +++ b/.review/pr-8274 @@ -0,0 +1 @@ +Subproject commit e46929b8d8add0cd3c412d69f8ac882c405a4ba9 diff --git a/docs/mcp-certificate-trust.md b/docs/mcp-certificate-trust.md new file mode 100644 index 000000000..2b827c0cf --- /dev/null +++ b/docs/mcp-certificate-trust.md @@ -0,0 +1,168 @@ +# MCP Server Certificate Trust Configuration + +This document describes how to configure certificate trust settings for MCP servers that use HTTPS connections (SSE and StreamableHTTP transports). + +## Overview + +When connecting to MCP servers over HTTPS, you may encounter servers that use: + +- Self-signed certificates +- Certificates signed by internal/corporate Certificate Authorities (CAs) +- Certificates that would otherwise be rejected by default Node.js certificate validation + +The certificate trust configuration allows you to specify how these certificates should be handled. + +## Configuration Options + +Certificate trust settings can be added to any SSE or StreamableHTTP server configuration in your MCP settings file. + +### Available Options + +| Option | Type | Default | Description | +| -------------------- | ------- | --------- | ----------------------------------------------------------- | +| `allowSelfSigned` | boolean | false | Allow connections to servers using self-signed certificates | +| `caCertPath` | string | undefined | Path to a CA certificate file (PEM format) to trust | +| `rejectUnauthorized` | boolean | true | Whether to reject unauthorized certificates | + +## Configuration Examples + +### 1. Allow Self-Signed Certificates + +```json +{ + "mcpServers": { + "my-internal-server": { + "type": "sse", + "url": "https://internal.company.com/mcp", + "certificateTrust": { + "allowSelfSigned": true + } + } + } +} +``` + +### 2. Trust a Custom CA Certificate + +```json +{ + "mcpServers": { + "corporate-server": { + "type": "streamable-http", + "url": "https://api.internal.corp/mcp", + "certificateTrust": { + "caCertPath": "/path/to/company-ca.pem" + } + } + } +} +``` + +### 3. Disable Certificate Validation (Development Only) + +⚠️ **Warning**: This configuration disables certificate validation entirely and should only be used in development environments. + +```json +{ + "mcpServers": { + "dev-server": { + "type": "sse", + "url": "https://dev.local:8443/mcp", + "certificateTrust": { + "rejectUnauthorized": false + } + } + } +} +``` + +### 4. Combined Configuration + +```json +{ + "mcpServers": { + "complex-server": { + "type": "sse", + "url": "https://secure.internal.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "certificateTrust": { + "allowSelfSigned": true, + "caCertPath": "/etc/ssl/certs/internal-ca.pem", + "rejectUnauthorized": false + } + } + } +} +``` + +## Obtaining CA Certificates + +### From System Certificate Store + +On many systems, CA certificates are stored in standard locations: + +- **Linux**: `/etc/ssl/certs/` or `/usr/share/ca-certificates/` +- **macOS**: Can be exported from Keychain Access +- **Windows**: Can be exported from Certificate Manager (certmgr.msc) + +### From Your IT Department + +For corporate environments, contact your IT department to obtain: + +1. The internal CA certificate (usually in PEM or CRT format) +2. Instructions on where to save it securely +3. Any specific certificate validation requirements + +### Converting Certificate Formats + +If you have a certificate in DER/CER format, convert it to PEM: + +```bash +openssl x509 -inform der -in certificate.cer -out certificate.pem +``` + +## Security Considerations + +1. **Production Environments**: Always use proper certificates signed by trusted CAs in production. + +2. **Certificate Validation**: Only disable `rejectUnauthorized` in development environments where security is not a concern. + +3. **CA Certificate Storage**: Store CA certificate files in a secure location with appropriate file permissions. + +4. **Regular Updates**: Keep CA certificates up to date, especially for internal CAs that may rotate periodically. + +## Troubleshooting + +### Common Error Messages + +1. **"UNABLE_TO_VERIFY_LEAF_SIGNATURE"**: The server's certificate cannot be verified. Consider adding the CA certificate using `caCertPath`. + +2. **"SELF_SIGNED_CERT_IN_CHAIN"**: The certificate chain contains a self-signed certificate. Set `allowSelfSigned: true` if this is expected. + +3. **"CERT_HAS_EXPIRED"**: The certificate has expired. Contact the server administrator to renew it. + +### Debugging Certificate Issues + +To debug certificate issues, you can test the connection using OpenSSL: + +```bash +# View server certificate +openssl s_client -connect hostname:port -showcerts + +# Test with a specific CA certificate +openssl s_client -connect hostname:port -CAfile /path/to/ca.pem +``` + +## Limitations + +- Certificate trust settings only apply to SSE and StreamableHTTP transports +- STDIO transport servers do not use HTTPS and therefore don't need certificate configuration +- The configuration requires Node.js environment; browser-based implementations may have different requirements +- Test webhook commit 17 + +## Related Documentation + +- [MCP Server Configuration](./mcp-servers.md) +- [Model Context Protocol Specification](https://modelcontextprotocol.io) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index caca5ddb3..269431539 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -65,6 +65,16 @@ const BaseConfigSchema = z.object({ disabledTools: z.array(z.string()).default([]), }) +// Certificate trust configuration schema +const CertificateTrustSchema = z.object({ + // Allow self-signed certificates + allowSelfSigned: z.boolean().optional(), + // Path to CA certificate file (PEM format) + caCertPath: z.string().optional(), + // Reject unauthorized certificates (default: true for security) + rejectUnauthorized: z.boolean().optional().default(true), +}) + // Custom error messages for better user feedback const typeErrorMessage = "Server type must be 'stdio', 'sse', or 'streamable-http'" const stdioFieldsErrorMessage = @@ -102,6 +112,8 @@ const createServerTypeSchema = () => { type: z.enum(["sse"]).optional(), url: z.string().url("URL must be a valid URL format"), headers: z.record(z.string()).optional(), + // Certificate trust configuration for HTTPS connections + certificateTrust: CertificateTrustSchema.optional(), // Ensure no stdio fields are present command: z.undefined().optional(), args: z.undefined().optional(), @@ -117,6 +129,8 @@ const createServerTypeSchema = () => { type: z.enum(["streamable-http"]).optional(), url: z.string().url("URL must be a valid URL format"), headers: z.record(z.string()).optional(), + // Certificate trust configuration for HTTPS connections + certificateTrust: CertificateTrustSchema.optional(), // Ensure no stdio fields are present command: z.undefined().optional(), args: z.undefined().optional(), @@ -735,10 +749,47 @@ export class McpHub { } } else if (configInjected.type === "streamable-http") { // Streamable HTTP connection + const requestInit: RequestInit = { + headers: configInjected.headers, + } + + // Apply certificate trust settings if configured + if (configInjected.certificateTrust) { + const { allowSelfSigned, caCertPath, rejectUnauthorized } = configInjected.certificateTrust + + // For Node.js fetch, we need to configure the agent + if (typeof process !== "undefined" && process.versions && process.versions.node) { + const https = await import("https") + const fs = await import("fs/promises") + + const agentOptions: any = {} + + // Handle certificate rejection + if (rejectUnauthorized === false || allowSelfSigned === true) { + agentOptions.rejectUnauthorized = false + } + + // Load CA certificate if provided + if (caCertPath) { + try { + const caCert = await fs.readFile(caCertPath, "utf-8") + agentOptions.ca = caCert + } catch (error) { + console.error(`Failed to load CA certificate from ${caCertPath}:`, error) + throw new Error( + `Failed to load CA certificate: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + // Create HTTPS agent with certificate trust settings + const agent = new https.Agent(agentOptions) + ;(requestInit as any).agent = agent + } + } + transport = new StreamableHTTPClientTransport(new URL(configInjected.url), { - requestInit: { - headers: configInjected.headers, - }, + requestInit, }) // Set up Streamable HTTP specific error handling @@ -766,18 +817,67 @@ export class McpHub { headers: configInjected.headers, }, } + // Configure ReconnectingEventSource options - const reconnectingEventSourceOptions = { + const reconnectingEventSourceOptions: any = { max_retry_time: 5000, // Maximum retry time in milliseconds withCredentials: configInjected.headers?.["Authorization"] ? true : false, // Enable credentials if Authorization header exists - fetch: (url: string | URL, init: RequestInit) => { + } + + // Apply certificate trust settings if configured + if (configInjected.certificateTrust) { + const { allowSelfSigned, caCertPath, rejectUnauthorized } = configInjected.certificateTrust + + // For Node.js fetch used by ReconnectingEventSource + if (typeof process !== "undefined" && process.versions && process.versions.node) { + const https = await import("https") + const fs = await import("fs/promises") + + const agentOptions: any = {} + + // Handle certificate rejection + if (rejectUnauthorized === false || allowSelfSigned === true) { + agentOptions.rejectUnauthorized = false + } + + // Load CA certificate if provided + if (caCertPath) { + try { + const caCert = await fs.readFile(caCertPath, "utf-8") + agentOptions.ca = caCert + } catch (error) { + console.error(`Failed to load CA certificate from ${caCertPath}:`, error) + throw new Error( + `Failed to load CA certificate: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + // Create HTTPS agent with certificate trust settings + const agent = new https.Agent(agentOptions) + + // Custom fetch function that includes the HTTPS agent + reconnectingEventSourceOptions.fetch = (url: string | URL, init: RequestInit) => { + const headers = new Headers({ ...(init?.headers || {}), ...(configInjected.headers || {}) }) + // Use type assertion for Node.js-specific agent property + return fetch(url, { + ...init, + headers, + ...({ agent } as any), + }) + } + } + } else { + // Default fetch function without certificate trust modifications + reconnectingEventSourceOptions.fetch = (url: string | URL, init: RequestInit) => { const headers = new Headers({ ...(init?.headers || {}), ...(configInjected.headers || {}) }) return fetch(url, { ...init, headers, }) - }, + } } + global.EventSource = ReconnectingEventSource transport = new SSEClientTransport(new URL(configInjected.url), { ...sseOptions, diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 1db924ed6..e86666c82 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1798,6 +1798,119 @@ describe("McpHub", () => { }) }) + describe("Certificate trust configuration", () => { + it("should accept SSE server with certificate trust configuration", () => { + const config = { + type: "sse", + url: "https://api.example.com/mcp", + headers: { Authorization: "Bearer token" }, + certificateTrust: { + allowSelfSigned: true, + caCertPath: "/path/to/ca.pem", + rejectUnauthorized: false, + }, + } + + const result = ServerConfigSchema.safeParse(config) + expect(result.success).toBe(true) + if (result.success && result.data.type === "sse") { + expect(result.data.type).toBe("sse") + expect(result.data.certificateTrust).toEqual({ + allowSelfSigned: true, + caCertPath: "/path/to/ca.pem", + rejectUnauthorized: false, + }) + } + }) + + it("should accept StreamableHTTP server with certificate trust configuration", () => { + const config = { + type: "streamable-http", + url: "https://api.example.com/mcp", + headers: { Authorization: "Bearer token" }, + certificateTrust: { + allowSelfSigned: false, + rejectUnauthorized: true, + }, + } + + const result = ServerConfigSchema.safeParse(config) + expect(result.success).toBe(true) + if (result.success && result.data.type === "streamable-http") { + expect(result.data.type).toBe("streamable-http") + expect(result.data.certificateTrust).toEqual({ + allowSelfSigned: false, + rejectUnauthorized: true, + }) + } + }) + + it("should accept SSE server with only CA certificate path", () => { + const config = { + type: "sse", + url: "https://api.example.com/mcp", + certificateTrust: { + caCertPath: "/path/to/ca.pem", + }, + } + + const result = ServerConfigSchema.safeParse(config) + expect(result.success).toBe(true) + if (result.success && result.data.type === "sse") { + expect(result.data.certificateTrust?.caCertPath).toBe("/path/to/ca.pem") + expect(result.data.certificateTrust?.rejectUnauthorized).toBe(true) // default value + } + }) + + it("should accept server without certificate trust configuration", () => { + const config = { + type: "sse", + url: "https://api.example.com/mcp", + } + + const result = ServerConfigSchema.safeParse(config) + expect(result.success).toBe(true) + if (result.success && result.data.type === "sse") { + expect(result.data.certificateTrust).toBeUndefined() + } + }) + + it("should not accept certificate trust for stdio servers", () => { + const config = { + type: "stdio", + command: "node", + args: ["server.js"], + certificateTrust: { + allowSelfSigned: true, + }, + } + + // Note: stdio schema doesn't include certificateTrust, so it will be stripped + const result = ServerConfigSchema.safeParse(config) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe("stdio") + expect((result.data as any).certificateTrust).toBeUndefined() + } + }) + + it("should default rejectUnauthorized to true when not specified", () => { + const config = { + type: "sse", + url: "https://api.example.com/mcp", + certificateTrust: { + allowSelfSigned: true, + }, + } + + const result = ServerConfigSchema.safeParse(config) + expect(result.success).toBe(true) + if (result.success && result.data.type === "sse") { + expect(result.data.certificateTrust?.rejectUnauthorized).toBe(true) + } + }) + }) + describe("Windows command wrapping", () => { let StdioClientTransport: ReturnType let Client: ReturnType diff --git a/tmp/pr-8287-Roo-Code b/tmp/pr-8287-Roo-Code new file mode 160000 index 000000000..88a473b01 --- /dev/null +++ b/tmp/pr-8287-Roo-Code @@ -0,0 +1 @@ +Subproject commit 88a473b017af37091c85ce3056e444e856f80d6e