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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

![openapi-mcp logo](openapi-mcp.png)

**Generate MCP tool definitions directly from a Swagger/OpenAPI specification file.**
**Generate MCP tool definitions directly from a Swagger/OpenAPI or WSDL specification file.**

OpenAPI-MCP is a dockerized MCP server that reads a `swagger.json` or `openapi.yaml` file and generates a corresponding [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) toolset. This allows MCP-compatible clients like [Cursor](https://cursor.sh/) to interact with APIs described by standard OpenAPI specifications. Now you can enable your AI agent to access any API by simply providing its OpenAPI/Swagger specification - no additional coding required.
OpenAPI-MCP is a dockerized MCP server that reads a `swagger.json`, `openapi.yaml`, or `*.wsdl` file and generates a corresponding [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) toolset. This allows MCP-compatible clients like [Cursor](https://cursor.sh/) to interact with APIs described by standard OpenAPI or SOAP specifications. Now you can enable your AI agent to access any API by simply providing its OpenAPI/Swagger or WSDL specification - no additional coding required.

## Table of Contents

Expand Down Expand Up @@ -38,7 +38,7 @@ Run the demo yourself: [Running the Weatherbit Example (Step-by-Step)](#running-

## Features

- **OpenAPI v2 (Swagger) & v3 Support:** Parses standard specification formats.
- **OpenAPI v2 (Swagger), v3 & WSDL Support:** Parses standard specification formats.
- **Schema Generation:** Creates MCP tool schemas from OpenAPI operation parameters and request/response definitions.
- **Secure API Key Management:**
- Injects API keys into requests (`header`, `query`, `path`, `cookie`) based on command-line configuration.
Expand Down Expand Up @@ -79,7 +79,7 @@ Alternatively, you can use the pre-built image available on [Docker Hub](https:/
You need to provide the OpenAPI specification and any necessary API key configuration when running the container.

* **Example 1: Using a local spec file and `.env` file:**
- Create a directory (e.g., `./my-api`) containing your `openapi.json` or `swagger.yaml`.
- Create a directory (e.g., `./my-api`) containing your `openapi.json`, `swagger.yaml`, or `service.wsdl`.
- If the API requires a key, create a `.env` file in the *same directory* (e.g., `./my-api/.env`) with `API_KEY=your_actual_key` (replace `API_KEY` if your `--api-key-env` flag is different).
```bash
docker run -p 8080:8080 --rm \\
Expand Down Expand Up @@ -111,7 +111,7 @@ Alternatively, you can use the pre-built image available on [Docker Hub](https:/
* `--env-file <path_to_host_env_file>`: Load environment variables from a local file (for API keys, etc.). Path is on the host.
* `-e <VAR_NAME>="<value>"`: Pass a single environment variable directly.
* `openapi-mcp:latest`: The name of the image you built locally.
* `--spec ...`: **Required.** Path to the spec file *inside the container* (e.g., `/app/spec/openapi.json`) or a public URL.
* `--spec ...`: **Required.** Path to the spec file *inside the container* (e.g., `/app/spec/openapi.json` or `/app/spec/service.wsdl`) or a public URL.
* `--port 8080`: (Optional) Change the internal port the server listens on (must match the container port in `-p`).
* `--api-key-env`, `--api-key-name`, `--api-key-loc`: Required if the target API needs an API key.
* (See `--help` for all command-line options by running `docker run --rm openapi-mcp:latest --help`)
Expand Down Expand Up @@ -190,7 +190,7 @@ The `openapi-mcp` command accepts the following flags:

| Flag | Description | Type | Default |
|----------------------|---------------------------------------------------------------------------------------------------------------------|---------------|----------------------------------|
| `--spec` | **Required.** Path or URL to the OpenAPI specification file. | `string` | (none) |
| `--spec` | **Required.** Path or URL to the OpenAPI or WSDL specification file. | `string` | (none) |
| `--port` | Port to run the MCP server on. | `int` | `8080` |
| `--api-key` | Direct API key value (use `--api-key-env` or `.env` file instead for security). | `string` | (none) |
| `--api-key-env` | Environment variable name containing the API key. If spec is local, also checks `.env` file in the spec's directory. | `string` | (none) |
Expand Down
4 changes: 2 additions & 2 deletions cmd/openapi-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (i *stringSliceFlag) Set(value string) error {
func main() {
// --- Flag Definitions First ---
// Define specPath early so we can use it for .env loading
specPath := flag.String("spec", "", "Path or URL to the OpenAPI specification file (required)")
specPath := flag.String("spec", "", "Path or URL to the OpenAPI or WSDL specification file (required)")
port := flag.Int("port", 8080, "Port to run the MCP server on")

apiKey := flag.String("api-key", "", "Direct API key value")
Expand All @@ -48,7 +48,7 @@ func main() {

serverBaseURL := flag.String("base-url", "", "Manually override the server base URL")
defaultToolName := flag.String("name", "OpenAPI-MCP Tools", "Default name for the toolset")
defaultToolDesc := flag.String("desc", "Tools generated from OpenAPI spec", "Default description for the toolset")
defaultToolDesc := flag.String("desc", "Tools generated from OpenAPI or WSDL spec", "Default description for the toolset")

// Parse flags *after* defining them all
flag.Parse()
Expand Down
22 changes: 22 additions & 0 deletions example/wsdl/simple.wsdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:tns="http://example.com/" targetNamespace="http://example.com/">
<message name="GetRequest">
<part name="id" type="xsd:string" xmlns:xsd="http://www.w3.org/2001/XMLSchema"/>
</message>
<portType name="SimplePort">
<operation name="Get">
<input message="tns:GetRequest"/>
</operation>
</portType>
<binding name="SimpleBinding" type="tns:SimplePort">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http"/>
<operation name="Get">
<soap:operation soapAction="http://example.com/Get"/>
</operation>
</binding>
<service name="SimpleService">
<port name="SimplePort" binding="tns:SimpleBinding">
<soap:address location="http://example.com/api"/>
</port>
</service>
</definitions>
3 changes: 2 additions & 1 deletion pkg/mcp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ type OperationDetail struct {
Path string `json:"path"` // Path template (e.g., /users/{id})
BaseURL string `json:"baseUrl"`
Parameters []ParameterDetail `json:"parameters,omitempty"`
// Add RequestBody schema if needed
SOAPAction string `json:"soapAction,omitempty"`
IsSOAP bool `json:"isSoap,omitempty"`
}

// ToolSet represents the collection of tools provided by an MCP server.
Expand Down
145 changes: 108 additions & 37 deletions pkg/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ import (

"github.com/ckanthony/openapi-mcp/pkg/config"
"github.com/ckanthony/openapi-mcp/pkg/mcp"
"github.com/ckanthony/openapi-mcp/pkg/wsdl"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-openapi/loads"
"github.com/go-openapi/spec"
)

const (
VersionV2 = "v2"
VersionV3 = "v3"
VersionV2 = "v2"
VersionV3 = "v3"
VersionWSDL = "wsdl"
)

// LoadSwagger detects the version and loads an OpenAPI/Swagger specification
Expand Down Expand Up @@ -66,48 +68,55 @@ func LoadSwagger(location string) (interface{}, string, error) {
}
}

// Detect version from data
// Attempt JSON detection first
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)
}

if _, ok := detector["openapi"]; ok {
// OpenAPI 3.x
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
var doc *openapi3.T
var loadErr error
if err := json.Unmarshal(data, &detector); err == nil {
if _, ok := detector["openapi"]; ok {
// OpenAPI 3.x
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
var doc *openapi3.T
var loadErr error

if !isURL {
log.Printf("Loading V3 spec using LoadFromFile: %s", absPath)
doc, loadErr = loader.LoadFromFile(absPath)
} else {
log.Printf("Loading V3 spec using LoadFromURI: %s", location)
doc, loadErr = loader.LoadFromURI(locationURL)
}

if !isURL {
// Use LoadFromFile for local files
log.Printf("Loading V3 spec using LoadFromFile: %s", absPath)
doc, loadErr = loader.LoadFromFile(absPath)
} else {
// Use LoadFromURI for URLs
log.Printf("Loading V3 spec using LoadFromURI: %s", location)
doc, loadErr = loader.LoadFromURI(locationURL)
}
if loadErr != nil {
return nil, "", fmt.Errorf("failed to load OpenAPI v3 spec from '%s': %w", location, loadErr)
}

if loadErr != nil {
return nil, "", fmt.Errorf("failed to load OpenAPI v3 spec from '%s': %w", location, loadErr)
if err := doc.Validate(context.Background()); err != nil {
return nil, "", fmt.Errorf("OpenAPI v3 spec validation failed for '%s': %w", location, err)
}
return doc, VersionV3, nil
} else if _, ok := detector["swagger"]; ok {
// Swagger 2.0
log.Printf("Loading V2 spec using loads.Analyzed from data (source: %s)", location)
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 doc.Spec(), VersionV2, nil
}
} else {
log.Printf("JSON unmarshal failed, attempting WSDL detection: %v", err)
}

if err := doc.Validate(context.Background()); err != nil {
return nil, "", fmt.Errorf("OpenAPI v3 spec validation failed for '%s': %w", location, err)
}
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)
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)
// Try WSDL detection
if strings.Contains(string(data), "definitions") {
wsdlDoc, werr := wsdl.ParseWSDL(data)
if werr == nil {
return wsdlDoc, VersionWSDL, nil
}
return doc.Spec(), VersionV2, nil
} else {
return nil, "", fmt.Errorf("failed to detect OpenAPI/Swagger version in '%s': missing 'openapi' or 'swagger' key", location)
log.Printf("WSDL parse attempt failed: %v", werr)
}

return nil, "", fmt.Errorf("failed to detect OpenAPI/Swagger/WSDL specification type in '%s'", location)
}

// GenerateToolSet converts a loaded spec (v2 or v3) into an MCP ToolSet.
Expand All @@ -125,6 +134,12 @@ func GenerateToolSet(specDoc interface{}, version string, cfg *config.Config) (*
return nil, fmt.Errorf("internal error: expected *spec.Swagger for v2 spec, got %T", specDoc)
}
return generateToolSetV2(docV2, cfg)
case VersionWSDL:
wsdlDoc, ok := specDoc.(*wsdl.Definitions)
if !ok {
return nil, fmt.Errorf("internal error: expected *wsdl.Definitions for WSDL spec, got %T", specDoc)
}
return generateToolSetWSDL(wsdlDoc, cfg)
default:
return nil, fmt.Errorf("unsupported specification version: %s", version)
}
Expand Down Expand Up @@ -953,6 +968,62 @@ func shouldInclude(opID string, opTags []string, cfg *config.Config) bool {
return false // Did not match any inclusion rule
}

// generateToolSetWSDL converts a WSDL definition into an MCP ToolSet.
func generateToolSetWSDL(doc *wsdl.Definitions, cfg *config.Config) (*mcp.ToolSet, error) {
toolSet := createBaseToolSet("WSDL Service", "", cfg)
toolSet.Operations = make(map[string]mcp.OperationDetail)

baseURL := ""
if len(doc.Services) > 0 && len(doc.Services[0].Ports) > 0 {
baseURL = strings.TrimSuffix(doc.Services[0].Ports[0].Address.Location, "/")
}

messageMap := make(map[string]wsdl.Message)
for _, m := range doc.Messages {
messageMap[m.Name] = m
}

for _, pt := range doc.PortTypes {
for _, op := range pt.Operations {
msgName := strings.TrimPrefix(op.Input.Message, "tns:")
msg := messageMap[msgName]
schema := mcp.Schema{Type: "object", Properties: map[string]mcp.Schema{}}
for _, part := range msg.Parts {
schema.Properties[part.Name] = mcp.Schema{Type: "string"}
schema.Required = append(schema.Required, part.Name)
}

tool := mcp.Tool{Name: op.Name, Description: "", InputSchema: schema}
toolSet.Tools = append(toolSet.Tools, tool)

soapAction := findSOAPAction(doc.Bindings, pt.Name, op.Name)

toolSet.Operations[op.Name] = mcp.OperationDetail{
Method: "POST",
Path: "/",
BaseURL: baseURL,
SOAPAction: soapAction,
IsSOAP: true,
}
}
}

return toolSet, nil
}

func findSOAPAction(bindings []wsdl.Binding, portTypeName, opName string) string {
for _, b := range bindings {
if strings.HasSuffix(b.Type, portTypeName) {
for _, bo := range b.Operations {
if bo.Name == opName {
return bo.SOAPOp.SOAPAction
}
}
}
}
return ""
}

// mapJSONSchemaType ensures the type is one recognized by JSON Schema / MCP.
func mapJSONSchemaType(oapiType string) string {
switch strings.ToLower(oapiType) { // Normalize type
Expand Down
49 changes: 45 additions & 4 deletions pkg/parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ const minimalV2SpecJSON = `{
}
}`

// Minimal WSDL document
const minimalWSDL = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:tns="http://example.com/" targetNamespace="http://example.com/">
<message name="GetRequest">
<part name="id" type="xsd:string" xmlns:xsd="http://www.w3.org/2001/XMLSchema"/>
</message>
<portType name="SimplePort">
<operation name="Get">
<input message="tns:GetRequest"/>
</operation>
</portType>
<binding name="SimpleBinding" type="tns:SimplePort">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http"/>
<operation name="Get">
<soap:operation soapAction="http://example.com/Get"/>
</operation>
</binding>
<service name="SimpleService">
<port name="SimplePort" binding="tns:SimpleBinding">
<soap:address location="http://example.com/api"/>
</port>
</service>
</definitions>`

// Malformed JSON
const malformedJSON = `{
"openapi": "3.0.0",
Expand Down Expand Up @@ -426,14 +450,14 @@ func TestLoadSwagger(t *testing.T) {
content: malformedJSON,
fileName: "malformed.json",
expectError: true,
containsError: "failed to parse JSON",
containsError: "failed to detect",
},
{
name: "No version key JSON file",
content: noVersionKeyJSON,
fileName: "no_version.json",
expectError: true,
containsError: "missing 'openapi' or 'swagger' key",
containsError: "failed to detect",
},
{
name: "Non-existent file",
Expand Down Expand Up @@ -469,7 +493,7 @@ func TestLoadSwagger(t *testing.T) {
name: "Malformed JSON URL",
content: malformedJSON,
expectError: true,
containsError: "failed to parse JSON",
containsError: "failed to detect",
isURLTest: true,
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
Expand All @@ -480,7 +504,7 @@ func TestLoadSwagger(t *testing.T) {
name: "No version key JSON URL",
content: noVersionKeyJSON,
expectError: true,
containsError: "missing 'openapi' or 'swagger' key",
containsError: "failed to detect",
isURLTest: true,
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
Expand Down Expand Up @@ -1084,3 +1108,20 @@ func TestGenerateToolSet(t *testing.T) {
})
}
}

func TestGenerateToolSetWSDL(t *testing.T) {
tmp := t.TempDir()
wsdlPath := filepath.Join(tmp, "test.wsdl")
err := os.WriteFile(wsdlPath, []byte(minimalWSDL), 0644)
require.NoError(t, err)

doc, version, err := LoadSwagger(wsdlPath)
require.NoError(t, err)
require.Equal(t, VersionWSDL, version)

ts, err := GenerateToolSet(doc, version, &config.Config{})
require.NoError(t, err)
require.NotNil(t, ts)
assert.Equal(t, 1, len(ts.Tools))
assert.Contains(t, ts.Operations, "Get")
}
Loading