diff --git a/README.md b/README.md index e608fa0..7a09523 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 `.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 specifications or SOAP services. ## Table of Contents @@ -38,7 +38,8 @@ 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 Support:** Parses standard specification formats. + - **WSDL Support:** Basic conversion of SOAP WSDL operations into MCP tools. - **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/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/pkg/mcp/types.go b/pkg/mcp/types.go index f15e003..aa984bc 100644 --- a/pkg/mcp/types.go +++ b/pkg/mcp/types.go @@ -15,6 +15,8 @@ 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"` + IsSOAP bool `json:"isSoap,omitempty"` // Add RequestBody schema if needed } diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 0ab6cca..385284d 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -3,6 +3,7 @@ package parser import ( "context" "encoding/json" + "encoding/xml" "fmt" "io" "log" @@ -15,14 +16,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 @@ -68,46 +71,71 @@ func LoadSwagger(location string) (interface{}, string, error) { // 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) - } + 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 _, ok := detector["openapi"]; ok { - // OpenAPI 3.x - loader := openapi3.NewLoader() - loader.IsExternalRefsAllowed = true - var doc *openapi3.T - var loadErr error + if loadErr != nil { + return nil, "", fmt.Errorf("failed to load OpenAPI v3 spec from '%s': %w", location, loadErr) + } - 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 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 loadErr != nil { - return nil, "", fmt.Errorf("failed to load OpenAPI v3 spec from '%s': %w", location, loadErr) + } else { + // Failed to parse JSON - maybe this is a WSDL? + if strings.Contains(string(data), " 0 && len(defs.Services[0].Ports) > 0 { + addr := defs.Services[0].Ports[0].Address.Location + if addr != "" { + if u, err := url.Parse(addr); err == nil { + baseURL = fmt.Sprintf("%s://%s", u.Scheme, u.Host) + path = u.Path + if path == "" { + path = "/" + } + } + } + } + + msgMap := make(map[string]wsdl.Message) + for _, m := range defs.Messages { + msgMap[m.Name] = m + } + + for _, pt := range defs.PortTypes { + for _, op := range pt.Operations { + msg, ok := msgMap[trimPrefix(op.Input.Message)] + if !ok { + continue + } + + schema := mcp.Schema{Type: "object", Properties: map[string]mcp.Schema{}} + for _, p := range msg.Parts { + schema.Properties[p.Name] = mcp.Schema{Type: "string"} + } + + tool := mcp.Tool{Name: op.Name, Description: op.Documentation, InputSchema: schema} + toolSet.Tools = append(toolSet.Tools, tool) + + soapAction := "" + for _, b := range defs.Bindings { + for _, bop := range b.Operations { + if bop.Name == op.Name { + soapAction = bop.SoapAction + break + } + } + } + + toolSet.Operations[op.Name] = mcp.OperationDetail{ + Method: http.MethodPost, + Path: path, + BaseURL: strings.TrimSuffix(baseURL, "/"), + Parameters: []mcp.ParameterDetail{}, + SOAPAction: soapAction, + IsSOAP: true, + } + } + } + + return toolSet, nil +} + func determineBaseURLV2(doc *spec.Swagger, cfg *config.Config) (string, error) { if cfg.ServerBaseURL != "" { return strings.TrimSuffix(cfg.ServerBaseURL, "/"), nil @@ -976,3 +1073,10 @@ func sliceContains(slice []string, item string) bool { } return false } + +func trimPrefix(name string) string { + if idx := strings.Index(name, ":"); idx != -1 { + return name[idx+1:] + } + return name +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f80038c..de66240 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 ) @@ -568,7 +569,10 @@ func executeToolCall(params *ToolCallParams, toolSet *mcp.ToolSet, cfg *config.C cookieParams = append(cookieParams, clientCookies...) } bodyData := make(map[string]interface{}) // For building the request body - requestBodyRequired := operation.Method == "POST" || operation.Method == "PUT" || operation.Method == "PATCH" + requestBodyRequired := operation.Method == http.MethodPost || operation.Method == http.MethodPut || operation.Method == http.MethodPatch + if operation.IsSOAP { + requestBodyRequired = true + } // Create a map of expected parameters from the operation details for easier lookup expectedParams := make(map[string]string) // Map param name to its location ('in') @@ -691,7 +695,12 @@ 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 { + envelope := wsdl.BuildSOAPEnvelope(params.ToolName, toolInput) + bodyBytes = []byte(envelope) + reqBody = bytes.NewBuffer(bodyBytes) + log.Printf("[ExecuteToolCall] SOAP Envelope: %s", envelope) + } else if requestBodyRequired && len(bodyData) > 0 { var err error bodyBytes, err = json.Marshal(bodyData) if err != nil { @@ -711,9 +720,17 @@ 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") + 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") // Assume JSON response typical for APIs + 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..2a1c15f --- /dev/null +++ b/pkg/wsdl/wsdl.go @@ -0,0 +1,118 @@ +package wsdl + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// Definitions represents the top-level WSDL definitions element. +type Definitions struct { + XMLName xml.Name `xml:"definitions"` + Name string `xml:"name,attr"` + TargetNamespace string `xml:"targetNamespace,attr"` + Messages []Message `xml:"message"` + PortTypes []PortType `xml:"portType"` + Bindings []Binding `xml:"binding"` + Services []Service `xml:"service"` +} + +type Message struct { + Name string `xml:"name,attr"` + Parts []Part `xml:"part"` +} + +type Part struct { + Name string `xml:"name,attr"` + Element string `xml:"element,attr"` + Type string `xml:"type,attr"` +} + +type PortType struct { + Name string `xml:"name,attr"` + Operations []PortTypeOperation `xml:"operation"` +} + +type PortTypeOperation struct { + Name string `xml:"name,attr"` + Documentation string `xml:"documentation"` + Input OperationMessage `xml:"input"` + Output OperationMessage `xml:"output"` +} + +type OperationMessage struct { + Message string `xml:"message,attr"` +} + +type Binding struct { + Name string `xml:"name,attr"` + Type string `xml:"type,attr"` + Operations []BindingOperation `xml:"operation"` +} + +type BindingOperation struct { + Name string `xml:"name,attr"` + SoapAction string `xml:"operation>soapAction,attr"` +} + +type Service struct { + Name string `xml:"name,attr"` + Ports []ServicePort `xml:"port"` +} + +type ServicePort struct { + Name string `xml:"name,attr"` + Binding string `xml:"binding,attr"` + Address Address `xml:"address"` +} + +type Address struct { + Location string `xml:"location,attr"` +} + +// LoadWSDL loads a WSDL file from the provided location which can be a local path or a URL. +func LoadWSDL(location string) (*Definitions, error) { + var reader io.ReadCloser + var err error + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { + u, errURL := http.Get(location) + if errURL != nil { + return nil, errURL + } + reader = u.Body + } else { + reader, err = os.Open(location) + if err != nil { + return nil, err + } + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + var defs Definitions + if err := xml.Unmarshal(data, &defs); err != nil { + return nil, err + } + return &defs, nil +} + +// BuildSOAPEnvelope creates a basic SOAP 1.1 envelope for the given operation and parameters. +func BuildSOAPEnvelope(operation string, params map[string]interface{}) string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`<` + operation + `>`) + for k, v := range params { + b.WriteString(`<` + k + `>` + fmt.Sprintf("%v", v) + ``) + } + b.WriteString(``) + b.WriteString(``) + return b.String() +}