diff --git a/README.md b/README.md index 3b02da2..8c591eb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ OpenAPI-MCP is a dockerized MCP server that reads a `swagger.json` or `openapi.y - [Using the Pre-built Docker Hub Image (Recommended)](#using-the-pre-built-docker-hub-image-recommended) - [Building Locally (Optional)](#building-locally-optional) - [Running the Weatherbit Example (Step-by-Step)](#running-the-weatherbit-example-step-by-step) +- [Running the Xquik API Example](#running-the-xquik-api-example) - [Command-Line Options](#command-line-options) - [Environment Variables](#environment-variables) @@ -186,6 +187,29 @@ A `docker-compose.yml` file is provided in the `example/` directory to demonstra 3. **Stop the service:** Press `Ctrl+C` in the terminal where Compose is running, or run `docker-compose down` from the `example` directory in another terminal. +## Running the Xquik API Example + +Xquik publishes an OpenAPI 3.1 document for X/Twitter automation workflows at +`https://xquik.com/openapi.json`. Since its API key is passed in the `x-api-key` +header, it maps directly to OpenAPI-MCP's header API-key options. + +Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp. + +```bash +docker run -p 8080:8080 --rm \ + -e XQUIK_API_KEY="your_xquik_api_key" \ + ckanthony/openapi-mcp:latest \ + --spec https://xquik.com/openapi.json \ + --api-key-env XQUIK_API_KEY \ + --api-key-name x-api-key \ + --api-key-loc header \ + --include-tag Trends \ + --include-tag Tweets +``` + +Use the tag filters to keep the generated MCP surface focused on X/Twitter +search and trend workflows. Remove them if you want the full Xquik API surface. + ## Command-Line Options The `openapi-mcp` command accepts the following flags: diff --git a/cmd/openapi-mcp/main.go b/cmd/openapi-mcp/main.go index 4e05368..37b3d53 100644 --- a/cmd/openapi-mcp/main.go +++ b/cmd/openapi-mcp/main.go @@ -26,6 +26,28 @@ func (i *stringSliceFlag) Set(value string) error { return nil } +func configurationSummary(cfg *config.Config) string { + apiKeySource := "none" + if cfg.APIKeyFromEnvVar != "" { + apiKeySource = "environment" + } else if cfg.APIKey != "" { + apiKeySource = "flag" + } + + return fmt.Sprintf( + "spec_configured=%t api_key_source=%s api_key_name=%q api_key_location=%q include_tags=%d exclude_tags=%d include_operations=%d exclude_operations=%d custom_headers_configured=%t", + cfg.SpecPath != "", + apiKeySource, + cfg.APIKeyName, + cfg.APIKeyLocation, + len(cfg.IncludeTags), + len(cfg.ExcludeTags), + len(cfg.IncludeOperations), + len(cfg.ExcludeOperations), + cfg.CustomHeaders != "", + ) +} + func main() { // --- Flag Definitions First --- // Define specPath early so we can use it for .env loading @@ -77,7 +99,7 @@ func main() { // --- Read REQUEST_HEADERS env var --- customHeadersEnv := os.Getenv("REQUEST_HEADERS") if customHeadersEnv != "" { - log.Printf("Found REQUEST_HEADERS environment variable: %s", customHeadersEnv) + log.Println("Found REQUEST_HEADERS environment variable.") } // --- Input Validation --- @@ -120,15 +142,14 @@ func main() { CustomHeaders: customHeadersEnv, } - log.Printf("Configuration loaded: %+v\n", cfg) - log.Println("API Key (resolved):", cfg.GetAPIKey()) + log.Printf("Configuration loaded: %s", configurationSummary(cfg)) // --- Call Parser --- specDoc, version, err := parser.LoadSwagger(cfg.SpecPath) if err != nil { log.Fatalf("Failed to load OpenAPI/Swagger spec: %v", err) } - log.Printf("Spec type %s loaded successfully from %s.\n", version, cfg.SpecPath) + log.Printf("Spec type %s loaded successfully.\n", version) toolSet, err := parser.GenerateToolSet(specDoc, version, cfg) if err != nil { diff --git a/cmd/openapi-mcp/main_test.go b/cmd/openapi-mcp/main_test.go new file mode 100644 index 0000000..b59e123 --- /dev/null +++ b/cmd/openapi-mcp/main_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + "testing" + + "github.com/ckanthony/openapi-mcp/pkg/config" +) + +func TestConfigurationSummaryRedactsSecrets(t *testing.T) { + cfg := &config.Config{ + SpecPath: "https://spec-secret@example.com/openapi.json?token=query-secret", + APIKey: "direct-secret", + APIKeyFromEnvVar: "API_TOKEN", + APIKeyName: "x-api-key", + APIKeyLocation: config.APIKeyLocationHeader, + IncludeTags: []string{"Tweets", "Trends"}, + ExcludeTags: []string{"Admin"}, + IncludeOperations: []string{"searchTweets"}, + ExcludeOperations: []string{"deleteTweet"}, + CustomHeaders: "Authorization:Bearer secret-header", + } + + summary := configurationSummary(cfg) + + for _, secret := range []string{ + "direct-secret", + "secret-header", + "API_TOKEN", + "spec-secret", + "query-secret", + } { + if strings.Contains(summary, secret) { + t.Fatalf("configuration summary exposed secret value %q", secret) + } + } + + for _, field := range []string{ + "spec_configured=true", + "api_key_source=environment", + `api_key_name="x-api-key"`, + `api_key_location="header"`, + "include_tags=2", + "exclude_tags=1", + "include_operations=1", + "exclude_operations=1", + "custom_headers_configured=true", + } { + if !strings.Contains(summary, field) { + t.Errorf("configuration summary missing %q: %s", field, summary) + } + } +} + +func TestConfigurationSummaryReportsDirectFlag(t *testing.T) { + cfg := &config.Config{APIKey: "direct-secret"} + + summary := configurationSummary(cfg) + + if !strings.Contains(summary, "api_key_source=flag") { + t.Fatalf("configuration summary should report the direct flag source: %s", summary) + } + if strings.Contains(summary, cfg.APIKey) { + t.Fatalf("configuration summary exposed the direct API key") + } +} diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 0ab6cca..bae5ed7 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -32,16 +32,20 @@ func LoadSwagger(location string) (interface{}, string, error) { // Determine if location is URL or file path locationURL, urlErr := url.ParseRequestURI(location) isURL := urlErr == nil && locationURL != nil && (locationURL.Scheme == "http" || locationURL.Scheme == "https") + displayLocation := location + if isURL { + displayLocation = locationForLog(locationURL) + } var data []byte var err error var absPath string // Store absolute path if it's a file if !isURL { - log.Printf("Detected file path location: %s", location) + log.Printf("Detected file path location: %s", displayLocation) absPath, err = filepath.Abs(location) if err != nil { - return nil, "", fmt.Errorf("failed to get absolute path for '%s': %w", location, err) + return nil, "", fmt.Errorf("failed to get absolute path for '%s': %w", displayLocation, err) } // Read data first for version detection data, err = os.ReadFile(absPath) @@ -49,27 +53,34 @@ func LoadSwagger(location string) (interface{}, string, error) { return nil, "", fmt.Errorf("failed reading file path '%s': %w", absPath, err) } } else { - log.Printf("Detected URL location: %s", location) + log.Printf("Detected URL location: %s", displayLocation) // Read data first for version detection resp, err := http.Get(location) if err != nil { - return nil, "", fmt.Errorf("failed to fetch URL '%s': %w", location, err) + return nil, "", fmt.Errorf( + "failed to fetch URL '%s': %w", + displayLocation, + errorForLog(err, location, displayLocation), + ) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) // Attempt to read body for error context - return nil, "", fmt.Errorf("failed to fetch URL '%s': status code %d, body: %s", location, resp.StatusCode, string(bodyBytes)) + return nil, "", fmt.Errorf("failed to fetch URL '%s': status code %d", displayLocation, resp.StatusCode) } data, err = io.ReadAll(resp.Body) if err != nil { - return nil, "", fmt.Errorf("failed to read response body from URL '%s': %w", location, err) + return nil, "", fmt.Errorf( + "failed to read response body from URL '%s': %w", + displayLocation, + errorForLog(err, location, displayLocation), + ) } } // Detect version from data var detector map[string]interface{} if err := json.Unmarshal(data, &detector); err != nil { - return nil, "", fmt.Errorf("failed to parse JSON from '%s' for version detection: %w", location, err) + return nil, "", fmt.Errorf("failed to parse JSON from '%s' for version detection: %w", displayLocation, err) } if _, ok := detector["openapi"]; ok { @@ -85,28 +96,71 @@ func LoadSwagger(location string) (interface{}, string, error) { doc, loadErr = loader.LoadFromFile(absPath) } else { // Use LoadFromURI for URLs - log.Printf("Loading V3 spec using LoadFromURI: %s", location) + log.Printf("Loading V3 spec using LoadFromURI: %s", displayLocation) doc, loadErr = loader.LoadFromURI(locationURL) } if loadErr != nil { - return nil, "", fmt.Errorf("failed to load OpenAPI v3 spec from '%s': %w", location, loadErr) + return nil, "", fmt.Errorf( + "failed to load OpenAPI v3 spec from '%s': %w", + displayLocation, + errorForLog(loadErr, location, displayLocation), + ) } if err := doc.Validate(context.Background()); err != nil { - return nil, "", fmt.Errorf("OpenAPI v3 spec validation failed for '%s': %w", location, err) + return nil, "", fmt.Errorf( + "OpenAPI v3 spec validation failed for '%s': %w", + displayLocation, + errorForLog(err, location, displayLocation), + ) } return doc, VersionV3, nil } else if _, ok := detector["swagger"]; ok { // Swagger 2.0 - Still load from data as loads.Analyzed expects bytes - log.Printf("Loading V2 spec using loads.Analyzed from data (source: %s)", location) + log.Printf("Loading V2 spec using loads.Analyzed from data (source: %s)", displayLocation) doc, err := loads.Analyzed(data, "2.0") if err != nil { - return nil, "", fmt.Errorf("failed to load or validate Swagger v2 spec from '%s': %w", location, err) + return nil, "", fmt.Errorf( + "failed to load or validate Swagger v2 spec from '%s': %w", + displayLocation, + errorForLog(err, location, displayLocation), + ) } return doc.Spec(), VersionV2, nil } else { - return nil, "", fmt.Errorf("failed to detect OpenAPI/Swagger version in '%s': missing 'openapi' or 'swagger' key", location) + return nil, "", fmt.Errorf("failed to detect OpenAPI/Swagger version in '%s': missing 'openapi' or 'swagger' key", displayLocation) + } +} + +func locationForLog(location *url.URL) string { + sanitized := *location + sanitized.User = nil + sanitized.RawQuery = "" + sanitized.ForceQuery = false + sanitized.Fragment = "" + return sanitized.String() +} + +type redactedLocationError struct { + err error + location string + displayLocation string +} + +func (e redactedLocationError) Error() string { + return strings.ReplaceAll(e.err.Error(), e.location, e.displayLocation) +} + +func (e redactedLocationError) Unwrap() error { + return e.err +} + +func errorForLog(err error, location, displayLocation string) error { + return redactedLocationError{ + err: err, + location: location, + displayLocation: displayLocation, } } diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 5313961..5efe6f9 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -1,6 +1,8 @@ package parser import ( + "bytes" + "log" "net/http" "net/http/httptest" "os" @@ -561,6 +563,37 @@ func TestLoadSwagger(t *testing.T) { } } +func TestLoadSwaggerRedactsRemoteLocationSecrets(t *testing.T) { + var logs bytes.Buffer + originalLogOutput := log.Writer() + log.SetOutput(&logs) + t.Cleanup(func() { + log.SetOutput(originalLogOutput) + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "response-secret", http.StatusInternalServerError) + })) + defer server.Close() + + location := strings.Replace(server.URL, "http://", "http://user-secret:password-secret@", 1) + + "/openapi.json?token=query-secret" + _, _, err := LoadSwagger(location) + + require.Error(t, err) + assert.Contains(t, err.Error(), "status code 500") + assert.Contains(t, logs.String(), "/openapi.json") + for _, secret := range []string{ + "user-secret", + "password-secret", + "query-secret", + "response-secret", + } { + assert.NotContains(t, err.Error(), secret) + assert.NotContains(t, logs.String(), secret) + } +} + // TODO: Add tests for GenerateToolSet func TestGenerateToolSet(t *testing.T) { // --- Load Specs Once ---