From fe4db1998d4868b6a5585175e55b1c44f4cde2af Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 5 Aug 2025 21:00:58 +0700 Subject: [PATCH] feat: add --tls-skip-verify flag for self-signed certificates (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add --tls-skip-verify flag for self-signed certificates Adds support for skipping TLS certificate verification when connecting to providers with self-signed certificates. This is particularly useful for local Ollama instances secured with HTTPS. - Add --tls-skip-verify command-line flag with security warnings - Update ProviderConfig to include TLSSkipVerify field - Modify HTTP client creation for all providers (Ollama, OpenAI, Anthropic, Google, Azure) - Create helper functions for TLS-aware HTTP client creation - Add comprehensive unit tests for TLS skip verify functionality - Update documentation with usage examples and security warnings Fixes #113 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode * feat: add TLS skip verify support to script mode - Add TLSSkipVerify field to Config struct for script frontmatter - Update script parsing to handle tls-skip-verify in YAML frontmatter - Pass TLS configuration to model creation in script mode - Add example script demonstrating TLS skip verify usage - Update script examples documentation This allows scripts to specify tls-skip-verify: true in their frontmatter to connect to providers with self-signed certificates. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --------- Co-authored-by: opencode --- README.md | 9 ++ cmd/root.go | 11 +++ cmd/script.go | 7 ++ examples/scripts/README.md | 22 +++++ examples/scripts/tls-test-script.sh | 9 ++ internal/config/config.go | 3 + internal/models/providers.go | 72 ++++++++++++++-- internal/models/providers_test.go | 123 ++++++++++++++++++++++++++++ 8 files changed, 248 insertions(+), 8 deletions(-) create mode 100755 examples/scripts/tls-test-script.sh create mode 100644 internal/models/providers_test.go diff --git a/README.md b/README.md index be1a83d9..c26f306c 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,13 @@ export GOOGLE_API_KEY='your-api-key' - Get your API server base URL, API key and model name - Use `--provider-url` and `--provider-api-key` flags or set environment variables +5. Self-Signed Certificates (TLS): +If your provider uses self-signed certificates (e.g., local Ollama with HTTPS), you can skip certificate verification: +```bash +mcphost --provider-url https://192.168.1.100:443 --tls-skip-verify +``` +⚠️ **WARNING**: Only use `--tls-skip-verify` for development or when connecting to trusted servers with self-signed certificates. This disables TLS certificate verification and is insecure for production use. + ## Installation 📦 ```bash @@ -741,6 +748,7 @@ mcphost -p "Generate a random UUID" --quiet | tr '[:lower:]' '[:upper:]' ### Flags - `--provider-url string`: Base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google) - `--provider-api-key string`: API key for the provider (applies to OpenAI, Anthropic, and Google) +- `--tls-skip-verify`: Skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates) - `--config string`: Config file location (default is $HOME/.mcphost.yml) - `--system-prompt string`: system-prompt file location - `--debug`: Enable debug logging @@ -808,6 +816,7 @@ stream: false # Disable streaming (default: true) # API Configuration provider-api-key: "your-api-key" # For OpenAI, Anthropic, or Google provider-url: "https://api.openai.com/v1" # Custom base URL +tls-skip-verify: false # Skip TLS certificate verification (default: false) ``` **Note**: Command-line flags take precedence over config file values. diff --git a/cmd/root.go b/cmd/root.go index 21282192..ffe5ef6b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -55,6 +55,9 @@ var ( // Hooks control noHooks bool + + // TLS configuration + tlsSkipVerify bool ) // agentUIAdapter adapts agent.Agent to ui.AgentInterface @@ -260,6 +263,7 @@ func init() { flags := rootCmd.PersistentFlags() flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)") flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)") + flags.BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)") // Model generation parameters flags.IntVar(&maxTokens, "max-tokens", 4096, "maximum number of tokens in the response") @@ -291,6 +295,7 @@ func init() { viper.BindPFlag("stop-sequences", rootCmd.PersistentFlags().Lookup("stop-sequences")) viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers")) viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu")) + viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify")) // Defaults are already set in flag definitions, no need to duplicate in viper @@ -364,6 +369,7 @@ func runNormalMode(ctx context.Context) error { StopSequences: viper.GetStringSlice("stop-sequences"), NumGPU: &numGPU, MainGPU: &mainGPU, + TLSSkipVerify: viper.GetBool("tls-skip-verify"), } // Create spinner function for agent creation @@ -448,6 +454,11 @@ func runNormalMode(ctx context.Context) error { "system-prompt": viper.GetString("system-prompt"), } + // Add TLS skip verify if enabled + if viper.GetBool("tls-skip-verify") { + debugConfig["tls-skip-verify"] = true + } + // Add Ollama-specific parameters if using Ollama if strings.HasPrefix(viper.GetString("model"), "ollama:") { debugConfig["num-gpu-layers"] = viper.GetInt("num-gpu-layers") diff --git a/cmd/script.go b/cmd/script.go index a0e357b4..2abf0563 100644 --- a/cmd/script.go +++ b/cmd/script.go @@ -134,6 +134,9 @@ func overrideConfigWithFrontmatter(scriptFile string, variables map[string]strin if scriptConfig.Stream != nil && !flagChanged("stream") { viper.Set("stream", *scriptConfig.Stream) } + if scriptConfig.TLSSkipVerify && !flagChanged("tls-skip-verify") { + viper.Set("tls-skip-verify", scriptConfig.TLSSkipVerify) + } } // parseCustomVariables extracts custom variables from command line arguments @@ -392,6 +395,9 @@ func parseScriptContent(content string, variables map[string]string) (*config.Co if noExit := frontmatterViper.GetBool("no-exit"); noExit { scriptConfig.NoExit = noExit } + if tlsSkipVerify := frontmatterViper.GetBool("tls-skip-verify"); tlsSkipVerify { + scriptConfig.TLSSkipVerify = tlsSkipVerify + } } // Set prompt from content after frontmatter @@ -586,6 +592,7 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string, TopP: &finalTopP, TopK: &finalTopK, StopSequences: finalStopSequences, + TLSSkipVerify: viper.GetBool("tls-skip-verify"), } // Create the agent using the factory (scripts don't need spinners) diff --git a/examples/scripts/README.md b/examples/scripts/README.md index 73cf41c7..43974835 100644 --- a/examples/scripts/README.md +++ b/examples/scripts/README.md @@ -28,6 +28,28 @@ mcphost script default-values-demo.sh \ --args:format "json" ``` +### `tls-test-script.sh` +Demonstrates TLS skip verify for connecting to providers with self-signed certificates. + +**Features showcased:** +- `tls-skip-verify` configuration in script frontmatter +- Connecting to HTTPS endpoints with self-signed certificates +- Security considerations for development environments + +**Usage:** +```bash +# Run with TLS skip verify enabled (configured in script) +mcphost script tls-test-script.sh + +# Override the provider URL +mcphost script tls-test-script.sh --provider-url https://192.168.1.100:443 + +# Disable TLS skip verify via command line (overrides script config) +mcphost script tls-test-script.sh --tls-skip-verify=false +``` + +⚠️ **WARNING**: Only use `tls-skip-verify` for development or when connecting to trusted servers with self-signed certificates. + ## Variable Syntax Reference MCPHost scripts support two types of variables: diff --git a/examples/scripts/tls-test-script.sh b/examples/scripts/tls-test-script.sh new file mode 100755 index 00000000..8f0974a1 --- /dev/null +++ b/examples/scripts/tls-test-script.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env -S mcphost script +--- +# Example script demonstrating TLS skip verify for self-signed certificates +model: "ollama:llama3.2" +provider-url: "https://localhost:8443" +tls-skip-verify: true +max-tokens: 1000 +--- +Hello! Can you tell me about TLS certificates and why someone might need to skip certificate verification in development environments? \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 1a303e60..466c7b41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -117,6 +117,9 @@ type Config struct { TopP *float32 `json:"top-p,omitempty" yaml:"top-p,omitempty"` TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"` StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"` + + // TLS configuration + TLSSkipVerify bool `json:"tls-skip-verify,omitempty" yaml:"tls-skip-verify,omitempty"` } // GetTransportType returns the transport type for the server config diff --git a/internal/models/providers.go b/internal/models/providers.go index c876911b..1c2caf5c 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -3,6 +3,7 @@ package models import ( "bytes" "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -78,6 +79,9 @@ type ProviderConfig struct { // Ollama-specific parameters NumGPU *int32 MainGPU *int32 + + // TLS configuration + TLSSkipVerify bool // Skip TLS certificate verification (insecure) } // ProviderResult contains the result of provider creation @@ -216,6 +220,11 @@ func createAzureOpenAIProvider(ctx context.Context, config *ProviderConfig, mode azureConfig.Stop = config.StopSequences } + // Set HTTP client with TLS config if needed + if config.TLSSkipVerify { + azureConfig.HTTPClient = createHTTPClientWithTLSConfig(true) + } + return openai.NewCustomChatModel(ctx, azureConfig) } @@ -246,12 +255,16 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN if strings.HasPrefix(source, "stored OAuth") { // For OAuth tokens, we need to use Authorization: Bearer header // Create a custom HTTP client that adds the proper headers - claudeConfig.HTTPClient = createOAuthHTTPClient(apiKey) + claudeConfig.HTTPClient = createOAuthHTTPClient(apiKey, config.TLSSkipVerify) // Set a dummy API key to prevent the library from failing validation claudeConfig.APIKey = "oauth-placeholder" } else { // For API keys, use the standard x-api-key header claudeConfig.APIKey = apiKey + // Set HTTP client with TLS config if needed + if config.TLSSkipVerify { + claudeConfig.HTTPClient = createHTTPClientWithTLSConfig(true) + } } if config.ProviderURL != "" { @@ -295,6 +308,11 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName openaiConfig.BaseURL = config.ProviderURL } + // Set HTTP client with TLS config if needed + if config.TLSSkipVerify { + openaiConfig.HTTPClient = createHTTPClientWithTLSConfig(true) + } + // Check if this is a reasoning model to handle beta limitations (skip validation if using custom URL) registry := GetGlobalRegistry() isReasoningModel := false @@ -350,10 +368,17 @@ func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName return nil, fmt.Errorf("Google API key not provided. Use --provider-api-key flag or GOOGLE_API_KEY/GEMINI_API_KEY/GOOGLE_GENERATIVE_AI_API_KEY environment variable") } - client, err := genai.NewClient(ctx, &genai.ClientConfig{ + clientConfig := &genai.ClientConfig{ APIKey: apiKey, Backend: genai.BackendGeminiAPI, - }) + } + + // Set HTTP client with TLS config if needed + if config.TLSSkipVerify { + clientConfig.HTTPClient = createHTTPClientWithTLSConfig(true) + } + + client, err := genai.NewClient(ctx, clientConfig) if err != nil { return nil, fmt.Errorf("failed to create Google client: %v", err) } @@ -389,8 +414,8 @@ type OllamaLoadingResult struct { } // loadOllamaModelWithFallback loads an Ollama model with GPU settings and automatic CPU fallback -func loadOllamaModelWithFallback(ctx context.Context, baseURL, modelName string, options *api.Options) (*OllamaLoadingResult, error) { - client := &http.Client{} +func loadOllamaModelWithFallback(ctx context.Context, baseURL, modelName string, options *api.Options, tlsSkipVerify bool) (*OllamaLoadingResult, error) { + client := createHTTPClientWithTLSConfig(tlsSkipVerify) // Phase 1: Check if model exists locally if err := checkOllamaModelExists(client, baseURL, modelName); err != nil { @@ -601,7 +626,7 @@ func createOllamaProviderWithResult(ctx context.Context, config *ProviderConfig, // Try to pre-load the model with GPU settings and automatic CPU fallback // If this fails, fall back to the original behavior - loadingResult, err := loadOllamaModelWithFallback(ctx, baseURL, modelName, options) + loadingResult, err := loadOllamaModelWithFallback(ctx, baseURL, modelName, options, config.TLSSkipVerify) var loadingMessage string if err != nil { @@ -620,6 +645,11 @@ func createOllamaProviderWithResult(ctx context.Context, config *ProviderConfig, Options: finalOptions, } + // Set HTTP client with TLS config if needed + if config.TLSSkipVerify { + ollamaConfig.HTTPClient = createHTTPClientWithTLSConfig(true) + } + chatModel, err := ollama.NewChatModel(ctx, ollamaConfig) if err != nil { return nil, err @@ -631,12 +661,38 @@ func createOllamaProviderWithResult(ctx context.Context, config *ProviderConfig, }, nil } +// createHTTPClientWithTLSConfig creates an HTTP client with optional TLS skip verify +func createHTTPClientWithTLSConfig(skipVerify bool) *http.Client { + if !skipVerify { + return &http.Client{} + } + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + return &http.Client{ + Transport: transport, + } +} + // createOAuthHTTPClient creates an HTTP client that adds OAuth headers for Anthropic API -func createOAuthHTTPClient(accessToken string) *http.Client { +func createOAuthHTTPClient(accessToken string, skipVerify bool) *http.Client { + var base http.RoundTripper = http.DefaultTransport + if skipVerify { + base = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } + return &http.Client{ Transport: &oauthTransport{ accessToken: accessToken, - base: http.DefaultTransport, + base: base, }, } } diff --git a/internal/models/providers_test.go b/internal/models/providers_test.go new file mode 100644 index 00000000..30d531bc --- /dev/null +++ b/internal/models/providers_test.go @@ -0,0 +1,123 @@ +package models + +import ( + "net/http" + "testing" +) + +func TestCreateHTTPClientWithTLSConfig(t *testing.T) { + tests := []struct { + name string + skipVerify bool + wantInsecure bool + }{ + { + name: "skip verify disabled", + skipVerify: false, + wantInsecure: false, + }, + { + name: "skip verify enabled", + skipVerify: true, + wantInsecure: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createHTTPClientWithTLSConfig(tt.skipVerify) + + if client == nil { + t.Fatal("expected non-nil client") + } + + // Check if the client has a custom transport when skipVerify is true + if tt.skipVerify { + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport when skipVerify is true") + } + + if transport.TLSClientConfig == nil { + t.Fatal("expected non-nil TLSClientConfig when skipVerify is true") + } + + if transport.TLSClientConfig.InsecureSkipVerify != tt.wantInsecure { + t.Errorf("InsecureSkipVerify = %v, want %v", + transport.TLSClientConfig.InsecureSkipVerify, tt.wantInsecure) + } + } + }) + } +} + +func TestCreateOAuthHTTPClient(t *testing.T) { + tests := []struct { + name string + accessToken string + skipVerify bool + wantInsecure bool + }{ + { + name: "oauth with skip verify disabled", + accessToken: "test-token", + skipVerify: false, + wantInsecure: false, + }, + { + name: "oauth with skip verify enabled", + accessToken: "test-token", + skipVerify: true, + wantInsecure: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createOAuthHTTPClient(tt.accessToken, tt.skipVerify) + + if client == nil { + t.Fatal("expected non-nil client") + } + + // Check that the transport is an oauthTransport + oauthTransport, ok := client.Transport.(*oauthTransport) + if !ok { + t.Fatal("expected *oauthTransport") + } + + if oauthTransport.accessToken != tt.accessToken { + t.Errorf("accessToken = %v, want %v", oauthTransport.accessToken, tt.accessToken) + } + + // Check the base transport when skipVerify is true + if tt.skipVerify { + baseTransport, ok := oauthTransport.base.(*http.Transport) + if !ok { + t.Fatal("expected base transport to be *http.Transport when skipVerify is true") + } + + if baseTransport.TLSClientConfig == nil { + t.Fatal("expected non-nil TLSClientConfig when skipVerify is true") + } + + if baseTransport.TLSClientConfig.InsecureSkipVerify != tt.wantInsecure { + t.Errorf("InsecureSkipVerify = %v, want %v", + baseTransport.TLSClientConfig.InsecureSkipVerify, tt.wantInsecure) + } + } + }) + } +} + +func TestProviderConfigTLSSkipVerify(t *testing.T) { + // Test that ProviderConfig properly stores TLSSkipVerify + config := &ProviderConfig{ + ModelString: "test:model", + TLSSkipVerify: true, + } + + if !config.TLSSkipVerify { + t.Error("expected TLSSkipVerify to be true") + } +}