Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .review/pr-8274
Submodule pr-8274 added at e46929
167 changes: 167 additions & 0 deletions docs/mcp-certificate-trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# 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

## Related Documentation

- [MCP Server Configuration](./mcp-servers.md)
- [Model Context Protocol Specification](https://modelcontextprotocol.io)
112 changes: 106 additions & 6 deletions src/services/mcp/McpHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The certificate trust configuration logic for both the 'streamable-http' and 'sse' branches is nearly identical. Consider extracting this duplicated code into a shared helper function to improve maintainability.

This comment was generated because it violated a code review rule: irule_tTqpIuNs8DV0QFGj.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] The certificate-trust wiring is duplicated across streamable-http and sse branches. Consider factoring a helper that returns the proper fetch options (Undici dispatcher) to reduce drift.

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 = {}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] agentOptions is typed as any. Replace with a typed shape (and avoid casting requestInit as any) to improve maintainability.


// Handle certificate rejection
if (rejectUnauthorized === false || allowSelfSigned === true) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Precedence between allowSelfSigned and rejectUnauthorized is implicit (this forces rejectUnauthorized=false if allowSelfSigned=true). Consider validating mutually exclusive/precedence in schema and documenting it to avoid confusing combos.

agentOptions.rejectUnauthorized = false
}

// Load CA certificate if provided
if (caCertPath) {
try {
const caCert = await fs.readFile(caCertPath, "utf-8")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] caCertPath: consider supporting multiple CA files (array) and validating absolute vs relative paths with clearer error messages. Node agents accept an array for "ca".

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Using https.Agent and assigning requestInit.agent will be ignored by Node’s built-in fetch (Undici). Use undici.Agent (or Pool) and pass it via the RequestInit "dispatcher" option so TLS settings (ca/rejectUnauthorized) actually apply for streamable-http.

}
}

transport = new StreamableHTTPClientTransport(new URL(configInjected.url), {
requestInit: {
headers: configInjected.headers,
},
requestInit,
})

// Set up Streamable HTTP specific error handling
Expand Down Expand Up @@ -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),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The custom fetch for SSE sets an "agent" option, which Undici-based fetch ignores. Build an Undici Agent/Pool and pass it via the "dispatcher" option here to ensure certificateTrust takes effect during reconnects.

})
}
}
} 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,
Expand Down
Loading
Loading