Skip to content
Open
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
168 changes: 168 additions & 0 deletions docs/mcp-certificate-trust.md
Original file line number Diff line number Diff line change
@@ -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
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
- Test webhook commit 17
Copy link

Choose a reason for hiding this comment

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

This test commit message should be removed before merging the PR.


## 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