Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Limitation note: In Node 18+ the global fetch is powered by undici and ignores RequestInit.agent. For certificate overrides (custom CA, self-signed, disabling verification) you must use undici’s dispatcher (e.g., an undici Agent/Pool with TLS options) and pass it via fetch(..., { dispatcher }). Consider adding this to the Limitations with a short example so users don’t expect agent to work in Node.

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

Choose a reason for hiding this comment

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

Node 18+ global fetch (undici) ignores RequestInit.agent, so these certificateTrust settings won’t take effect for StreamableHTTP. Use undici’s dispatcher instead. Suggest: create an undici Agent with TLS options (e.g., new Agent({ connect: { tls: { rejectUnauthorized: false, ca: caCert } } })) and set (requestInit as any).dispatcher = agent. This also applies to the SSE custom fetch.

;(requestInit as any).agent = agent
}
}

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.

SSE path: the custom fetch adds { agent } to RequestInit, but Node’s global fetch (undici) ignores agent. Use undici’s dispatcher instead. Create an undici Agent/Pool with TLS options (e.g., new Agent({ connect: { tls: { rejectUnauthorized: false, ca: caCert } } })) and call fetch(url, { ...init, headers, dispatcher }). Consider caching the dispatcher per server to avoid recreating it on reconnect.

})
}
}
} 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