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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
29 changes: 25 additions & 4 deletions cmd/openapi-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions cmd/openapi-mcp/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
82 changes: 68 additions & 14 deletions pkg/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,44 +32,55 @@ 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)
if err != nil {
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 {
Expand All @@ -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,
}
}

Expand Down
33 changes: 33 additions & 0 deletions pkg/parser/parser_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package parser

import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -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 ---
Expand Down