diff --git a/README.md b/README.md index e608fa0..153127b 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 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 @@ -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. @@ -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 \\ @@ -111,7 +111,7 @@ Alternatively, you can use the pre-built image available on [Docker Hub](https:/ * `--env-file `: Load environment variables from a local file (for API keys, etc.). Path is on the host. * `-e =""`: 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`) @@ -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) | diff --git a/cmd/openapi-mcp/main.go b/cmd/openapi-mcp/main.go index 4e05368..96963f7 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") @@ -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() diff --git a/example/wsdl/simple.wsdl b/example/wsdl/simple.wsdl new file mode 100644 index 0000000..311790b --- /dev/null +++ b/example/wsdl/simple.wsdl @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/mcp/types.go b/pkg/mcp/types.go index f15e003..b532f85 100644 --- a/pkg/mcp/types.go +++ b/pkg/mcp/types.go @@ -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. diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 0ab6cca..f89f24d 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -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 @@ -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. @@ -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) } @@ -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 diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go index 5313961..b3d1b3b 100644 --- a/pkg/parser/parser_test.go +++ b/pkg/parser/parser_test.go @@ -63,6 +63,30 @@ const minimalV2SpecJSON = `{ } }` +// Minimal WSDL document +const minimalWSDL = ` + + + + + + + + + + + + + + + + + + + + +` + // Malformed JSON const malformedJSON = `{ "openapi": "3.0.0", @@ -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", @@ -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) @@ -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) @@ -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") +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f80038c..22edd50 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -568,7 +568,7 @@ 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 == "POST" || operation.Method == "PUT" || operation.Method == "PATCH" || operation.IsSOAP // 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,15 +691,21 @@ 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 requestBodyRequired { var err error - bodyBytes, err = json.Marshal(bodyData) + if operation.IsSOAP { + bodyBytes, err = buildSOAPEnvelope(operation.Path, bodyData) + } else if len(bodyData) > 0 { + bodyBytes, err = json.Marshal(bodyData) + } if err != nil { - log.Printf("[ExecuteToolCall] Error marshalling request body: %v", err) - return nil, fmt.Errorf("error marshalling request body: %w", err) + log.Printf("[ExecuteToolCall] Error preparing request body: %v", err) + return nil, fmt.Errorf("error preparing request body: %w", err) + } + if len(bodyBytes) > 0 { + reqBody = bytes.NewBuffer(bodyBytes) + log.Printf("[ExecuteToolCall] Request body: %s", string(bodyBytes)) } - reqBody = bytes.NewBuffer(bodyBytes) - log.Printf("[ExecuteToolCall] Request body: %s", string(bodyBytes)) } // --- Create HTTP Request --- @@ -711,9 +717,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("Content-Type", "text/xml; charset=utf-8") + if operation.SOAPAction != "" { + req.Header.Set("SOAPAction", operation.SOAPAction) + } + req.Header.Set("Accept", "text/xml") + } 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 @@ -916,6 +930,23 @@ func getMethodFromResponse(resp jsonRPCResponse) string { return "unknown" } +// buildSOAPEnvelope creates a simple SOAP envelope with the operation name and parameters. +func buildSOAPEnvelope(operation string, params map[string]interface{}) ([]byte, error) { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString("<" + operation + ">") + for k, v := range params { + b.WriteString("<" + k + ">") + b.WriteString(fmt.Sprintf("%v", v)) + b.WriteString("") + } + b.WriteString("") + b.WriteString(``) + return []byte(b.String()), nil +} + // tryWriteHTTPError attempts to write an HTTP error, ignoring failures. func tryWriteHTTPError(w http.ResponseWriter, code int, message string) { if _, err := w.Write([]byte(message)); err != nil { diff --git a/pkg/wsdl/wsdl.go b/pkg/wsdl/wsdl.go new file mode 100644 index 0000000..bde547e --- /dev/null +++ b/pkg/wsdl/wsdl.go @@ -0,0 +1,116 @@ +package wsdl + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// Definitions represents the root of a WSDL document. +type Definitions struct { + XMLName xml.Name `xml:"definitions"` + TargetNamespace string `xml:"targetNamespace,attr"` + Services []Service `xml:"service"` + PortTypes []PortType `xml:"portType"` + Bindings []Binding `xml:"binding"` + Messages []Message `xml:"message"` +} + +type Service struct { + Name string `xml:"name,attr"` + Ports []Port `xml:"port"` +} + +type Port struct { + Name string `xml:"name,attr"` + Binding string `xml:"binding,attr"` + Address Address `xml:"address"` +} + +type Address struct { + Location string `xml:"location,attr"` +} + +type PortType struct { + Name string `xml:"name,attr"` + Operations []PortTypeOperation `xml:"operation"` +} + +type PortTypeOperation struct { + Name string `xml:"name,attr"` + 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"` + SOAPOp SOAPOperation `xml:"operation"` +} + +type SOAPOperation struct { + SOAPAction string `xml:"soapAction,attr"` + Style string `xml:"style,attr"` +} + +type Message struct { + Name string `xml:"name,attr"` + Parts []Part `xml:"part"` +} + +type Part struct { + Name string `xml:"name,attr"` + Type string `xml:"type,attr"` + Element string `xml:"element,attr"` +} + +// LoadWSDL reads a WSDL from a file path or URL and parses it. +func LoadWSDL(location string) (*Definitions, error) { + var reader io.ReadCloser + var err error + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { + resp, err := http.Get(location) + if err != nil { + return nil, fmt.Errorf("failed to fetch WSDL: %w", err) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("failed to fetch WSDL: status %d, body %s", resp.StatusCode, string(body)) + } + reader = resp.Body + } else { + file, err := os.Open(location) + if err != nil { + return nil, fmt.Errorf("failed to open WSDL file: %w", err) + } + reader = file + } + defer reader.Close() + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + return ParseWSDL(data) +} + +// ParseWSDL parses WSDL XML bytes into Definitions. +func ParseWSDL(data []byte) (*Definitions, error) { + var defs Definitions + if err := xml.Unmarshal(data, &defs); err != nil { + return nil, err + } + return &defs, nil +}