From 8281aff1be008d51b0dea0aa0c2b0bf7bf7758fa Mon Sep 17 00:00:00 2001 From: Mark Dai Date: Mon, 7 Jul 2025 13:32:09 +0800 Subject: [PATCH] Add WSDL support --- README.md | 6 +- cmd/openapi-mcp/main.go | 4 +- example/wsdl/tempconvert.wsdl | 154 +++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 6 ++ pkg/mcp/types.go | 3 + pkg/parser/parser.go | 156 ++++++++++++++++++++++++++-------- pkg/parser/parser_test.go | 36 +++++++- pkg/server/server.go | 23 ++++- pkg/wsdl/wsdl.go | 80 +++++++++++++++++ 10 files changed, 422 insertions(+), 47 deletions(-) create mode 100644 example/wsdl/tempconvert.wsdl create mode 100644 pkg/wsdl/wsdl.go diff --git a/README.md b/README.md index e608fa0..3fb2641 100644 --- a/README.md +++ b/README.md @@ -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 `service.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 spec file - no additional coding required. ## Table of Contents @@ -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/v3 & WSDL Support:** Parses standard OpenAPI or SOAP WSDL specifications. - **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. diff --git a/cmd/openapi-mcp/main.go b/cmd/openapi-mcp/main.go index 4e05368..452a293 100644 --- a/cmd/openapi-mcp/main.go +++ b/cmd/openapi-mcp/main.go @@ -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") @@ -126,7 +126,7 @@ func main() { // --- Call Parser --- specDoc, version, err := parser.LoadSwagger(cfg.SpecPath) if err != nil { - log.Fatalf("Failed to load OpenAPI/Swagger spec: %v", err) + log.Fatalf("Failed to load specification: %v", err) } log.Printf("Spec type %s loaded successfully from %s.\n", version, cfg.SpecPath) diff --git a/example/wsdl/tempconvert.wsdl b/example/wsdl/tempconvert.wsdl new file mode 100644 index 0000000..e80f08e --- /dev/null +++ b/example/wsdl/tempconvert.wsdl @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/go.mod b/go.mod index 8258de8..a168220 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-openapi/loads v0.22.0 github.com/go-openapi/spec v0.21.0 github.com/google/uuid v1.6.0 + github.com/hooklift/gowsdl v0.5.0 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.9.0 ) diff --git a/go.sum b/go.sum index 6058b23..f3f9edf 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE= @@ -26,6 +27,8 @@ github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hooklift/gowsdl v0.5.0 h1:DE8RevqhGPLchumV/V7OwbCzfJ8lcozFg1uWC/ESCBQ= +github.com/hooklift/gowsdl v0.5.0/go.mod h1:9kRc402w9Ci/Mek5a1DNgTmU14yPY8fMumxNVvxhis4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -52,6 +55,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= @@ -61,5 +66,6 @@ go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGc gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/mcp/types.go b/pkg/mcp/types.go index f15e003..d960a77 100644 --- a/pkg/mcp/types.go +++ b/pkg/mcp/types.go @@ -15,6 +15,9 @@ type OperationDetail struct { Path string `json:"path"` // Path template (e.g., /users/{id}) BaseURL string `json:"baseUrl"` Parameters []ParameterDetail `json:"parameters,omitempty"` + SOAPAction string `json:"soapAction,omitempty"` + Namespace string `json:"namespace,omitempty"` + IsSOAP bool `json:"isSoap,omitempty"` // Add RequestBody schema if needed } diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 0ab6cca..b14882a 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -15,14 +15,17 @@ 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" + gowsdl "github.com/hooklift/gowsdl" ) const ( - VersionV2 = "v2" - VersionV3 = "v3" + VersionV2 = "v2" + VersionV3 = "v3" + VersionWSDL = "wsdl" ) // LoadSwagger detects the version and loads an OpenAPI/Swagger specification @@ -66,48 +69,56 @@ 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 { + // 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 !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 - 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) + } + return doc.Spec(), VersionV2, nil } + } - 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 JSON detection fails, attempt WSDL (XML) + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, "<") { + wsdlDoc, err := wsdl.LoadWSDL(location) 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 WSDL spec from '%s': %w", location, err) } - 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 wsdlDoc, VersionWSDL, nil } + + return nil, "", fmt.Errorf("failed to detect OpenAPI/Swagger version in '%s': missing 'openapi' or 'swagger' key", location) } // GenerateToolSet converts a loaded spec (v2 or v3) into an MCP ToolSet. @@ -125,6 +136,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: + docWSDL, ok := specDoc.(*gowsdl.WSDL) + if !ok { + return nil, fmt.Errorf("internal error: expected *gowsdl.WSDL for wsdl spec, got %T", specDoc) + } + return generateToolSetWSDL(docWSDL, cfg) default: return nil, fmt.Errorf("unsupported specification version: %s", version) } @@ -550,6 +567,73 @@ func generateToolSetV2(doc *spec.Swagger, cfg *config.Config) (*mcp.ToolSet, err return toolSet, nil } +func generateToolSetWSDL(doc *gowsdl.WSDL, cfg *config.Config) (*mcp.ToolSet, error) { + toolSet := createBaseToolSet(doc.Name, doc.Doc, cfg) + toolSet.Operations = make(map[string]mcp.OperationDetail) + + baseURL := "" + path := "" + if len(doc.Service) > 0 && len(doc.Service[0].Ports) > 0 { + addr := doc.Service[0].Ports[0].SOAPAddress.Location + if u, err := url.Parse(addr); err == nil { + baseURL = u.Scheme + "://" + u.Host + path = u.Path + } else { + baseURL = strings.TrimSuffix(addr, "/") + } + } + + msgMap := make(map[string]*gowsdl.WSDLMessage) + for _, m := range doc.Messages { + msgMap[m.Name] = m + } + + namespace := doc.TargetNamespace + + for _, pt := range doc.PortTypes { + for _, op := range pt.Operations { + toolName := op.Name + toolDesc := op.Doc + + msgName := stripNamespace(op.Input.Message) + msg := msgMap[msgName] + inputSchema := mcp.Schema{Type: "object", Properties: map[string]mcp.Schema{}} + params := []mcp.ParameterDetail{} + if msg != nil { + for _, p := range msg.Parts { + inputSchema.Properties[p.Name] = mcp.Schema{Type: "string"} + params = append(params, mcp.ParameterDetail{Name: p.Name, In: "body"}) + } + } + + toolSet.Tools = append(toolSet.Tools, mcp.Tool{ + Name: toolName, + Description: toolDesc, + InputSchema: inputSchema, + }) + + toolSet.Operations[toolName] = mcp.OperationDetail{ + Method: "POST", + Path: path, + BaseURL: baseURL, + Parameters: params, + SOAPAction: op.SOAPOperation.SOAPAction, + IsSOAP: true, + Namespace: namespace, + } + } + } + + return toolSet, nil +} + +func stripNamespace(name string) string { + if idx := strings.Index(name, ":"); idx != -1 { + return name[idx+1:] + } + return name +} + func determineBaseURLV2(doc *spec.Swagger, cfg *config.Config) (string, error) { if cfg.ServerBaseURL != "" { return strings.TrimSuffix(cfg.ServerBaseURL, "/"), nil diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 5313961..d80b6f0 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -396,6 +396,31 @@ const fileV2SpecJSON = `{ } }` +// Minimal WSDL +const minimalWSDL = ` + + + + + + + + + + + + + + + + + + + + + +` + func TestLoadSwagger(t *testing.T) { tests := []struct { name string @@ -421,12 +446,19 @@ func TestLoadSwagger(t *testing.T) { expectError: false, expectVersion: VersionV2, }, + { + name: "Valid WSDL file", + content: minimalWSDL, + fileName: "service.wsdl", + expectError: false, + expectVersion: VersionWSDL, + }, { name: "Malformed JSON file", content: malformedJSON, fileName: "malformed.json", expectError: true, - containsError: "failed to parse JSON", + containsError: "failed to detect OpenAPI/Swagger version", }, { name: "No version key JSON file", @@ -469,7 +501,7 @@ func TestLoadSwagger(t *testing.T) { name: "Malformed JSON URL", content: malformedJSON, expectError: true, - containsError: "failed to parse JSON", + containsError: "failed to detect OpenAPI/Swagger version", isURLTest: true, handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/pkg/server/server.go b/pkg/server/server.go index f80038c..325113f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/ckanthony/openapi-mcp/pkg/config" "github.com/ckanthony/openapi-mcp/pkg/mcp" + "github.com/ckanthony/openapi-mcp/pkg/wsdl" "github.com/google/uuid" // Import UUID package ) @@ -691,7 +692,11 @@ func executeToolCall(params *ToolCallParams, toolSet *mcp.ToolSet, cfg *config.C // --- Prepare Request Body --- var reqBody io.Reader var bodyBytes []byte // Keep for logging - if requestBodyRequired && len(bodyData) > 0 { + if operation.IsSOAP { + bodyBytes = []byte(wsdl.BuildSOAPEnvelope(operation.Namespace, toolName, toolInput)) + reqBody = bytes.NewBuffer(bodyBytes) + log.Printf("[ExecuteToolCall] SOAP envelope: %s", string(bodyBytes)) + } else if requestBodyRequired && len(bodyData) > 0 { var err error bodyBytes, err = json.Marshal(bodyData) if err != nil { @@ -711,9 +716,19 @@ func executeToolCall(params *ToolCallParams, toolSet *mcp.ToolSet, cfg *config.C // --- Set Headers --- // Default headers - req.Header.Set("Accept", "application/json") // Assume JSON response typical for APIs - if reqBody != nil { - req.Header.Set("Content-Type", "application/json") // Assume JSON body if body exists + if operation.IsSOAP { + req.Header.Set("Accept", "text/xml") + if reqBody != nil { + req.Header.Set("Content-Type", "text/xml; charset=utf-8") + } + if operation.SOAPAction != "" { + req.Header.Set("SOAPAction", operation.SOAPAction) + } + } else { + req.Header.Set("Accept", "application/json") + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } } // Add headers collected from input/spec AND potentially injected API key diff --git a/pkg/wsdl/wsdl.go b/pkg/wsdl/wsdl.go new file mode 100644 index 0000000..4c7040d --- /dev/null +++ b/pkg/wsdl/wsdl.go @@ -0,0 +1,80 @@ +package wsdl + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "strings" + + gowsdl "github.com/hooklift/gowsdl" +) + +// LoadWSDL reads a WSDL file from a local path or URL and unmarshals it +// into the gowsdl.WSDL structure. +func LoadWSDL(location string) (*gowsdl.WSDL, error) { + loc, err := gowsdl.ParseLocation(location) + if err != nil { + return nil, err + } + + locStr := loc.String() + var data []byte + if strings.HasPrefix(locStr, "http://") || strings.HasPrefix(locStr, "https://") { + resp, err := http.Get(locStr) + if err != nil { + return nil, fmt.Errorf("failed to fetch WSDL '%s': %w", locStr, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to fetch WSDL '%s': status %d, body: %s", locStr, resp.StatusCode, string(body)) + } + data, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + } else { + b, err := os.ReadFile(locStr) + if err != nil { + return nil, err + } + data = b + } + + ws := new(gowsdl.WSDL) + if err := xml.Unmarshal(data, ws); err != nil { + return nil, err + } + return ws, nil +} + +// BuildSOAPEnvelope creates a simple SOAP 1.1 envelope with the given +// operation name and parameters. The namespace will be used as the +// prefix 'tns' if provided. +func BuildSOAPEnvelope(namespace, operation string, params map[string]interface{}) string { + var sb strings.Builder + sb.WriteString(``) + sb.WriteString(``) + sb.WriteString(``) + if namespace != "" { + sb.WriteString(``) + } else { + sb.WriteString(`<` + operation + `>`) + } + for k, v := range params { + sb.WriteString("<" + k + ">" + fmt.Sprintf("%v", v) + "") + } + if namespace != "" { + sb.WriteString(``) + } else { + sb.WriteString(``) + } + sb.WriteString(``) + return sb.String() +}