#
tokens: 38669/50000 16/19 files (page 1/3)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 1 of 3. Use http://codebase.md/ckanthony/openapi-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .github
│   └── workflows
│       ├── ci.yml
│       └── publish.yml
├── .gitignore
├── cmd
│   └── openapi-mcp
│       └── main.go
├── Dockerfile
├── example
│   ├── agent_demo.png
│   ├── docker-compose.yml
│   └── weather
│       ├── .env.example
│       └── weatherbitio-swagger.json
├── go.mod
├── go.sum
├── openapi-mcp.png
├── pkg
│   ├── config
│   │   ├── config_test.go
│   │   └── config.go
│   ├── mcp
│   │   └── types.go
│   ├── parser
│   │   ├── parser_test.go
│   │   └── parser.go
│   └── server
│       ├── manager_test.go
│       ├── manager.go
│       ├── server_test.go
│       └── server.go
└── README.md
```

# Files

--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------

```
 1 | # Git files
 2 | .git
 3 | .gitignore
 4 | 
 5 | # Docker files
 6 | .dockerignore
 7 | Dockerfile
 8 | 
 9 | # Documentation
10 | *.md
11 | 
12 | # Environment files (except example)
13 | .env
14 | *.env
15 | !.env.example
16 | 
17 | # Go cache and modules (handled in multi-stage build)
18 | vendor/
19 | 
20 | # Local build artifacts
21 | openapi-mcp
22 | *.exe
23 | *.test
24 | *.out
25 | 
26 | # OS generated files
27 | .DS_Store
28 | *~ 
```

--------------------------------------------------------------------------------
/example/weather/.env.example:
--------------------------------------------------------------------------------

```
1 | # Example environment variables for the Weatherbit API example.
2 | # Copy this file to .env in the same directory (example/weather/.env)
3 | # and replace placeholders with your actual values.
4 | 
5 | # Required: Your Weatherbit API Key
6 | API_KEY=YOUR_WEATHERBIT_API_KEY_HERE
7 | 
8 | # Optional: Custom headers (JSON format)
9 | # REQUEST_HEADERS='{"X-Client-ID": "MyTestClient"}' 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Binaries for programs and plugins
 2 | *.exe
 3 | *.exe~
 4 | *.dll
 5 | *.so
 6 | *.dylib
 7 | 
 8 | # Test binary, built with `go test -c`
 9 | *.test
10 | 
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 | 
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 | 
17 | # Go workspace file
18 | go.work
19 | go.work.sum
20 | 
21 | # Environment configuration files
22 | .env
23 | *.env
24 | !.env.example
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # OpenAPI-MCP: Dockerized MCP Server to allow your AI agent to access any API with existing api docs
  2 | 
  3 | [![Go Reference](https://pkg.go.dev/badge/github.com/ckanthony/openapi-mcp.svg)](https://pkg.go.dev/github.com/ckanthony/openapi-mcp)
  4 | [![CI](https://github.com/ckanthony/openapi-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/ckanthony/openapi-mcp/actions/workflows/ci.yml)
  5 | [![codecov](https://codecov.io/gh/ckanthony/openapi-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/ckanthony/openapi-mcp)
  6 | ![](https://badge.mcpx.dev?type=dev 'MCP Dev')
  7 | 
  8 | ![openapi-mcp logo](openapi-mcp.png)
  9 | 
 10 | **Generate MCP tool definitions directly from a Swagger/OpenAPI specification file.**
 11 | 
 12 | 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.
 13 | 
 14 | ## Table of Contents
 15 | 
 16 | -   [Why OpenAPI-MCP?](#why-openapi-mcp)
 17 | -   [Features](#features)
 18 | -   [Installation](#installation)
 19 |     -   [Using the Pre-built Docker Hub Image (Recommended)](#using-the-pre-built-docker-hub-image-recommended)
 20 |     -   [Building Locally (Optional)](#building-locally-optional)
 21 | -   [Running the Weatherbit Example (Step-by-Step)](#running-the-weatherbit-example-step-by-step)
 22 | -   [Command-Line Options](#command-line-options)
 23 |     -   [Environment Variables](#environment-variables)
 24 | 
 25 | ## Demo
 26 | 
 27 | Run the demo yourself: [Running the Weatherbit Example (Step-by-Step)](#running-the-weatherbit-example-step-by-step)
 28 | 
 29 | ![demo](https://github.com/user-attachments/assets/4d457137-5da4-422a-b323-afd4b175bd56)
 30 | 
 31 | ## Why OpenAPI-MCP?
 32 | 
 33 | -   **Standard Compliance:** Leverage your existing OpenAPI/Swagger documentation.
 34 | -   **Automatic Tool Generation:** Create MCP tools without manual configuration for each endpoint.
 35 | -   **Flexible API Key Handling:** Securely manage API key authentication for the proxied API without exposing keys to the MCP client.
 36 | -   **Local & Remote Specs:** Works with local specification files or remote URLs.
 37 | -   **Dockerized Tool:** Easily deploy and run as a containerized service with Docker.
 38 | 
 39 | ## Features
 40 | 
 41 | -   **OpenAPI v2 (Swagger) & v3 Support:** Parses standard specification formats.
 42 | -   **Schema Generation:** Creates MCP tool schemas from OpenAPI operation parameters and request/response definitions.
 43 | -   **Secure API Key Management:**
 44 |     -   Injects API keys into requests (`header`, `query`, `path`, `cookie`) based on command-line configuration.
 45 |         -   Loads API keys directly from flags (`--api-key`), environment variables (`--api-key-env`), or `.env` files located alongside local specs.
 46 |         -   Keeps API keys hidden from the end MCP client (e.g., the AI assistant).
 47 | -   **Server URL Detection:** Uses server URLs from the spec as the base for tool interactions (can be overridden).
 48 | -   **Filtering:** Options to include/exclude specific operations or tags (`--include-tag`, `--exclude-tag`, `--include-op`, `--exclude-op`).
 49 | -   **Request Header Injection:** Pass custom headers (e.g., for additional auth, tracing) via the `REQUEST_HEADERS` environment variable.
 50 | 
 51 | ## Installation
 52 | 
 53 | ### Docker
 54 | 
 55 | The recommended way to run this tool is via [Docker](https://hub.docker.com/r/ckanthony/openapi-mcp).
 56 | 
 57 | #### Using the Pre-built Docker Hub Image (Recommended)
 58 | 
 59 | Alternatively, you can use the pre-built image available on [Docker Hub](https://hub.docker.com/r/ckanthony/openapi-mcp).
 60 | 
 61 | 1.  **Pull the Image:**
 62 |     ```bash
 63 |     docker pull ckanthony/openapi-mcp:latest
 64 |     ```
 65 | 2.  **Run the Container:**
 66 |     Follow the `docker run` examples above, but replace `openapi-mcp:latest` with `ckanthony/openapi-mcp:latest`.
 67 | 
 68 | #### Building Locally (Optional)
 69 | 
 70 | 1.  **Build the Docker Image Locally:**
 71 |     ```bash
 72 |     # Navigate to the repository root
 73 |     cd openapi-mcp
 74 |     # Build the Docker image (tag it as you like, e.g., openapi-mcp:latest)
 75 |     docker build -t openapi-mcp:latest .
 76 |     ```
 77 | 
 78 | 2.  **Run the Container:**
 79 |     You need to provide the OpenAPI specification and any necessary API key configuration when running the container.
 80 | 
 81 |     *   **Example 1: Using a local spec file and `.env` file:**
 82 |         -   Create a directory (e.g., `./my-api`) containing your `openapi.json` or `swagger.yaml`.
 83 |         -   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).
 84 |         ```bash
 85 |         docker run -p 8080:8080 --rm \\
 86 |             -v $(pwd)/my-api:/app/spec \\
 87 |             --env-file $(pwd)/my-api/.env \\
 88 |             openapi-mcp:latest \\
 89 |             --spec /app/spec/openapi.json \\
 90 |             --api-key-env API_KEY \\
 91 |             --api-key-name X-API-Key \\
 92 |             --api-key-loc header
 93 |         ```
 94 |         *(Adjust `--spec`, `--api-key-env`, `--api-key-name`, `--api-key-loc`, and `-p` as needed.)*
 95 | 
 96 |     *   **Example 2: Using a remote spec URL and direct environment variable:**
 97 |         ```bash
 98 |         docker run -p 8080:8080 --rm \\
 99 |             -e SOME_API_KEY="your_actual_key" \\
100 |             openapi-mcp:latest \\
101 |             --spec https://petstore.swagger.io/v2/swagger.json \\
102 |             --api-key-env SOME_API_KEY \\
103 |             --api-key-name api_key \\
104 |             --api-key-loc header
105 |         ```
106 | 
107 |     *   **Key Docker Run Options:**
108 |         *   `-p <host_port>:8080`: Map a port on your host to the container's default port 8080.
109 |         *   `--rm`: Automatically remove the container when it exits.
110 |         *   `-v <host_path>:<container_path>`: Mount a local directory containing your spec into the container. Use absolute paths or `$(pwd)/...`. Common container path: `/app/spec`.
111 |         *   `--env-file <path_to_host_env_file>`: Load environment variables from a local file (for API keys, etc.). Path is on the host.
112 |         *   `-e <VAR_NAME>="<value>"`: Pass a single environment variable directly.
113 |         *   `openapi-mcp:latest`: The name of the image you built locally.
114 |         *   `--spec ...`: **Required.** Path to the spec file *inside the container* (e.g., `/app/spec/openapi.json`) or a public URL.
115 |         *   `--port 8080`: (Optional) Change the internal port the server listens on (must match the container port in `-p`).
116 |         *   `--api-key-env`, `--api-key-name`, `--api-key-loc`: Required if the target API needs an API key.
117 |         *   (See `--help` for all command-line options by running `docker run --rm openapi-mcp:latest --help`)
118 | 
119 | 
120 | ## Running the Weatherbit Example (Step-by-Step)
121 | 
122 | This repository includes an example using the [Weatherbit API](https://www.weatherbit.io/). Here's how to run it using the public Docker image:
123 | 
124 | 1.  **Find OpenAPI Specs (Optional Knowledge):**
125 |     Many public APIs have their OpenAPI/Swagger specifications available online. A great resource for discovering them is [APIs.guru](https://apis.guru/). The Weatherbit specification used in this example (`weatherbitio-swagger.json`) was sourced from there.
126 | 
127 | 2.  **Get a Weatherbit API Key:**
128 |     *   Go to [Weatherbit.io](https://www.weatherbit.io/) and sign up for an account (they offer a free tier).
129 |     *   Find your API key in your Weatherbit account dashboard.
130 | 
131 | 3.  **Clone this Repository:**
132 |     You need the example files from this repository.
133 |     ```bash
134 |     git clone https://github.com/ckanthony/openapi-mcp.git
135 |     cd openapi-mcp
136 |     ```
137 | 
138 | 4.  **Prepare Environment File:**
139 |     *   Navigate to the example directory: `cd example/weather`
140 |     *   Copy the example environment file: `cp .env.example .env`
141 |     *   Edit the new `.env` file and replace `YOUR_WEATHERBIT_API_KEY_HERE` with the actual API key you obtained from Weatherbit.
142 | 
143 | 5.  **Run the Docker Container:**
144 |     From the `openapi-mcp` **root directory** (the one containing the `example` folder), run the following command:
145 |     ```bash
146 |     docker run -p 8080:8080 --rm \\
147 |         -v $(pwd)/example/weather:/app/spec \\
148 |         --env-file $(pwd)/example/weather/.env \\
149 |         ckanthony/openapi-mcp:latest \\
150 |         --spec /app/spec/weatherbitio-swagger.json \\
151 |         --api-key-env API_KEY \\
152 |         --api-key-name key \\
153 |         --api-key-loc query
154 |     ```
155 |     *   `-v $(pwd)/example/weather:/app/spec`: Mounts the local `example/weather` directory (containing the spec and `.env` file) to `/app/spec` inside the container.
156 |     *   `--env-file $(pwd)/example/weather/.env`: Tells Docker to load environment variables (specifically `API_KEY`) from your `.env` file.
157 |     *   `ckanthony/openapi-mcp:latest`: Uses the public Docker image.
158 |     *   `--spec /app/spec/weatherbitio-swagger.json`: Points to the spec file inside the container.
159 |     *   The `--api-key-*` flags configure how the tool should inject the API key (read from the `API_KEY` env var, named `key`, placed in the `query` string).
160 | 
161 | 6.  **Access the MCP Server:**
162 |     The MCP server should now be running and accessible at `http://localhost:8080` for compatible clients.
163 | 
164 | **Using Docker Compose (Example):**
165 | 
166 | A `docker-compose.yml` file is provided in the `example/` directory to demonstrate running the Weatherbit API example using the *locally built* image.
167 | 
168 | 1.  **Prepare Environment File:** Copy `example/weather/.env.example` to `example/weather/.env` and add your actual Weatherbit API key:
169 |     ```dotenv
170 |     # example/weather/.env
171 |     API_KEY=YOUR_ACTUAL_WEATHERBIT_KEY
172 |     ```
173 | 
174 | 2.  **Run with Docker Compose:** Navigate to the `example` directory and run:
175 |     ```bash
176 |     cd example
177 |     # This builds the image locally based on ../Dockerfile
178 |     # It does NOT use the public Docker Hub image
179 |     docker-compose up --build
180 |     ```
181 |     *   `--build`: Forces Docker Compose to build the image using the `Dockerfile` in the project root before starting the service.
182 |     *   Compose will read `example/docker-compose.yml`, build the image, mount `./weather`, read `./weather/.env`, and start the `openapi-mcp` container with the specified command-line arguments.
183 |     *   The MCP server will be available at `http://localhost:8080`.
184 | 
185 | 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.
186 | 
187 | ## Command-Line Options
188 | 
189 | The `openapi-mcp` command accepts the following flags:
190 | 
191 | | Flag                 | Description                                                                                                         | Type          | Default                          |
192 | |----------------------|---------------------------------------------------------------------------------------------------------------------|---------------|----------------------------------|
193 | | `--spec`             | **Required.** Path or URL to the OpenAPI specification file.                                                          | `string`      | (none)                           |
194 | | `--port`             | Port to run the MCP server on.                                                                                      | `int`         | `8080`                           |
195 | | `--api-key`          | Direct API key value (use `--api-key-env` or `.env` file instead for security).                                       | `string`      | (none)                           |
196 | | `--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)                           |
197 | | `--api-key-name`     | **Required if key used.** Name of the API key parameter (header, query, path, or cookie name).                       | `string`      | (none)                           |
198 | | `--api-key-loc`      | **Required if key used.** Location of API key: `header`, `query`, `path`, or `cookie`.                              | `string`      | (none)                           |
199 | | `--include-tag`      | Tag to include (can be repeated). If include flags are used, only included items are exposed.                       | `string slice`| (none)                           |
200 | | `--exclude-tag`      | Tag to exclude (can be repeated). Exclusions apply after inclusions.                                                | `string slice`| (none)                           |
201 | | `--include-op`       | Operation ID to include (can be repeated).                                                                          | `string slice`| (none)                           |
202 | | `--exclude-op`       | Operation ID to exclude (can be repeated).                                                                          | `string slice`| (none)                           |
203 | | `--base-url`         | Manually override the target API server base URL detected from the spec.                                              | `string`      | (none)                           |
204 | | `--name`             | Default name for the generated MCP toolset (used if spec has no title).                                             | `string`      | "OpenAPI-MCP Tools"            |
205 | | `--desc`             | Default description for the generated MCP toolset (used if spec has no description).                                | `string`      | "Tools generated from OpenAPI spec" |
206 | 
207 | **Note:** You can get this list by running the tool with the `--help` flag (e.g., `docker run --rm ckanthony/openapi-mcp:latest --help`).
208 | 
209 | ### Environment Variables
210 | 
211 | *   `REQUEST_HEADERS`: Set this environment variable to a JSON string (e.g., `'{"X-Custom": "Value"}'`) to add custom headers to *all* outgoing requests to the target API.
212 | 
```

--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: CI
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [ main ]
 6 |   pull_request:
 7 |     branches: [ main ]
 8 | 
 9 | jobs:
10 |   test:
11 |     name: Test
12 |     environment: CI
13 |     runs-on: ubuntu-latest
14 |     steps:
15 |       - uses: actions/checkout@v4
16 | 
17 |       - name: Set up Go
18 |         uses: actions/setup-go@v5
19 |         with:
20 |           go-version: '1.21'
21 |           cache: true
22 | 
23 |       - name: Install dependencies
24 |         run: go mod tidy
25 |         working-directory: .
26 | 
27 |       - name: Run tests with coverage
28 |         run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
29 |         working-directory: .
30 | 
31 |       - name: Upload coverage to Codecov
32 |         uses: codecov/codecov-action@v5
33 |         with:
34 |           token: ${{ secrets.CODECOV_TOKEN }}
35 |           slug: ckanthony/openapi-mcp
```

--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: Publish Docker image
 2 | 
 3 | on:
 4 |   push:
 5 |     tags:
 6 |       - 'v*.*.*' # Trigger on version tags like v1.0.0
 7 | 
 8 | jobs:
 9 |   push_to_registry:
10 |     name: Build and push Docker image to Docker Hub
11 |     environment: CI
12 |     runs-on: ubuntu-latest
13 |     steps:
14 |       - name: Check out the repo
15 |         uses: actions/checkout@v4
16 | 
17 |       - name: Log in to Docker Hub
18 |         uses: docker/login-action@v3
19 |         with:
20 |           username: ckanthony
21 |           password: ${{ secrets.DOCKERHUB_TOKEN }}
22 | 
23 |       - name: Set up Docker Buildx
24 |         uses: docker/setup-buildx-action@v3
25 | 
26 |       - name: Extract metadata (tags, labels) for Docker
27 |         id: meta
28 |         uses: docker/metadata-action@v5
29 |         with:
30 |           images: ckanthony/openapi-mcp
31 |           # Add git tag as Docker tag
32 |           tags: |
33 |             type=semver,pattern={{version}}
34 |             type=semver,pattern={{major}}.{{minor}}
35 |             type=raw,value=latest,enable={{is_default_branch}}
36 | 
37 |       - name: Build and push Docker image
38 |         uses: docker/build-push-action@v5
39 |         with:
40 |           context: .
41 |           push: true
42 |           tags: ${{ steps.meta.outputs.tags }}
43 |           labels: ${{ steps.meta.outputs.labels }}
44 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | # --- Build Stage ---
 2 | ARG GO_VERSION=1.22
 3 | FROM golang:${GO_VERSION}-alpine AS builder
 4 | 
 5 | WORKDIR /app
 6 | 
 7 | # Copy Go modules and download dependencies first
 8 | # This layer is cached unless go.mod or go.sum changes
 9 | COPY go.mod go.sum ./
10 | RUN go mod download
11 | 
12 | # Copy the rest of the application source code
13 | COPY . .
14 | 
15 | # Build the static binary for the command-line tool
16 | # CGO_ENABLED=0 produces a static binary, important for distroless/scratch images
17 | # -ldflags="-s -w" strips debug symbols and DWARF info, reducing binary size
18 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /openapi-mcp ./cmd/openapi-mcp/main.go
19 | 
20 | # --- Final Stage ---
21 | # Use a minimal base image. distroless/static is very small and secure.
22 | # alpine is another good option if you need a shell for debugging.
23 | # FROM alpine:latest
24 | FROM gcr.io/distroless/static-debian12 AS final
25 | 
26 | # Copy the static binary from the builder stage
27 | COPY --from=builder /openapi-mcp /openapi-mcp
28 | 
29 | # Copy example files (optional, but useful for demonstrating)
30 | COPY example /app/example
31 | 
32 | WORKDIR /app
33 | 
34 | # Define the default command to run when the container starts
35 | # Users can override this command or provide arguments like --spec, --port etc.
36 | ENTRYPOINT ["/openapi-mcp"]
37 | 
38 | # Expose the default port (optional, good documentation)
39 | EXPOSE 8080 
```

--------------------------------------------------------------------------------
/example/docker-compose.yml:
--------------------------------------------------------------------------------

```yaml
 1 | version: '3.8' # Specifies the Docker Compose file version
 2 | 
 3 | services:
 4 |   openapi-mcp:
 5 |     # Build the image using the Dockerfile located in the parent directory
 6 |     build:
 7 |       context: .. # The context is the parent directory (project root)
 8 |       dockerfile: Dockerfile # Explicitly points to the Dockerfile
 9 |     image: openapi-mcp-example-weather-compose:latest # Optional: Name the image built by compose
10 |     container_name: openapi-mcp-example-weather-service # Sets a specific name for the container
11 | 
12 |     ports:
13 |       # Map port 8080 on the host to port 8080 in the container
14 |       - "8080:8080"
15 | 
16 |     volumes:
17 |       # Mount the local './weather' directory (relative to this compose file)
18 |       # to '/app/example/weather' inside the container.
19 |       # This makes the spec file accessible to the application.
20 |       - ./weather:/app/example/weather
21 | 
22 |     # Load environment variables from the .env file located in ./weather
23 |     # This is the recommended way to handle secrets like API keys.
24 |     # Ensure 'example/weather/.env' exists and defines API_KEY.
25 |     env_file:
26 |       - ./weather/.env
27 | 
28 |     # Define the command to run inside the container, overriding the Dockerfile's CMD/ENTRYPOINT args
29 |     # Uses the variables loaded from the env_file.
30 |     # Make sure the --spec path matches the volume mount point.
31 |     command: >
32 |       --spec /app/example/weather/weatherbitio-swagger.json
33 |       --api-key-env API_KEY
34 |       --api-key-name key
35 |       --api-key-loc query
36 |       --port 8080 # The port the app listens on inside the container
37 | 
38 |     # Restart policy: Automatically restart the container unless it was manually stopped.
39 |     restart: unless-stopped 
```

--------------------------------------------------------------------------------
/pkg/server/manager.go:
--------------------------------------------------------------------------------

```go
 1 | package server
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 	"log"
 6 | 	"net/http"
 7 | 	"sync"
 8 | )
 9 | 
10 | // client holds information about a connected SSE client.
11 | type client struct {
12 | 	writer  http.ResponseWriter
13 | 	flusher http.Flusher
14 | 	// channel chan []byte // Could be used later for broadcasting updates
15 | }
16 | 
17 | // connectionManager manages active client connections.
18 | type connectionManager struct {
19 | 	clients map[*http.Request]*client // Use request ptr as key
20 | 	mu      sync.RWMutex
21 | 	toolSet []byte // Pre-encoded toolset JSON
22 | }
23 | 
24 | // newConnectionManager creates a manager.
25 | func newConnectionManager(toolSetJSON []byte) *connectionManager {
26 | 	return &connectionManager{
27 | 		clients: make(map[*http.Request]*client),
28 | 		toolSet: toolSetJSON,
29 | 	}
30 | }
31 | 
32 | // addClient registers a new client and sends the initial toolset.
33 | func (m *connectionManager) addClient(r *http.Request, w http.ResponseWriter, f http.Flusher) {
34 | 	newClient := &client{writer: w, flusher: f}
35 | 	m.mu.Lock()
36 | 	m.clients[r] = newClient
37 | 	m.mu.Unlock()
38 | 
39 | 	log.Printf("Client connected: %s (Total: %d)", r.RemoteAddr, m.getClientCount())
40 | 
41 | 	// Send initial toolset immediately
42 | 	go m.sendToolset(newClient) // Send in a goroutine to avoid blocking registration?
43 | }
44 | 
45 | // removeClient removes a client.
46 | func (m *connectionManager) removeClient(r *http.Request) {
47 | 	m.mu.Lock()
48 | 	_, ok := m.clients[r]
49 | 	if ok {
50 | 		delete(m.clients, r)
51 | 		log.Printf("Client disconnected: %s (Total: %d)", r.RemoteAddr, len(m.clients))
52 | 	} else {
53 | 		log.Printf("Attempted to remove already disconnected client: %s", r.RemoteAddr)
54 | 	}
55 | 	m.mu.Unlock()
56 | }
57 | 
58 | // getClientCount returns the number of active clients.
59 | func (m *connectionManager) getClientCount() int {
60 | 	m.mu.RLock()
61 | 	count := len(m.clients)
62 | 	m.mu.RUnlock()
63 | 	return count
64 | }
65 | 
66 | // sendToolset sends the pre-encoded toolset to a specific client.
67 | func (m *connectionManager) sendToolset(c *client) {
68 | 	if c == nil {
69 | 		return
70 | 	}
71 | 	log.Printf("Attempting to send toolset to client...")
72 | 	_, err := fmt.Fprintf(c.writer, "event: tool_set\ndata: %s\n\n", string(m.toolSet))
73 | 	if err != nil {
74 | 		// This error often happens if the client disconnected before/during the write
75 | 		log.Printf("Error sending toolset data to client: %v (client likely disconnected)", err)
76 | 		// Optionally trigger removal here if possible, though context done in handler is primary mechanism
77 | 		return
78 | 	}
79 | 	// Flush the data
80 | 	c.flusher.Flush()
81 | 	log.Println("Sent tool_set event and flushed.")
82 | }
83 | 
```

--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------

```go
 1 | package config
 2 | 
 3 | import (
 4 | 	"log"
 5 | 	"os"
 6 | )
 7 | 
 8 | // APIKeyLocation specifies where the API key is located for requests.
 9 | type APIKeyLocation string
10 | 
11 | const (
12 | 	APIKeyLocationHeader APIKeyLocation = "header"
13 | 	APIKeyLocationQuery  APIKeyLocation = "query"
14 | 	APIKeyLocationPath   APIKeyLocation = "path"
15 | 	APIKeyLocationCookie APIKeyLocation = "cookie"
16 | 	// APIKeyLocationCookie APIKeyLocation = "cookie" // Add if needed
17 | )
18 | 
19 | // Config holds the configuration for generating the MCP toolset.
20 | type Config struct {
21 | 	SpecPath string // Path or URL to the OpenAPI specification file.
22 | 
23 | 	// API Key details (optional, inferred from spec if possible)
24 | 	APIKey           string         // The actual API key value.
25 | 	APIKeyName       string         // Name of the header or query parameter for the API key (e.g., "X-API-Key", "api_key").
26 | 	APIKeyLocation   APIKeyLocation // Where the API key should be placed (header, query, path, or cookie).
27 | 	APIKeyFromEnvVar string         // Environment variable name to read the API key from.
28 | 
29 | 	// Filtering (optional)
30 | 	IncludeTags       []string // Only include operations with these tags.
31 | 	ExcludeTags       []string // Exclude operations with these tags.
32 | 	IncludeOperations []string // Only include operations with these IDs.
33 | 	ExcludeOperations []string // Exclude operations with these IDs.
34 | 
35 | 	// Overrides (optional)
36 | 	ServerBaseURL   string // Manually override the base URL for API calls, ignoring the spec's servers field.
37 | 	DefaultToolName string // Name for the toolset if not specified in the spec's info section.
38 | 	DefaultToolDesc string // Description for the toolset if not specified in the spec's info section.
39 | 
40 | 	// Server-side request modification
41 | 	CustomHeaders string // Comma-separated list of headers (e.g., "Header1:Value1,Header2:Value2") to add to outgoing requests.
42 | }
43 | 
44 | // GetAPIKey resolves the API key value, prioritizing the environment variable over the direct flag.
45 | func (c *Config) GetAPIKey() string {
46 | 	log.Println("GetAPIKey: Attempting to resolve API key...")
47 | 
48 | 	// 1. Check environment variable specified by --api-key-env
49 | 	if c.APIKeyFromEnvVar != "" {
50 | 		log.Printf("GetAPIKey: Checking environment variable specified by --api-key-env: %s", c.APIKeyFromEnvVar)
51 | 		val := os.Getenv(c.APIKeyFromEnvVar)
52 | 		if val != "" {
53 | 			log.Printf("GetAPIKey: Found key in environment variable %s.", c.APIKeyFromEnvVar)
54 | 			return val
55 | 		}
56 | 		log.Printf("GetAPIKey: Environment variable %s not found or empty.", c.APIKeyFromEnvVar)
57 | 	} else {
58 | 		log.Println("GetAPIKey: No --api-key-env variable specified.")
59 | 	}
60 | 
61 | 	// 2. Check direct flag --api-key
62 | 	if c.APIKey != "" {
63 | 		log.Println("GetAPIKey: Found key provided directly via --api-key flag.")
64 | 		return c.APIKey
65 | 	}
66 | 
67 | 	// 3. No key found
68 | 	log.Println("GetAPIKey: No API key found from config (env var or direct flag).")
69 | 	return ""
70 | }
71 | 
```

--------------------------------------------------------------------------------
/pkg/config/config_test.go:
--------------------------------------------------------------------------------

```go
  1 | package config
  2 | 
  3 | import (
  4 | 	"os"
  5 | 	"testing"
  6 | )
  7 | 
  8 | func TestConfig_GetAPIKey(t *testing.T) {
  9 | 	tests := []struct {
 10 | 		name        string
 11 | 		config      Config
 12 | 		envKey      string // Environment variable name to set
 13 | 		envValue    string // Value to set for the env var
 14 | 		expectedKey string
 15 | 		cleanupEnv  bool // Flag to indicate if env var needs cleanup
 16 | 	}{
 17 | 		{
 18 | 			name:        "No key set",
 19 | 			config:      Config{}, // Empty config
 20 | 			expectedKey: "",
 21 | 		},
 22 | 		{
 23 | 			name: "Direct key set only",
 24 | 			config: Config{
 25 | 				APIKey: "direct-key-123",
 26 | 			},
 27 | 			expectedKey: "direct-key-123",
 28 | 		},
 29 | 		{
 30 | 			name: "Env var set only",
 31 | 			config: Config{
 32 | 				APIKeyFromEnvVar: "TEST_API_KEY_ENV_ONLY",
 33 | 			},
 34 | 			envKey:      "TEST_API_KEY_ENV_ONLY",
 35 | 			envValue:    "env-key-456",
 36 | 			expectedKey: "env-key-456",
 37 | 			cleanupEnv:  true,
 38 | 		},
 39 | 		{
 40 | 			name: "Both direct and env var set (env takes precedence)",
 41 | 			config: Config{
 42 | 				APIKey:           "direct-key-789",
 43 | 				APIKeyFromEnvVar: "TEST_API_KEY_BOTH",
 44 | 			},
 45 | 			envKey:      "TEST_API_KEY_BOTH",
 46 | 			envValue:    "env-key-abc",
 47 | 			expectedKey: "env-key-abc",
 48 | 			cleanupEnv:  true,
 49 | 		},
 50 | 		{
 51 | 			name: "Direct key set, env var specified but not set",
 52 | 			config: Config{
 53 | 				APIKey:           "direct-key-xyz",
 54 | 				APIKeyFromEnvVar: "TEST_API_KEY_UNSET",
 55 | 			},
 56 | 			envKey:      "TEST_API_KEY_UNSET", // Ensure this is not set
 57 | 			envValue:    "",
 58 | 			expectedKey: "direct-key-xyz", // Should fall back to direct key
 59 | 			cleanupEnv:  true,             // Cleanup in case it was set previously
 60 | 		},
 61 | 		{
 62 | 			name: "Env var specified but empty string value",
 63 | 			config: Config{
 64 | 				APIKeyFromEnvVar: "TEST_API_KEY_EMPTY",
 65 | 			},
 66 | 			envKey:      "TEST_API_KEY_EMPTY",
 67 | 			envValue:    "", // Explicitly set to empty string
 68 | 			expectedKey: "", // Empty env var should result in empty key
 69 | 			cleanupEnv:  true,
 70 | 		},
 71 | 	}
 72 | 
 73 | 	for _, tc := range tests {
 74 | 		t.Run(tc.name, func(t *testing.T) {
 75 | 			// Set environment variable if needed for this test case
 76 | 			if tc.envKey != "" {
 77 | 				originalValue, wasSet := os.LookupEnv(tc.envKey)
 78 | 				err := os.Setenv(tc.envKey, tc.envValue)
 79 | 				if err != nil {
 80 | 					t.Fatalf("Failed to set environment variable %s: %v", tc.envKey, err)
 81 | 				}
 82 | 				// Schedule cleanup
 83 | 				if tc.cleanupEnv {
 84 | 					t.Cleanup(func() {
 85 | 						if wasSet {
 86 | 							os.Setenv(tc.envKey, originalValue)
 87 | 						} else {
 88 | 							os.Unsetenv(tc.envKey)
 89 | 						}
 90 | 					})
 91 | 				}
 92 | 			} else {
 93 | 				// Ensure env var is unset if tc.envKey is empty (for tests like "Direct key set only")
 94 | 				// This prevents interference from previous tests if not cleaned up properly.
 95 | 				os.Unsetenv(tc.config.APIKeyFromEnvVar) // Unset based on config field if relevant
 96 | 			}
 97 | 
 98 | 			// Call the method under test
 99 | 			actualKey := tc.config.GetAPIKey()
100 | 
101 | 			// Assert the result
102 | 			if actualKey != tc.expectedKey {
103 | 				t.Errorf("Expected API key %q, but got %q", tc.expectedKey, actualKey)
104 | 			}
105 | 		})
106 | 	}
107 | }
108 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/types.go:
--------------------------------------------------------------------------------

```go
 1 | package mcp
 2 | 
 3 | // Based on the MCP specification: https://modelcontextprotocol.io/spec/
 4 | 
 5 | // ParameterDetail describes a single parameter for an operation.
 6 | type ParameterDetail struct {
 7 | 	Name string `json:"name"`
 8 | 	In   string `json:"in"` // Location (query, header, path, cookie)
 9 | 	// Add other details if needed, e.g., required, type
10 | }
11 | 
12 | // OperationDetail holds the necessary information to execute a specific API operation.
13 | type OperationDetail struct {
14 | 	Method     string            `json:"method"`
15 | 	Path       string            `json:"path"` // Path template (e.g., /users/{id})
16 | 	BaseURL    string            `json:"baseUrl"`
17 | 	Parameters []ParameterDetail `json:"parameters,omitempty"`
18 | 	// Add RequestBody schema if needed
19 | }
20 | 
21 | // ToolSet represents the collection of tools provided by an MCP server.
22 | type ToolSet struct {
23 | 	MCPVersion  string `json:"mcp_version"`
24 | 	Name        string `json:"name"`
25 | 	Description string `json:"description,omitempty"`
26 | 	// Auth        *AuthInfo `json:"auth,omitempty"` // Removed authentication info
27 | 	Tools []Tool `json:"tools"`
28 | 
29 | 	// Operations maps Tool.Name (operationId) to its execution details.
30 | 	// This is internal to the server and not part of the standard MCP JSON response.
31 | 	Operations map[string]OperationDetail `json:"-"` // Use json:"-" to exclude from JSON
32 | 
33 | 	// Internal fields for server-side auth handling (not exposed in JSON)
34 | 	apiKeyName string // e.g., "key", "X-API-Key"
35 | 	apiKeyIn   string // e.g., "query", "header"
36 | }
37 | 
38 | // SetAPIKeyDetails allows the parser to set internal API key info.
39 | func (ts *ToolSet) SetAPIKeyDetails(name, in string) {
40 | 	ts.apiKeyName = name
41 | 	ts.apiKeyIn = in
42 | }
43 | 
44 | // GetAPIKeyDetails allows the server to retrieve internal API key info.
45 | // We might need this later when making the request.
46 | func (ts *ToolSet) GetAPIKeyDetails() (name, in string) {
47 | 	return ts.apiKeyName, ts.apiKeyIn
48 | }
49 | 
50 | // Tool represents a single function or capability exposed via MCP.
51 | type Tool struct {
52 | 	Name        string `json:"name"` // Corresponds to OpenAPI operationId or generated name
53 | 	Description string `json:"description,omitempty"`
54 | 	InputSchema Schema `json:"inputSchema"` // Renamed from Parameters, consolidate parameters/body here
55 | 	// Entrypoint  string      `json:"entrypoint"`             // Removed for simplicity, schema should contain enough info?
56 | 	// RequestBody RequestBody `json:"request_body,omitempty"` // Removed, info should be part of InputSchema
57 | 	// HTTPMethod  string      `json:"http_method"`            // Removed for simplicity
58 | 	// TODO: Add Response handling if needed by spec/client
59 | }
60 | 
61 | // RequestBody describes the expected request body for a tool.
62 | // This might become redundant if all info is in InputSchema.
63 | // Keeping it for now as the parser might still use it internally.
64 | type RequestBody struct {
65 | 	Description string            `json:"description,omitempty"`
66 | 	Required    bool              `json:"required,omitempty"`
67 | 	Content     map[string]Schema `json:"content"` // Keyed by media type (e.g., "application/json")
68 | }
69 | 
70 | // Schema defines the structure and constraints of data (parameters or request/response bodies).
71 | // This mirrors a subset of JSON Schema properties.
72 | type Schema struct {
73 | 	Type        string            `json:"type,omitempty"` // e.g., "object", "string", "integer", "array"
74 | 	Description string            `json:"description,omitempty"`
75 | 	Properties  map[string]Schema `json:"properties,omitempty"` // For type "object"
76 | 	Required    []string          `json:"required,omitempty"`   // For type "object"
77 | 	Items       *Schema           `json:"items,omitempty"`      // For type "array"
78 | 	Format      string            `json:"format,omitempty"`     // e.g., "int32", "date-time"
79 | 	Enum        []interface{}     `json:"enum,omitempty"`
80 | 	// Add other relevant JSON Schema fields as needed (e.g., minimum, maximum, pattern)
81 | }
82 | 
```

--------------------------------------------------------------------------------
/cmd/openapi-mcp/main.go:
--------------------------------------------------------------------------------

```go
  1 | package main
  2 | 
  3 | import (
  4 | 	"flag"
  5 | 	"fmt"
  6 | 	"log"
  7 | 	"os"
  8 | 	"path/filepath"
  9 | 	"strings"
 10 | 
 11 | 	"github.com/ckanthony/openapi-mcp/pkg/config"
 12 | 	"github.com/ckanthony/openapi-mcp/pkg/parser"
 13 | 	"github.com/ckanthony/openapi-mcp/pkg/server"
 14 | 	"github.com/joho/godotenv"
 15 | )
 16 | 
 17 | // stringSliceFlag allows defining a flag that can be repeated to collect multiple string values.
 18 | type stringSliceFlag []string
 19 | 
 20 | func (i *stringSliceFlag) String() string {
 21 | 	return strings.Join(*i, ", ")
 22 | }
 23 | 
 24 | func (i *stringSliceFlag) Set(value string) error {
 25 | 	*i = append(*i, value)
 26 | 	return nil
 27 | }
 28 | 
 29 | func main() {
 30 | 	// --- Flag Definitions First ---
 31 | 	// Define specPath early so we can use it for .env loading
 32 | 	specPath := flag.String("spec", "", "Path or URL to the OpenAPI specification file (required)")
 33 | 	port := flag.Int("port", 8080, "Port to run the MCP server on")
 34 | 
 35 | 	apiKey := flag.String("api-key", "", "Direct API key value")
 36 | 	apiKeyEnv := flag.String("api-key-env", "", "Environment variable name containing the API key")
 37 | 	apiKeyName := flag.String("api-key-name", "", "Name of the API key header, query parameter, path parameter, or cookie (required if api-key or api-key-env is set)")
 38 | 	apiKeyLocStr := flag.String("api-key-loc", "", "Location of API key: 'header', 'query', 'path', or 'cookie' (required if api-key or api-key-env is set)")
 39 | 
 40 | 	var includeTags stringSliceFlag
 41 | 	flag.Var(&includeTags, "include-tag", "Tag to include (can be repeated)")
 42 | 	var excludeTags stringSliceFlag
 43 | 	flag.Var(&excludeTags, "exclude-tag", "Tag to exclude (can be repeated)")
 44 | 	var includeOps stringSliceFlag
 45 | 	flag.Var(&includeOps, "include-op", "Operation ID to include (can be repeated)")
 46 | 	var excludeOps stringSliceFlag
 47 | 	flag.Var(&excludeOps, "exclude-op", "Operation ID to exclude (can be repeated)")
 48 | 
 49 | 	serverBaseURL := flag.String("base-url", "", "Manually override the server base URL")
 50 | 	defaultToolName := flag.String("name", "OpenAPI-MCP Tools", "Default name for the toolset")
 51 | 	defaultToolDesc := flag.String("desc", "Tools generated from OpenAPI spec", "Default description for the toolset")
 52 | 
 53 | 	// Parse flags *after* defining them all
 54 | 	flag.Parse()
 55 | 
 56 | 	// --- Load .env after parsing flags ---
 57 | 	if *specPath != "" && !strings.HasPrefix(*specPath, "http://") && !strings.HasPrefix(*specPath, "https://") {
 58 | 		envPath := filepath.Join(filepath.Dir(*specPath), ".env")
 59 | 		log.Printf("Attempting to load .env file from spec directory: %s", envPath)
 60 | 		err := godotenv.Load(envPath)
 61 | 		if err != nil {
 62 | 			// It's okay if the file doesn't exist, log other errors.
 63 | 			if !os.IsNotExist(err) {
 64 | 				log.Printf("Warning: Error loading .env file from %s: %v", envPath, err)
 65 | 			} else {
 66 | 				log.Printf("Info: No .env file found at %s, proceeding without it.", envPath)
 67 | 			}
 68 | 		} else {
 69 | 			log.Printf("Successfully loaded .env file from %s", envPath)
 70 | 		}
 71 | 	} else if *specPath == "" {
 72 | 		log.Println("Skipping .env load because --spec is missing.")
 73 | 	} else {
 74 | 		log.Println("Skipping .env load because spec path appears to be a URL.")
 75 | 	}
 76 | 
 77 | 	// --- Read REQUEST_HEADERS env var ---
 78 | 	customHeadersEnv := os.Getenv("REQUEST_HEADERS")
 79 | 	if customHeadersEnv != "" {
 80 | 		log.Printf("Found REQUEST_HEADERS environment variable: %s", customHeadersEnv)
 81 | 	}
 82 | 
 83 | 	// --- Input Validation ---
 84 | 	if *specPath == "" {
 85 | 		log.Println("Error: --spec flag is required.")
 86 | 		flag.Usage()
 87 | 		os.Exit(1)
 88 | 	}
 89 | 
 90 | 	var apiKeyLocation config.APIKeyLocation
 91 | 	if *apiKeyLocStr != "" {
 92 | 		switch *apiKeyLocStr {
 93 | 		case string(config.APIKeyLocationHeader):
 94 | 			apiKeyLocation = config.APIKeyLocationHeader
 95 | 		case string(config.APIKeyLocationQuery):
 96 | 			apiKeyLocation = config.APIKeyLocationQuery
 97 | 		case string(config.APIKeyLocationPath):
 98 | 			apiKeyLocation = config.APIKeyLocationPath
 99 | 		case string(config.APIKeyLocationCookie):
100 | 			apiKeyLocation = config.APIKeyLocationCookie
101 | 		default:
102 | 			log.Fatalf("Error: invalid --api-key-loc value: %s. Must be 'header', 'query', 'path', or 'cookie'.", *apiKeyLocStr)
103 | 		}
104 | 	}
105 | 
106 | 	// --- Configuration Population ---
107 | 	cfg := &config.Config{
108 | 		SpecPath:          *specPath,
109 | 		APIKey:            *apiKey,
110 | 		APIKeyFromEnvVar:  *apiKeyEnv,
111 | 		APIKeyName:        *apiKeyName,
112 | 		APIKeyLocation:    apiKeyLocation,
113 | 		IncludeTags:       includeTags,
114 | 		ExcludeTags:       excludeTags,
115 | 		IncludeOperations: includeOps,
116 | 		ExcludeOperations: excludeOps,
117 | 		ServerBaseURL:     *serverBaseURL,
118 | 		DefaultToolName:   *defaultToolName,
119 | 		DefaultToolDesc:   *defaultToolDesc,
120 | 		CustomHeaders:     customHeadersEnv,
121 | 	}
122 | 
123 | 	log.Printf("Configuration loaded: %+v\n", cfg)
124 | 	log.Println("API Key (resolved):", cfg.GetAPIKey())
125 | 
126 | 	// --- Call Parser ---
127 | 	specDoc, version, err := parser.LoadSwagger(cfg.SpecPath)
128 | 	if err != nil {
129 | 		log.Fatalf("Failed to load OpenAPI/Swagger spec: %v", err)
130 | 	}
131 | 	log.Printf("Spec type %s loaded successfully from %s.\n", version, cfg.SpecPath)
132 | 
133 | 	toolSet, err := parser.GenerateToolSet(specDoc, version, cfg)
134 | 	if err != nil {
135 | 		log.Fatalf("Failed to generate MCP toolset: %v", err)
136 | 	}
137 | 	log.Printf("MCP toolset generated with %d tools.\n", len(toolSet.Tools))
138 | 
139 | 	// --- Start Server ---
140 | 	addr := fmt.Sprintf(":%d", *port)
141 | 	log.Printf("Starting MCP server on %s...", addr)
142 | 	err = server.ServeMCP(addr, toolSet, cfg) // Pass cfg to ServeMCP
143 | 	if err != nil {
144 | 		log.Fatalf("Failed to start server: %v", err)
145 | 	}
146 | }
147 | 
```

--------------------------------------------------------------------------------
/pkg/server/manager_test.go:
--------------------------------------------------------------------------------

```go
  1 | package server
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"net/http"
  6 | 	"net/http/httptest"
  7 | 	"strings"
  8 | 	"testing"
  9 | 	"time"
 10 | 
 11 | 	"github.com/stretchr/testify/assert"
 12 | )
 13 | 
 14 | // mockResponseWriter implements http.ResponseWriter and http.Flusher for testing SSE.
 15 | type mockResponseWriter struct {
 16 | 	*httptest.ResponseRecorder       // Embed to get ResponseWriter behavior
 17 | 	flushed                    bool  // Track if Flush was called
 18 | 	forceError                 error // Added for testing error handling
 19 | }
 20 | 
 21 | // NewMockResponseWriter creates a new mock response writer.
 22 | func NewMockResponseWriter() *mockResponseWriter {
 23 | 	return &mockResponseWriter{
 24 | 		ResponseRecorder: httptest.NewRecorder(),
 25 | 	}
 26 | }
 27 | 
 28 | // Write method for mockResponseWriter (ensure it handles forceError)
 29 | func (m *mockResponseWriter) Write(p []byte) (int, error) {
 30 | 	if m.forceError != nil {
 31 | 		return 0, m.forceError
 32 | 	}
 33 | 	return m.ResponseRecorder.Write(p) // Use embedded writer
 34 | }
 35 | 
 36 | // Flush method for mockResponseWriter
 37 | func (m *mockResponseWriter) Flush() {
 38 | 	if m.forceError != nil { // Don't flush if write failed
 39 | 		return
 40 | 	}
 41 | 	m.flushed = true
 42 | 	// We don't actually flush the embedded recorder in this mock
 43 | }
 44 | 
 45 | // --- Simple Mock Flusher ---
 46 | type mockFlusher struct {
 47 | 	flushed bool
 48 | }
 49 | 
 50 | func (f *mockFlusher) Flush() {
 51 | 	f.flushed = true
 52 | }
 53 | 
 54 | // --- End Mock Flusher ---
 55 | 
 56 | func TestManager_Run_Stop(t *testing.T) {
 57 | 	// Basic test to ensure the manager can start and stop.
 58 | 	// More comprehensive tests involving resource handling would be needed.
 59 | 
 60 | 	// Dummy tool set JSON for initialization
 61 | 	dummyToolSet := []byte(`{"tools": []}`)
 62 | 
 63 | 	m := newConnectionManager(dummyToolSet)
 64 | 
 65 | 	// Basic run/stop test - might need refinement depending on Run() implementation
 66 | 	// We need a way to observe if Run() is actually doing something or blocking.
 67 | 	// For now, just test start and stop signals.
 68 | 	stopChan := make(chan struct{})
 69 | 	go func() {
 70 | 		// Need to figure out what Run expects or does.
 71 | 		// If Run is intended to block, this test structure needs adjustment.
 72 | 		// For now, assume Run might just start background tasks and doesn't block indefinitely.
 73 | 		// If it expects specific input or state, that needs mocking.
 74 | 		// Placeholder: Simulate Run behavior relevant to Stop.
 75 | 		// If Run blocks, this goroutine might hang.
 76 | 		<-stopChan // Simulate Run blocking until Stop is called
 77 | 	}()
 78 | 
 79 | 	// Simulate adding a client to test remove logic
 80 | 	req := httptest.NewRequest(http.MethodGet, "/events", nil)
 81 | 	mrr := NewMockResponseWriter() // Use the mock
 82 | 	m.addClient(req, mrr, mrr)     // Pass the mock which implements both interfaces
 83 | 	if m.getClientCount() != 1 {
 84 | 		t.Errorf("Expected 1 client after add, got %d", m.getClientCount())
 85 | 	}
 86 | 
 87 | 	time.Sleep(100 * time.Millisecond) // Give time for potential background tasks
 88 | 
 89 | 	// Test removing the client
 90 | 	m.removeClient(req)
 91 | 	if m.getClientCount() != 0 {
 92 | 		t.Errorf("Expected 0 clients after remove, got %d", m.getClientCount())
 93 | 	}
 94 | 
 95 | 	// Simulate stopping the manager
 96 | 	close(stopChan) // Signal the placeholder Run goroutine to exit
 97 | 
 98 | 	// Need a way to verify Stop() worked. If it closes internal channels,
 99 | 	// we could potentially check that. Without knowing Stop's implementation,
100 | 	// this is a basic check.
101 | 	// Maybe add a dedicated Stop() method to connectionManager if Run blocks?
102 | 	// Or check internal state if possible.
103 | 
104 | 	// Example: If Stop closes a known channel:
105 | 	// select {
106 | 	// case <-m.internalStopChan: // Assuming internalStopChan exists and is closed by Stop()
107 | 	//	// Expected behavior
108 | 	// case <-time.After(1 * time.Second):
109 | 	//	t.Fatal("Manager did not signal stop within the expected time")
110 | 	// }
111 | }
112 | 
113 | // Define a dummy non-flusher if needed
114 | type nonFlusher struct {
115 | 	http.ResponseWriter
116 | }
117 | 
118 | func (nf *nonFlusher) Flush() { /* Do nothing */ }
119 | 
120 | func TestManager_AddRemoveClient(t *testing.T) {
121 | 	dummyToolSet := []byte(`{"tools": []}`)
122 | 	m := newConnectionManager(dummyToolSet)
123 | 
124 | 	req1 := httptest.NewRequest(http.MethodGet, "/events?id=1", nil)
125 | 	mrr1 := NewMockResponseWriter() // Use mock
126 | 
127 | 	req2 := httptest.NewRequest(http.MethodGet, "/events?id=2", nil)
128 | 	mrr2 := NewMockResponseWriter() // Use mock
129 | 
130 | 	m.addClient(req1, mrr1, mrr1) // Pass mock
131 | 	if count := m.getClientCount(); count != 1 {
132 | 		t.Errorf("Expected 1 client, got %d", count)
133 | 	}
134 | 
135 | 	m.addClient(req2, mrr2, mrr2) // Pass mock
136 | 	if count := m.getClientCount(); count != 2 {
137 | 		t.Errorf("Expected 2 clients, got %d", count)
138 | 	}
139 | 
140 | 	m.removeClient(req1)
141 | 	if count := m.getClientCount(); count != 1 {
142 | 		t.Errorf("Expected 1 client after removing req1, got %d", count)
143 | 	}
144 | 	// Ensure the correct client was removed
145 | 	m.mu.RLock()
146 | 	_, exists := m.clients[req1]
147 | 	m.mu.RUnlock()
148 | 	if exists {
149 | 		t.Error("req1 should have been removed but still exists in map")
150 | 	}
151 | 
152 | 	m.removeClient(req2)
153 | 	if count := m.getClientCount(); count != 0 {
154 | 		t.Errorf("Expected 0 clients after removing req2, got %d", count)
155 | 	}
156 | 
157 | 	// Test removing non-existent client
158 | 	m.removeClient(req1) // Remove again
159 | 	if count := m.getClientCount(); count != 0 {
160 | 		t.Errorf("Expected 0 clients after removing non-existent, got %d", count)
161 | 	}
162 | }
163 | 
164 | // Test for sendToolset needs a way to capture output sent to the client.
165 | // httptest.ResponseRecorder can capture the body.
166 | func TestManager_SendToolset(t *testing.T) {
167 | 	toolSetData := `{"tools": ["tool1", "tool2"]}`
168 | 	m := newConnectionManager([]byte(toolSetData))
169 | 
170 | 	mrr := NewMockResponseWriter() // Use mock
171 | 
172 | 	// Directly create a client struct instance for testing sendToolset specifically
173 | 	// Note: This bypasses addClient logic for focused testing of sendToolset.
174 | 	testClient := &client{writer: mrr, flusher: mrr} // Use mock for both
175 | 
176 | 	m.sendToolset(testClient)
177 | 
178 | 	// Use strings.TrimSpace for comparison to avoid issues with subtle whitespace differences
179 | 	// Escape inner quotes
180 | 	expectedOutputPattern := "event: tool_set\ndata: {\"tools\": [\"tool1\", \"tool2\"]}\n\n"
181 | 	actualOutput := mrr.Body.String()
182 | 
183 | 	if strings.TrimSpace(actualOutput) != strings.TrimSpace(expectedOutputPattern) {
184 | 		// Use %q to quote strings, making whitespace visible
185 | 		t.Errorf("Expected toolset output matching pattern %q, got %q", expectedOutputPattern, actualOutput)
186 | 	}
187 | 	if !mrr.flushed { // Check if flush was called
188 | 		t.Error("Expected Flush() to be called on the writer, but it wasn't")
189 | 	}
190 | 
191 | 	// Test sending to nil client
192 | 	m.sendToolset(nil) // Should not panic
193 | }
194 | 
195 | // Test case for when writing the toolset fails (e.g., client disconnected)
196 | func TestConnectionManager_SendToolset_WriteError(t *testing.T) {
197 | 	mgr := newConnectionManager([]byte(`{"tool":"set"}`))
198 | 
199 | 	// Create a mock writer that always returns an error
200 | 	mockWriter := &mockResponseWriter{
201 | 		ResponseRecorder: httptest.NewRecorder(), // Initialize embedded recorder
202 | 		forceError:       fmt.Errorf("simulated write error"),
203 | 	}
204 | 	mockFlusher := &mockFlusher{}
205 | 
206 | 	// Create a client with the erroring writer
207 | 	mockClient := &client{
208 | 		writer:  mockWriter,
209 | 		flusher: mockFlusher,
210 | 	}
211 | 
212 | 	// Call sendToolset - we expect it to log the error and return early
213 | 	// We don't easily assert the log, but we run it for coverage.
214 | 	mgr.sendToolset(mockClient)
215 | 
216 | 	// Assert that Flush was NOT called because the function should have returned early
217 | 	assert.False(t, mockFlusher.flushed, "Flush should not be called when Write fails")
218 | 	// Assert that Write was attempted (optional, depends on mock capabilities)
219 | 	// If mockResponseWriter tracks calls, assert Write was called once.
220 | }
221 | 
```

--------------------------------------------------------------------------------
/pkg/parser/parser_test.go:
--------------------------------------------------------------------------------

```go
   1 | package parser
   2 | 
   3 | import (
   4 | 	"net/http"
   5 | 	"net/http/httptest"
   6 | 	"os"
   7 | 	"path/filepath"
   8 | 	"sort"
   9 | 	"strings"
  10 | 	"testing"
  11 | 
  12 | 	"github.com/getkin/kin-openapi/openapi3"
  13 | 	"github.com/go-openapi/spec"
  14 | 	"github.com/stretchr/testify/assert"
  15 | 	"github.com/stretchr/testify/require"
  16 | 
  17 | 	"github.com/ckanthony/openapi-mcp/pkg/config"
  18 | 	"github.com/ckanthony/openapi-mcp/pkg/mcp"
  19 | )
  20 | 
  21 | // Minimal valid OpenAPI V3 spec (JSON string)
  22 | const minimalV3SpecJSON = `{
  23 |   "openapi": "3.0.0",
  24 |   "info": {
  25 |     "title": "Minimal V3 API",
  26 |     "version": "1.0.0"
  27 |   },
  28 |   "paths": {
  29 |     "/ping": {
  30 |       "get": {
  31 |         "summary": "Simple ping endpoint",
  32 |         "operationId": "getPing",
  33 |         "responses": {
  34 |           "200": {
  35 |             "description": "OK"
  36 |           }
  37 |         }
  38 |       }
  39 |     }
  40 |   }
  41 | }`
  42 | 
  43 | // Minimal valid Swagger V2 spec (JSON string)
  44 | const minimalV2SpecJSON = `{
  45 |   "swagger": "2.0",
  46 |   "info": {
  47 |     "title": "Minimal V2 API",
  48 |     "version": "1.0.0"
  49 |   },
  50 |   "paths": {
  51 |     "/health": {
  52 |       "get": {
  53 |         "summary": "Simple health check",
  54 |         "operationId": "getHealth",
  55 |         "produces": ["application/json"],
  56 |         "responses": {
  57 |           "200": {
  58 |             "description": "OK"
  59 |           }
  60 |         }
  61 |       }
  62 |     }
  63 |   }
  64 | }`
  65 | 
  66 | // Malformed JSON
  67 | const malformedJSON = `{
  68 |   "openapi": "3.0.0",
  69 |   "info": {
  70 |     "title": "Missing Version",
  71 |   }
  72 | }`
  73 | 
  74 | // JSON without version key
  75 | const noVersionKeyJSON = `{
  76 |   "info": {
  77 |     "title": "No Version Key",
  78 |     "version": "1.0"
  79 |   },
  80 |   "paths": {}
  81 | }`
  82 | 
  83 | // V3 Spec with tags and multiple operations
  84 | const complexV3SpecJSON = `{
  85 |   "openapi": "3.0.0",
  86 |   "info": {
  87 |     "title": "Complex V3 API",
  88 |     "version": "1.1.0"
  89 |   },
  90 |   "tags": [
  91 |     {"name": "tag1", "description": "First Tag"},
  92 |     {"name": "tag2", "description": "Second Tag"}
  93 |   ],
  94 |   "paths": {
  95 |     "/items": {
  96 |       "get": {
  97 |         "summary": "List Items",
  98 |         "operationId": "listItems",
  99 |         "tags": ["tag1"],
 100 |         "responses": {"200": {"description": "OK"}}
 101 |       },
 102 |       "post": {
 103 |         "summary": "Create Item",
 104 |         "operationId": "createItem",
 105 |         "tags": ["tag1", "tag2"],
 106 |         "responses": {"201": {"description": "Created"}}
 107 |       }
 108 |     },
 109 |     "/users": {
 110 |       "get": {
 111 |         "summary": "List Users",
 112 |         "operationId": "listUsers",
 113 |         "tags": ["tag2"],
 114 |         "responses": {"200": {"description": "OK"}}
 115 |       }
 116 |     },
 117 |     "/ping": {
 118 |       "get": {
 119 |         "summary": "Simple ping",
 120 |         "operationId": "getPing",
 121 |         "responses": {"200": {"description": "OK"}}
 122 |       }
 123 |     }
 124 |   }
 125 | }`
 126 | 
 127 | // V2 Spec with tags and multiple operations
 128 | const complexV2SpecJSON = `{
 129 |   "swagger": "2.0",
 130 |   "info": {
 131 |     "title": "Complex V2 API",
 132 |     "version": "1.1.0"
 133 |   },
 134 |   "tags": [
 135 |     {"name": "tag1", "description": "First Tag"},
 136 |     {"name": "tag2", "description": "Second Tag"}
 137 |   ],
 138 |   "paths": {
 139 |     "/items": {
 140 |       "get": {
 141 |         "summary": "List Items",
 142 |         "operationId": "listItems",
 143 |         "tags": ["tag1"],
 144 |         "produces": ["application/json"],
 145 |         "responses": {"200": {"description": "OK"}}
 146 |       },
 147 |       "post": {
 148 |         "summary": "Create Item",
 149 |         "operationId": "createItem",
 150 |         "tags": ["tag1", "tag2"],
 151 |         "produces": ["application/json"],
 152 |         "responses": {"201": {"description": "Created"}}
 153 |       }
 154 |     },
 155 |     "/users": {
 156 |       "get": {
 157 |         "summary": "List Users",
 158 |         "operationId": "listUsers",
 159 |         "tags": ["tag2"],
 160 |         "produces": ["application/json"],
 161 |         "responses": {"200": {"description": "OK"}}
 162 |       }
 163 |     },
 164 |     "/ping": {
 165 |       "get": {
 166 |         "summary": "Simple ping",
 167 |         "operationId": "getPing",
 168 |         "produces": ["application/json"],
 169 |         "responses": {"200": {"description": "OK"}}
 170 |       }
 171 |     }
 172 |   }
 173 | }`
 174 | 
 175 | // V3 Spec with various parameter types and request body
 176 | const paramsV3SpecJSON = `{
 177 |   "openapi": "3.0.0",
 178 |   "info": {
 179 |     "title": "Params V3 API",
 180 |     "version": "1.0.0"
 181 |   },
 182 |   "paths": {
 183 |     "/test/{path_param}": {
 184 |       "post": {
 185 |         "summary": "Test various params",
 186 |         "operationId": "testParams",
 187 |         "parameters": [
 188 |           {
 189 |             "name": "path_param",
 190 |             "in": "path",
 191 |             "required": true,
 192 |             "schema": {"type": "integer", "format": "int32"}
 193 |           },
 194 |           {
 195 |             "name": "query_param",
 196 |             "in": "query",
 197 |             "required": true,
 198 |             "schema": {"type": "string", "enum": ["A", "B"]}
 199 |           },
 200 |           {
 201 |             "name": "optional_query",
 202 |             "in": "query",
 203 |             "schema": {"type": "boolean"}
 204 |           },
 205 |           {
 206 |             "name": "X-Header-Param",
 207 |             "in": "header",
 208 |             "required": true,
 209 |             "schema": {"type": "string"}
 210 |           },
 211 |           {
 212 |             "name": "CookieParam",
 213 |             "in": "cookie",
 214 |             "schema": {"type": "number"}
 215 |           }
 216 |         ],
 217 |         "requestBody": {
 218 |           "required": true,
 219 |           "content": {
 220 |             "application/json": {
 221 |               "schema": {
 222 |                 "type": "object",
 223 |                 "properties": {
 224 |                   "id": {"type": "string"},
 225 |                   "value": {"type": "number"}
 226 |                 },
 227 |                 "required": ["id"]
 228 |               }
 229 |             }
 230 |           }
 231 |         },
 232 |         "responses": {
 233 |           "200": {"description": "OK"}
 234 |         }
 235 |       }
 236 |     }
 237 |   }
 238 | }`
 239 | 
 240 | // V2 Spec with various parameter types and $ref
 241 | const paramsV2SpecJSON = `{
 242 |   "swagger": "2.0",
 243 |   "info": {
 244 |     "title": "Params V2 API",
 245 |     "version": "1.0.0"
 246 |   },
 247 |   "definitions": {
 248 |     "Item": {
 249 |       "type": "object",
 250 |       "properties": {
 251 |         "id": {"type": "string", "format": "uuid"},
 252 |         "name": {"type": "string"}
 253 |       },
 254 |       "required": ["id"]
 255 |     }
 256 |   },
 257 |   "paths": {
 258 |     "/test/{path_id}": {
 259 |       "put": {
 260 |         "summary": "Test V2 params and ref",
 261 |         "operationId": "testV2Params",
 262 |         "consumes": ["application/json"],
 263 |         "produces": ["application/json"],
 264 |         "parameters": [
 265 |           {
 266 |             "name": "path_id",
 267 |             "in": "path",
 268 |             "required": true,
 269 |             "type": "string"
 270 |           },
 271 |           {
 272 |             "name": "query_flag",
 273 |             "in": "query",
 274 |             "type": "boolean",
 275 |             "required": true
 276 |           },
 277 |           {
 278 |             "name": "X-Request-ID",
 279 |             "in": "header",
 280 |             "type": "string",
 281 |             "required": false
 282 |           },
 283 |           {
 284 |             "name": "body_param",
 285 |             "in": "body",
 286 |             "required": true,
 287 |             "schema": {
 288 |               "$ref": "#/definitions/Item"
 289 |             }
 290 |           }
 291 |         ],
 292 |         "responses": {
 293 |           "200": {"description": "OK"}
 294 |         }
 295 |       }
 296 |     }
 297 |   }
 298 | }`
 299 | 
 300 | // V3 Spec with array types
 301 | const arraysV3SpecJSON = `{
 302 |   "openapi": "3.0.0",
 303 |   "info": {"title": "Arrays V3 API", "version": "1.0.0"},
 304 |   "paths": {
 305 |     "/process": {
 306 |       "post": {
 307 |         "summary": "Process arrays",
 308 |         "operationId": "processArrays",
 309 |         "parameters": [
 310 |           {
 311 |             "name": "string_array_query",
 312 |             "in": "query",
 313 |             "schema": {
 314 |               "type": "array",
 315 |               "items": {"type": "string"}
 316 |             }
 317 |           }
 318 |         ],
 319 |         "requestBody": {
 320 |           "content": {
 321 |             "application/json": {
 322 |               "schema": {
 323 |                 "type": "object",
 324 |                 "properties": {
 325 |                   "int_array_body": {
 326 |                     "type": "array",
 327 |                     "items": {"type": "integer", "format": "int64"}
 328 |                   }
 329 |                 }
 330 |               }
 331 |             }
 332 |           }
 333 |         },
 334 |         "responses": {"200": {"description": "OK"}}
 335 |       }
 336 |     }
 337 |   }
 338 | }`
 339 | 
 340 | // V2 Spec with array types
 341 | const arraysV2SpecJSON = `{
 342 |   "swagger": "2.0",
 343 |   "info": {"title": "Arrays V2 API", "version": "1.0.0"},
 344 |   "paths": {
 345 |     "/process": {
 346 |       "get": {
 347 |         "summary": "Get arrays",
 348 |         "operationId": "getArrays",
 349 |         "parameters": [
 350 |           {
 351 |             "name": "string_array_query",
 352 |             "in": "query",
 353 |             "type": "array",
 354 |             "items": {"type": "string"},
 355 |             "collectionFormat": "csv"
 356 |           },
 357 |           {
 358 |              "name": "int_array_form",
 359 |              "in": "formData",
 360 |              "type": "array",
 361 |              "items": {"type": "integer", "format": "int32"}
 362 |           }
 363 |         ],
 364 |         "responses": {"200": {"description": "OK"}}
 365 |       }
 366 |     }
 367 |   }
 368 | }`
 369 | 
 370 | // V2 Spec with file parameter
 371 | const fileV2SpecJSON = `{
 372 |   "swagger": "2.0",
 373 |   "info": {"title": "File V2 API", "version": "1.0.0"},
 374 |   "paths": {
 375 |     "/upload": {
 376 |       "post": {
 377 |         "summary": "Upload file",
 378 |         "operationId": "uploadFile",
 379 |         "consumes": ["multipart/form-data"],
 380 |         "parameters": [
 381 |           {
 382 |             "name": "description",
 383 |             "in": "formData",
 384 |             "type": "string"
 385 |           },
 386 |           {
 387 |             "name": "file_upload",
 388 |             "in": "formData",
 389 |             "required": true,
 390 |             "type": "file"
 391 |           }
 392 |         ],
 393 |         "responses": {"200": {"description": "OK"}}
 394 |       }
 395 |     }
 396 |   }
 397 | }`
 398 | 
 399 | func TestLoadSwagger(t *testing.T) {
 400 | 	tests := []struct {
 401 | 		name          string
 402 | 		content       string
 403 | 		fileName      string
 404 | 		expectError   bool
 405 | 		expectVersion string
 406 | 		containsError string           // Substring to check in error message
 407 | 		isURLTest     bool             // Flag to indicate if the test uses a URL
 408 | 		handler       http.HandlerFunc // Handler for mock HTTP server
 409 | 	}{
 410 | 		{
 411 | 			name:          "Valid V3 JSON file",
 412 | 			content:       minimalV3SpecJSON,
 413 | 			fileName:      "valid_v3.json",
 414 | 			expectError:   false,
 415 | 			expectVersion: VersionV3,
 416 | 		},
 417 | 		{
 418 | 			name:          "Valid V2 JSON file",
 419 | 			content:       minimalV2SpecJSON,
 420 | 			fileName:      "valid_v2.json",
 421 | 			expectError:   false,
 422 | 			expectVersion: VersionV2,
 423 | 		},
 424 | 		{
 425 | 			name:          "Malformed JSON file",
 426 | 			content:       malformedJSON,
 427 | 			fileName:      "malformed.json",
 428 | 			expectError:   true,
 429 | 			containsError: "failed to parse JSON",
 430 | 		},
 431 | 		{
 432 | 			name:          "No version key JSON file",
 433 | 			content:       noVersionKeyJSON,
 434 | 			fileName:      "no_version.json",
 435 | 			expectError:   true,
 436 | 			containsError: "missing 'openapi' or 'swagger' key",
 437 | 		},
 438 | 		{
 439 | 			name:          "Non-existent file",
 440 | 			content:       "", // No content needed
 441 | 			fileName:      "non_existent.json",
 442 | 			expectError:   true,
 443 | 			containsError: "failed reading file path",
 444 | 		},
 445 | 		// --- URL Tests ---
 446 | 		{
 447 | 			name:          "Valid V3 JSON URL",
 448 | 			content:       minimalV3SpecJSON,
 449 | 			expectError:   false,
 450 | 			expectVersion: VersionV3,
 451 | 			isURLTest:     true,
 452 | 			handler: func(w http.ResponseWriter, r *http.Request) {
 453 | 				w.WriteHeader(http.StatusOK)
 454 | 				w.Write([]byte(minimalV3SpecJSON))
 455 | 			},
 456 | 		},
 457 | 		{
 458 | 			name:          "Valid V2 JSON URL",
 459 | 			content:       minimalV2SpecJSON, // Content used by handler
 460 | 			expectError:   false,
 461 | 			expectVersion: VersionV2,
 462 | 			isURLTest:     true,
 463 | 			handler: func(w http.ResponseWriter, r *http.Request) {
 464 | 				w.WriteHeader(http.StatusOK)
 465 | 				w.Write([]byte(minimalV2SpecJSON))
 466 | 			},
 467 | 		},
 468 | 		{
 469 | 			name:          "Malformed JSON URL",
 470 | 			content:       malformedJSON,
 471 | 			expectError:   true,
 472 | 			containsError: "failed to parse JSON",
 473 | 			isURLTest:     true,
 474 | 			handler: func(w http.ResponseWriter, r *http.Request) {
 475 | 				w.WriteHeader(http.StatusOK)
 476 | 				w.Write([]byte(malformedJSON))
 477 | 			},
 478 | 		},
 479 | 		{
 480 | 			name:          "No version key JSON URL",
 481 | 			content:       noVersionKeyJSON,
 482 | 			expectError:   true,
 483 | 			containsError: "missing 'openapi' or 'swagger' key",
 484 | 			isURLTest:     true,
 485 | 			handler: func(w http.ResponseWriter, r *http.Request) {
 486 | 				w.WriteHeader(http.StatusOK)
 487 | 				w.Write([]byte(noVersionKeyJSON))
 488 | 			},
 489 | 		},
 490 | 		{
 491 | 			name:          "URL Not Found (404)",
 492 | 			expectError:   true,
 493 | 			containsError: "failed to fetch URL", // Check for fetch error
 494 | 			isURLTest:     true,
 495 | 			handler: func(w http.ResponseWriter, r *http.Request) {
 496 | 				http.NotFound(w, r) // Use standard http.NotFound
 497 | 			},
 498 | 		},
 499 | 		{
 500 | 			name:          "URL Internal Server Error (500)",
 501 | 			expectError:   true,
 502 | 			containsError: "failed to fetch URL", // Check for fetch error
 503 | 			isURLTest:     true,
 504 | 			handler: func(w http.ResponseWriter, r *http.Request) {
 505 | 				http.Error(w, "Internal Server Error", http.StatusInternalServerError) // Use standard http.Error
 506 | 			},
 507 | 		},
 508 | 	}
 509 | 
 510 | 	for _, tc := range tests {
 511 | 		t.Run(tc.name, func(t *testing.T) {
 512 | 			var location string
 513 | 			var server *httptest.Server // Declare server variable
 514 | 
 515 | 			if tc.isURLTest {
 516 | 				// Set up mock HTTP server
 517 | 				require.NotNil(t, tc.handler, "URL test case must provide a handler")
 518 | 				server = httptest.NewServer(tc.handler)
 519 | 				defer server.Close()
 520 | 				location = server.URL // Use the mock server's URL
 521 | 			} else {
 522 | 				// Existing file path logic
 523 | 				tempDir := t.TempDir()
 524 | 				filePath := filepath.Join(tempDir, tc.fileName)
 525 | 
 526 | 				// Create the file only if content is provided
 527 | 				if tc.content != "" {
 528 | 					err := os.WriteFile(filePath, []byte(tc.content), 0644)
 529 | 					require.NoError(t, err, "Failed to write temp spec file")
 530 | 				}
 531 | 
 532 | 				// For the non-existent file case, ensure it really doesn't exist
 533 | 				if tc.name == "Non-existent file" {
 534 | 					filePath = filepath.Join(tempDir, "definitely_not_here.json")
 535 | 				}
 536 | 				location = filePath
 537 | 			}
 538 | 
 539 | 			specDoc, version, err := LoadSwagger(location)
 540 | 
 541 | 			if tc.expectError {
 542 | 				assert.Error(t, err)
 543 | 				if tc.containsError != "" {
 544 | 					assert.True(t, strings.Contains(err.Error(), tc.containsError),
 545 | 						"Error message %q does not contain expected substring %q", err.Error(), tc.containsError)
 546 | 				}
 547 | 				assert.Nil(t, specDoc)
 548 | 				assert.Empty(t, version)
 549 | 			} else {
 550 | 				assert.NoError(t, err)
 551 | 				assert.NotNil(t, specDoc)
 552 | 				assert.Equal(t, tc.expectVersion, version)
 553 | 				// Basic type assertion based on expected version
 554 | 				if version == VersionV3 {
 555 | 					assert.IsType(t, &openapi3.T{}, specDoc) // Expecting a pointer
 556 | 				} else if version == VersionV2 {
 557 | 					assert.IsType(t, &spec.Swagger{}, specDoc) // Expecting a pointer
 558 | 				}
 559 | 			}
 560 | 		})
 561 | 	}
 562 | }
 563 | 
 564 | // TODO: Add tests for GenerateToolSet
 565 | func TestGenerateToolSet(t *testing.T) {
 566 | 	// --- Load Specs Once ---
 567 | 	// Load V3 spec (error checked in TestLoadSwagger)
 568 | 	tempDirV3 := t.TempDir()
 569 | 	filePathV3 := filepath.Join(tempDirV3, "minimal_v3.json")
 570 | 	err := os.WriteFile(filePathV3, []byte(minimalV3SpecJSON), 0644)
 571 | 	require.NoError(t, err)
 572 | 	docV3, versionV3, err := LoadSwagger(filePathV3)
 573 | 	require.NoError(t, err)
 574 | 	require.Equal(t, VersionV3, versionV3)
 575 | 	specV3 := docV3.(*openapi3.T)
 576 | 
 577 | 	// Load V2 spec (error checked in TestLoadSwagger)
 578 | 	tempDirV2 := t.TempDir()
 579 | 	filePathV2 := filepath.Join(tempDirV2, "minimal_v2.json")
 580 | 	err = os.WriteFile(filePathV2, []byte(minimalV2SpecJSON), 0644)
 581 | 	require.NoError(t, err)
 582 | 	docV2, versionV2, err := LoadSwagger(filePathV2)
 583 | 	require.NoError(t, err)
 584 | 	require.Equal(t, VersionV2, versionV2)
 585 | 	specV2 := docV2.(*spec.Swagger)
 586 | 
 587 | 	// Load Complex V3 spec
 588 | 	tempDirComplexV3 := t.TempDir()
 589 | 	filePathComplexV3 := filepath.Join(tempDirComplexV3, "complex_v3.json")
 590 | 	err = os.WriteFile(filePathComplexV3, []byte(complexV3SpecJSON), 0644)
 591 | 	require.NoError(t, err)
 592 | 	docComplexV3, versionComplexV3, err := LoadSwagger(filePathComplexV3)
 593 | 	require.NoError(t, err)
 594 | 	require.Equal(t, VersionV3, versionComplexV3)
 595 | 	specComplexV3 := docComplexV3.(*openapi3.T)
 596 | 
 597 | 	// Load Complex V2 spec
 598 | 	tempDirComplexV2 := t.TempDir()
 599 | 	filePathComplexV2 := filepath.Join(tempDirComplexV2, "complex_v2.json")
 600 | 	err = os.WriteFile(filePathComplexV2, []byte(complexV2SpecJSON), 0644)
 601 | 	require.NoError(t, err)
 602 | 	docComplexV2, versionComplexV2, err := LoadSwagger(filePathComplexV2)
 603 | 	require.NoError(t, err)
 604 | 	require.Equal(t, VersionV2, versionComplexV2)
 605 | 	specComplexV2 := docComplexV2.(*spec.Swagger)
 606 | 
 607 | 	// Load Params V3 spec
 608 | 	tempDirParamsV3 := t.TempDir()
 609 | 	filePathParamsV3 := filepath.Join(tempDirParamsV3, "params_v3.json")
 610 | 	err = os.WriteFile(filePathParamsV3, []byte(paramsV3SpecJSON), 0644)
 611 | 	require.NoError(t, err)
 612 | 	docParamsV3, versionParamsV3, err := LoadSwagger(filePathParamsV3)
 613 | 	require.NoError(t, err)
 614 | 	require.Equal(t, VersionV3, versionParamsV3)
 615 | 	specParamsV3 := docParamsV3.(*openapi3.T)
 616 | 
 617 | 	// Load Params V2 spec
 618 | 	tempDirParamsV2 := t.TempDir()
 619 | 	filePathParamsV2 := filepath.Join(tempDirParamsV2, "params_v2.json")
 620 | 	err = os.WriteFile(filePathParamsV2, []byte(paramsV2SpecJSON), 0644)
 621 | 	require.NoError(t, err)
 622 | 	docParamsV2, versionParamsV2, err := LoadSwagger(filePathParamsV2)
 623 | 	require.NoError(t, err)
 624 | 	require.Equal(t, VersionV2, versionParamsV2)
 625 | 	specParamsV2 := docParamsV2.(*spec.Swagger)
 626 | 
 627 | 	// Load Arrays V3 spec
 628 | 	tempDirArraysV3 := t.TempDir()
 629 | 	filePathArraysV3 := filepath.Join(tempDirArraysV3, "arrays_v3.json")
 630 | 	err = os.WriteFile(filePathArraysV3, []byte(arraysV3SpecJSON), 0644)
 631 | 	require.NoError(t, err)
 632 | 	docArraysV3, versionArraysV3, err := LoadSwagger(filePathArraysV3)
 633 | 	require.NoError(t, err)
 634 | 	require.Equal(t, VersionV3, versionArraysV3)
 635 | 	specArraysV3 := docArraysV3.(*openapi3.T)
 636 | 
 637 | 	// Load Arrays V2 spec
 638 | 	tempDirArraysV2 := t.TempDir()
 639 | 	filePathArraysV2 := filepath.Join(tempDirArraysV2, "arrays_v2.json")
 640 | 	err = os.WriteFile(filePathArraysV2, []byte(arraysV2SpecJSON), 0644)
 641 | 	require.NoError(t, err)
 642 | 	docArraysV2, versionArraysV2, err := LoadSwagger(filePathArraysV2)
 643 | 	require.NoError(t, err)
 644 | 	require.Equal(t, VersionV2, versionArraysV2)
 645 | 	specArraysV2 := docArraysV2.(*spec.Swagger)
 646 | 
 647 | 	// Load File V2 spec
 648 | 	tempDirFileV2 := t.TempDir()
 649 | 	filePathFileV2 := filepath.Join(tempDirFileV2, "file_v2.json")
 650 | 	err = os.WriteFile(filePathFileV2, []byte(fileV2SpecJSON), 0644)
 651 | 	require.NoError(t, err)
 652 | 	docFileV2, versionFileV2, err := LoadSwagger(filePathFileV2)
 653 | 	require.NoError(t, err)
 654 | 	require.Equal(t, VersionV2, versionFileV2)
 655 | 	specFileV2 := docFileV2.(*spec.Swagger)
 656 | 
 657 | 	// --- Test Cases ---
 658 | 	tests := []struct {
 659 | 		name            string
 660 | 		spec            interface{}
 661 | 		version         string
 662 | 		cfg             *config.Config
 663 | 		expectError     bool
 664 | 		expectedToolSet *mcp.ToolSet // Define expected basic structure
 665 | 	}{
 666 | 		{
 667 | 			name:        "V3 Minimal Spec - Default Config",
 668 | 			spec:        specV3,
 669 | 			version:     VersionV3,
 670 | 			cfg:         &config.Config{}, // Default empty config
 671 | 			expectError: false,
 672 | 			expectedToolSet: &mcp.ToolSet{
 673 | 				Name:        "Minimal V3 API",
 674 | 				Description: "",
 675 | 				Tools: []mcp.Tool{
 676 | 					{
 677 | 						Name:        "getPing",
 678 | 						Description: "Note: The API key is handled by the server, no need to provide it. Simple ping endpoint",
 679 | 						InputSchema: mcp.Schema{Type: "object", Properties: map[string]mcp.Schema{}, Required: []string{}},
 680 | 					},
 681 | 				},
 682 | 				Operations: map[string]mcp.OperationDetail{
 683 | 					"getPing": {
 684 | 						Method:     "GET",
 685 | 						Path:       "/ping",
 686 | 						BaseURL:    "",                      // No server defined
 687 | 						Parameters: []mcp.ParameterDetail{}, // Expect empty slice
 688 | 					},
 689 | 				},
 690 | 			},
 691 | 		},
 692 | 		{
 693 | 			name:        "V2 Minimal Spec - Default Config",
 694 | 			spec:        specV2,
 695 | 			version:     VersionV2,
 696 | 			cfg:         &config.Config{}, // Default empty config
 697 | 			expectError: false,
 698 | 			expectedToolSet: &mcp.ToolSet{
 699 | 				Name:        "Minimal V2 API",
 700 | 				Description: "",
 701 | 				Tools: []mcp.Tool{
 702 | 					{
 703 | 						Name:        "getHealth",
 704 | 						Description: "Note: The API key is handled by the server, no need to provide it. Simple health check",
 705 | 						InputSchema: mcp.Schema{Type: "object", Properties: map[string]mcp.Schema{}, Required: []string{}},
 706 | 					},
 707 | 				},
 708 | 				Operations: map[string]mcp.OperationDetail{
 709 | 					"getHealth": {
 710 | 						Method:     "GET",
 711 | 						Path:       "/health",
 712 | 						BaseURL:    "",                      // No host/schemes/basePath
 713 | 						Parameters: []mcp.ParameterDetail{}, // Expect empty slice
 714 | 					},
 715 | 				},
 716 | 			},
 717 | 		},
 718 | 		{
 719 | 			name:    "V3 Minimal Spec - Config Overrides",
 720 | 			spec:    specV3,
 721 | 			version: VersionV3,
 722 | 			cfg: &config.Config{
 723 | 				ServerBaseURL:   "http://override.com/v1",
 724 | 				DefaultToolName: "Override Name",
 725 | 				DefaultToolDesc: "Override Desc",
 726 | 			},
 727 | 			expectError: false,
 728 | 			expectedToolSet: &mcp.ToolSet{
 729 | 				Name:        "Override Name", // Uses override
 730 | 				Description: "Override Desc", // Uses override
 731 | 				Tools: []mcp.Tool{
 732 | 					{
 733 | 						Name:        "getPing",
 734 | 						Description: "Note: The API key is handled by the server, no need to provide it. Simple ping endpoint",
 735 | 						InputSchema: mcp.Schema{Type: "object", Properties: map[string]mcp.Schema{}, Required: []string{}},
 736 | 					},
 737 | 				},
 738 | 				Operations: map[string]mcp.OperationDetail{
 739 | 					"getPing": {
 740 | 						Method:     "GET",
 741 | 						Path:       "/ping",
 742 | 						BaseURL:    "http://override.com/v1", // Uses override
 743 | 						Parameters: []mcp.ParameterDetail{},  // Expect empty slice
 744 | 					},
 745 | 				},
 746 | 			},
 747 | 		},
 748 | 		// --- Filtering Tests (Using Complex Specs) ---
 749 | 		{
 750 | 			name:        "V3 Complex - Include Tag1",
 751 | 			spec:        specComplexV3,
 752 | 			version:     VersionV3,
 753 | 			cfg:         &config.Config{IncludeTags: []string{"tag1"}},
 754 | 			expectError: false,
 755 | 			expectedToolSet: &mcp.ToolSet{
 756 | 				Name: "Complex V3 API", Description: "", // Should only include listItems and createItem
 757 | 				Tools:      []mcp.Tool{{Name: "listItems"}, {Name: "createItem"}},             // Simplified for length check
 758 | 				Operations: map[string]mcp.OperationDetail{"listItems": {}, "createItem": {}}, // Simplified for length check
 759 | 			},
 760 | 		},
 761 | 		{
 762 | 			name:        "V3 Complex - Exclude Tag2",
 763 | 			spec:        specComplexV3,
 764 | 			version:     VersionV3,
 765 | 			cfg:         &config.Config{ExcludeTags: []string{"tag2"}},
 766 | 			expectError: false,
 767 | 			expectedToolSet: &mcp.ToolSet{
 768 | 				Name: "Complex V3 API", Description: "", // Should include listItems and getPing
 769 | 				Tools:      []mcp.Tool{{Name: "listItems"}, {Name: "getPing"}},             // Simplified for length check
 770 | 				Operations: map[string]mcp.OperationDetail{"listItems": {}, "getPing": {}}, // Simplified for length check
 771 | 			},
 772 | 		},
 773 | 		{
 774 | 			name:        "V3 Complex - Include Operation listItems",
 775 | 			spec:        specComplexV3,
 776 | 			version:     VersionV3,
 777 | 			cfg:         &config.Config{IncludeOperations: []string{"listItems"}},
 778 | 			expectError: false,
 779 | 			expectedToolSet: &mcp.ToolSet{
 780 | 				Name: "Complex V3 API", Description: "", // Should include only listItems
 781 | 				Tools:      []mcp.Tool{{Name: "listItems"}},                 // Simplified for length check
 782 | 				Operations: map[string]mcp.OperationDetail{"listItems": {}}, // Simplified for length check
 783 | 			},
 784 | 		},
 785 | 		{
 786 | 			name:        "V3 Complex - Exclude Operation createItem, getPing",
 787 | 			spec:        specComplexV3,
 788 | 			version:     VersionV3,
 789 | 			cfg:         &config.Config{ExcludeOperations: []string{"createItem", "getPing"}},
 790 | 			expectError: false,
 791 | 			expectedToolSet: &mcp.ToolSet{
 792 | 				Name: "Complex V3 API", Description: "", // Should include listItems and listUsers
 793 | 				Tools:      []mcp.Tool{{Name: "listItems"}, {Name: "listUsers"}},             // Simplified for length check
 794 | 				Operations: map[string]mcp.OperationDetail{"listItems": {}, "listUsers": {}}, // Simplified for length check
 795 | 			},
 796 | 		},
 797 | 		{
 798 | 			name:        "V2 Complex - Include Tag1",
 799 | 			spec:        specComplexV2,
 800 | 			version:     VersionV2,
 801 | 			cfg:         &config.Config{IncludeTags: []string{"tag1"}},
 802 | 			expectError: false,
 803 | 			expectedToolSet: &mcp.ToolSet{
 804 | 				Name: "Complex V2 API", Description: "", // Should only include listItems and createItem
 805 | 				Tools:      []mcp.Tool{{Name: "listItems"}, {Name: "createItem"}},             // Simplified for length check
 806 | 				Operations: map[string]mcp.OperationDetail{"listItems": {}, "createItem": {}}, // Simplified for length check
 807 | 			},
 808 | 		},
 809 | 		{
 810 | 			name:        "V2 Complex - Exclude Tag2",
 811 | 			spec:        specComplexV2,
 812 | 			version:     VersionV2,
 813 | 			cfg:         &config.Config{ExcludeTags: []string{"tag2"}},
 814 | 			expectError: false,
 815 | 			expectedToolSet: &mcp.ToolSet{
 816 | 				Name: "Complex V2 API", Description: "", // Should include listItems and getPing
 817 | 				Tools:      []mcp.Tool{{Name: "listItems"}, {Name: "getPing"}},             // Simplified for length check
 818 | 				Operations: map[string]mcp.OperationDetail{"listItems": {}, "getPing": {}}, // Simplified for length check
 819 | 			},
 820 | 		},
 821 | 		// --- Parameter/Schema Tests ---
 822 | 		{
 823 | 			name:        "V3 Params and Request Body",
 824 | 			spec:        specParamsV3,
 825 | 			version:     VersionV3,
 826 | 			cfg:         &config.Config{},
 827 | 			expectError: false,
 828 | 			expectedToolSet: &mcp.ToolSet{
 829 | 				Name:        "Params V3 API",
 830 | 				Description: "", // Updated: No description in spec info
 831 | 				Tools: []mcp.Tool{
 832 | 					{
 833 | 						Name:        "testParams",
 834 | 						Description: "Note: The API key is handled by the server, no need to provide it. Test various params",
 835 | 						InputSchema: mcp.Schema{
 836 | 							Type: "object",
 837 | 							Properties: map[string]mcp.Schema{
 838 | 								// Parameters merged with Request Body properties
 839 | 								"path_param":     {Type: "integer", Format: "int32"},
 840 | 								"query_param":    {Type: "string", Enum: []interface{}{"A", "B"}},
 841 | 								"optional_query": {Type: "boolean"},
 842 | 								"X-Header-Param": {Type: "string"},
 843 | 								"CookieParam":    {Type: "number"},
 844 | 								"id":             {Type: "string"},
 845 | 								"value":          {Type: "number"},
 846 | 							},
 847 | 							Required: []string{"path_param", "query_param", "X-Header-Param", "id"}, // Order might differ, will sort before assert
 848 | 						},
 849 | 					},
 850 | 				},
 851 | 				Operations: map[string]mcp.OperationDetail{
 852 | 					"testParams": {
 853 | 						Method:  "POST",
 854 | 						Path:    "/test/{path_param}",
 855 | 						BaseURL: "", // No server
 856 | 						Parameters: []mcp.ParameterDetail{
 857 | 							{Name: "path_param", In: "path"},
 858 | 							{Name: "query_param", In: "query"},
 859 | 							{Name: "optional_query", In: "query"},
 860 | 							{Name: "X-Header-Param", In: "header"},
 861 | 							{Name: "CookieParam", In: "cookie"},
 862 | 						},
 863 | 					},
 864 | 				},
 865 | 			},
 866 | 		},
 867 | 		{
 868 | 			name:        "V2 Params and Ref",
 869 | 			spec:        specParamsV2,
 870 | 			version:     VersionV2,
 871 | 			cfg:         &config.Config{},
 872 | 			expectError: false,
 873 | 			expectedToolSet: &mcp.ToolSet{
 874 | 				Name:        "Params V2 API",
 875 | 				Description: "", // Corrected: No description in spec info
 876 | 				Tools: []mcp.Tool{
 877 | 					{
 878 | 						Name:        "testV2Params",
 879 | 						Description: "Note: The API key is handled by the server, no need to provide it. Test V2 params and ref",
 880 | 						InputSchema: mcp.Schema{
 881 | 							Type: "object",
 882 | 							Properties: map[string]mcp.Schema{
 883 | 								// Path, Query, Header params first
 884 | 								"path_id":      {Type: "string"},
 885 | 								"query_flag":   {Type: "boolean"},
 886 | 								"X-Request-ID": {Type: "string"},
 887 | 								// Body param ($ref to Item) merged
 888 | 								"id":   {Type: "string", Format: "uuid"},
 889 | 								"name": {Type: "string"},
 890 | 							},
 891 | 							Required: []string{"path_id", "query_flag", "id"}, // Required params + required definition props
 892 | 						},
 893 | 					},
 894 | 				},
 895 | 				Operations: map[string]mcp.OperationDetail{
 896 | 					"testV2Params": {
 897 | 						Method:  "PUT",
 898 | 						Path:    "/test/{path_id}",
 899 | 						BaseURL: "", // No server
 900 | 						Parameters: []mcp.ParameterDetail{
 901 | 							{Name: "path_id", In: "path"},
 902 | 							{Name: "query_flag", In: "query"},
 903 | 							{Name: "X-Request-ID", In: "header"},
 904 | 							{Name: "body_param", In: "body"}, // Body param listed here
 905 | 						},
 906 | 					},
 907 | 				},
 908 | 			},
 909 | 		},
 910 | 		// --- Array Tests ---
 911 | 		{
 912 | 			name:        "V3 Arrays",
 913 | 			spec:        specArraysV3,
 914 | 			version:     VersionV3,
 915 | 			cfg:         &config.Config{},
 916 | 			expectError: false,
 917 | 			expectedToolSet: &mcp.ToolSet{
 918 | 				Name: "Arrays V3 API", Description: "",
 919 | 				Tools: []mcp.Tool{
 920 | 					{
 921 | 						Name:        "processArrays",
 922 | 						Description: "Note: The API key is handled by the server, no need to provide it. Process arrays",
 923 | 						InputSchema: mcp.Schema{
 924 | 							Type: "object",
 925 | 							Properties: map[string]mcp.Schema{
 926 | 								"string_array_query": {Type: "array", Items: &mcp.Schema{Type: "string"}},
 927 | 								"int_array_body":     {Type: "array", Items: &mcp.Schema{Type: "integer", Format: "int64"}},
 928 | 							},
 929 | 							Required: []string{}, // No required fields specified
 930 | 						},
 931 | 					},
 932 | 				},
 933 | 				Operations: map[string]mcp.OperationDetail{
 934 | 					"processArrays": {
 935 | 						Method:  "POST",
 936 | 						Path:    "/process",
 937 | 						BaseURL: "",
 938 | 						Parameters: []mcp.ParameterDetail{
 939 | 							{Name: "string_array_query", In: "query"},
 940 | 							// Body param details are not explicitly listed in V3 op details
 941 | 						},
 942 | 					},
 943 | 				},
 944 | 			},
 945 | 		},
 946 | 		{
 947 | 			name:        "V2 Arrays",
 948 | 			spec:        specArraysV2,
 949 | 			version:     VersionV2,
 950 | 			cfg:         &config.Config{},
 951 | 			expectError: false,
 952 | 			expectedToolSet: &mcp.ToolSet{
 953 | 				Name: "Arrays V2 API", Description: "",
 954 | 				Tools: []mcp.Tool{
 955 | 					{
 956 | 						Name:        "getArrays",
 957 | 						Description: "Note: The API key is handled by the server, no need to provide it. Get arrays",
 958 | 						InputSchema: mcp.Schema{
 959 | 							Type: "object",
 960 | 							Properties: map[string]mcp.Schema{
 961 | 								"string_array_query": {Type: "array", Items: &mcp.Schema{Type: "string"}},
 962 | 								"int_array_form":     {Type: "array", Items: &mcp.Schema{Type: "integer", Format: "int32"}},
 963 | 							},
 964 | 							Required: []string{}, // No required fields specified
 965 | 						},
 966 | 					},
 967 | 				},
 968 | 				Operations: map[string]mcp.OperationDetail{
 969 | 					"getArrays": {
 970 | 						Method:  "GET",
 971 | 						Path:    "/process",
 972 | 						BaseURL: "",
 973 | 						Parameters: []mcp.ParameterDetail{
 974 | 							{Name: "string_array_query", In: "query"},
 975 | 							{Name: "int_array_form", In: "formData"},
 976 | 						},
 977 | 					},
 978 | 				},
 979 | 			},
 980 | 		},
 981 | 		{
 982 | 			name:        "V2 File Param",
 983 | 			spec:        specFileV2,
 984 | 			version:     VersionV2,
 985 | 			cfg:         &config.Config{},
 986 | 			expectError: false,
 987 | 			expectedToolSet: &mcp.ToolSet{
 988 | 				Name: "File V2 API", Description: "",
 989 | 				Tools: []mcp.Tool{
 990 | 					{
 991 | 						Name:        "uploadFile",
 992 | 						Description: "Note: The API key is handled by the server, no need to provide it. Upload file",
 993 | 						InputSchema: mcp.Schema{
 994 | 							Type: "object",
 995 | 							Properties: map[string]mcp.Schema{
 996 | 								"description": {Type: "string"},
 997 | 								"file_upload": {Type: "string"}, // file type maps to string
 998 | 							},
 999 | 							Required: []string{"file_upload"}, // file_upload is required
1000 | 						},
1001 | 					},
1002 | 				},
1003 | 				Operations: map[string]mcp.OperationDetail{
1004 | 					"uploadFile": {
1005 | 						Method:  "POST",
1006 | 						Path:    "/upload",
1007 | 						BaseURL: "",
1008 | 						Parameters: []mcp.ParameterDetail{
1009 | 							{Name: "description", In: "formData"},
1010 | 							{Name: "file_upload", In: "formData"},
1011 | 						},
1012 | 					},
1013 | 				},
1014 | 			},
1015 | 		},
1016 | 		// TODO: Add V3/V2 tests for refs
1017 | 		// TODO: Add V3/V2 tests for file types (V2)
1018 | 	}
1019 | 
1020 | 	for _, tc := range tests {
1021 | 		t.Run(tc.name, func(t *testing.T) {
1022 | 			toolSet, err := GenerateToolSet(tc.spec, tc.version, tc.cfg)
1023 | 
1024 | 			if tc.expectError {
1025 | 				assert.Error(t, err)
1026 | 				assert.Nil(t, toolSet)
1027 | 			} else {
1028 | 				assert.NoError(t, err)
1029 | 				require.NotNil(t, toolSet)
1030 | 
1031 | 				// Compare basic ToolSet fields
1032 | 				assert.Equal(t, tc.expectedToolSet.Name, toolSet.Name, "ToolSet Name mismatch")
1033 | 				assert.Equal(t, tc.expectedToolSet.Description, toolSet.Description, "ToolSet Description mismatch")
1034 | 
1035 | 				// Compare Tool/Operation counts first for filtering tests
1036 | 				assert.Equal(t, len(tc.expectedToolSet.Tools), len(toolSet.Tools), "Tool count mismatch")
1037 | 				assert.Equal(t, len(tc.expectedToolSet.Operations), len(toolSet.Operations), "Operation count mismatch")
1038 | 
1039 | 				// If counts match, check specific tool names exist (more robust for filtering tests)
1040 | 				if len(tc.expectedToolSet.Tools) == len(toolSet.Tools) {
1041 | 					actualToolNames := make(map[string]bool)
1042 | 					for _, actualTool := range toolSet.Tools {
1043 | 						actualToolNames[actualTool.Name] = true
1044 | 					}
1045 | 					for _, expectedTool := range tc.expectedToolSet.Tools {
1046 | 						assert.Contains(t, actualToolNames, expectedTool.Name, "Expected tool %s not found in actual tools", expectedTool.Name)
1047 | 					}
1048 | 				}
1049 | 
1050 | 				// If counts match, check specific operation IDs exist (more robust for filtering tests)
1051 | 				if len(tc.expectedToolSet.Operations) == len(toolSet.Operations) {
1052 | 					for opID := range tc.expectedToolSet.Operations {
1053 | 						assert.Contains(t, toolSet.Operations, opID, "Expected operation detail %s not found", opID)
1054 | 					}
1055 | 				}
1056 | 
1057 | 				// Full comparison only for non-filtering tests for now (can be expanded)
1058 | 				if !strings.Contains(tc.name, "Complex") {
1059 | 					// Compare Tools slice fully
1060 | 					for i, expectedTool := range tc.expectedToolSet.Tools {
1061 | 						if i < len(toolSet.Tools) { // Bounds check
1062 | 							actualTool := toolSet.Tools[i]
1063 | 							assert.Equal(t, expectedTool.Name, actualTool.Name, "Tool[%d] Name mismatch", i)
1064 | 							assert.Equal(t, expectedTool.Description, actualTool.Description, "Tool[%d] Description mismatch", i)
1065 | 							// Sort Required slices before comparing Schemas
1066 | 							expectedSchema := expectedTool.InputSchema
1067 | 							actualSchema := actualTool.InputSchema
1068 | 							sort.Strings(expectedSchema.Required)
1069 | 							sort.Strings(actualSchema.Required)
1070 | 							assert.Equal(t, expectedSchema, actualSchema, "Tool[%d] InputSchema mismatch", i)
1071 | 						}
1072 | 					}
1073 | 					// Compare Operations map fully
1074 | 					for opID, expectedOpDetail := range tc.expectedToolSet.Operations {
1075 | 						if actualOpDetail, ok := toolSet.Operations[opID]; ok {
1076 | 							assert.Equal(t, expectedOpDetail.Method, actualOpDetail.Method, "OpDetail %s Method mismatch", opID)
1077 | 							assert.Equal(t, expectedOpDetail.Path, actualOpDetail.Path, "OpDetail %s Path mismatch", opID)
1078 | 							assert.Equal(t, expectedOpDetail.BaseURL, actualOpDetail.BaseURL, "OpDetail %s BaseURL mismatch", opID)
1079 | 							assert.Equal(t, expectedOpDetail.Parameters, actualOpDetail.Parameters, "OpDetail %s Parameters mismatch", opID)
1080 | 						}
1081 | 					}
1082 | 				}
1083 | 			}
1084 | 		})
1085 | 	}
1086 | }
1087 | 
```

--------------------------------------------------------------------------------
/pkg/parser/parser.go:
--------------------------------------------------------------------------------

```go
  1 | package parser
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"io"
  8 | 	"log"
  9 | 	"net/http"
 10 | 	"net/url"
 11 | 	"os"
 12 | 	"path/filepath"
 13 | 	"sort"
 14 | 	"strings"
 15 | 
 16 | 	"github.com/ckanthony/openapi-mcp/pkg/config"
 17 | 	"github.com/ckanthony/openapi-mcp/pkg/mcp"
 18 | 	"github.com/getkin/kin-openapi/openapi3"
 19 | 	"github.com/go-openapi/loads"
 20 | 	"github.com/go-openapi/spec"
 21 | )
 22 | 
 23 | const (
 24 | 	VersionV2 = "v2"
 25 | 	VersionV3 = "v3"
 26 | )
 27 | 
 28 | // LoadSwagger detects the version and loads an OpenAPI/Swagger specification
 29 | // from a local file path or a remote URL.
 30 | // It returns the loaded spec document (as interface{}), the detected version (string), and an error.
 31 | func LoadSwagger(location string) (interface{}, string, error) {
 32 | 	// Determine if location is URL or file path
 33 | 	locationURL, urlErr := url.ParseRequestURI(location)
 34 | 	isURL := urlErr == nil && locationURL != nil && (locationURL.Scheme == "http" || locationURL.Scheme == "https")
 35 | 
 36 | 	var data []byte
 37 | 	var err error
 38 | 	var absPath string // Store absolute path if it's a file
 39 | 
 40 | 	if !isURL {
 41 | 		log.Printf("Detected file path location: %s", location)
 42 | 		absPath, err = filepath.Abs(location)
 43 | 		if err != nil {
 44 | 			return nil, "", fmt.Errorf("failed to get absolute path for '%s': %w", location, err)
 45 | 		}
 46 | 		// Read data first for version detection
 47 | 		data, err = os.ReadFile(absPath)
 48 | 		if err != nil {
 49 | 			return nil, "", fmt.Errorf("failed reading file path '%s': %w", absPath, err)
 50 | 		}
 51 | 	} else {
 52 | 		log.Printf("Detected URL location: %s", location)
 53 | 		// Read data first for version detection
 54 | 		resp, err := http.Get(location)
 55 | 		if err != nil {
 56 | 			return nil, "", fmt.Errorf("failed to fetch URL '%s': %w", location, err)
 57 | 		}
 58 | 		defer resp.Body.Close()
 59 | 		if resp.StatusCode != http.StatusOK {
 60 | 			bodyBytes, _ := io.ReadAll(resp.Body) // Attempt to read body for error context
 61 | 			return nil, "", fmt.Errorf("failed to fetch URL '%s': status code %d, body: %s", location, resp.StatusCode, string(bodyBytes))
 62 | 		}
 63 | 		data, err = io.ReadAll(resp.Body)
 64 | 		if err != nil {
 65 | 			return nil, "", fmt.Errorf("failed to read response body from URL '%s': %w", location, err)
 66 | 		}
 67 | 	}
 68 | 
 69 | 	// Detect version from data
 70 | 	var detector map[string]interface{}
 71 | 	if err := json.Unmarshal(data, &detector); err != nil {
 72 | 		return nil, "", fmt.Errorf("failed to parse JSON from '%s' for version detection: %w", location, err)
 73 | 	}
 74 | 
 75 | 	if _, ok := detector["openapi"]; ok {
 76 | 		// OpenAPI 3.x
 77 | 		loader := openapi3.NewLoader()
 78 | 		loader.IsExternalRefsAllowed = true
 79 | 		var doc *openapi3.T
 80 | 		var loadErr error
 81 | 
 82 | 		if !isURL {
 83 | 			// Use LoadFromFile for local files
 84 | 			log.Printf("Loading V3 spec using LoadFromFile: %s", absPath)
 85 | 			doc, loadErr = loader.LoadFromFile(absPath)
 86 | 		} else {
 87 | 			// Use LoadFromURI for URLs
 88 | 			log.Printf("Loading V3 spec using LoadFromURI: %s", location)
 89 | 			doc, loadErr = loader.LoadFromURI(locationURL)
 90 | 		}
 91 | 
 92 | 		if loadErr != nil {
 93 | 			return nil, "", fmt.Errorf("failed to load OpenAPI v3 spec from '%s': %w", location, loadErr)
 94 | 		}
 95 | 
 96 | 		if err := doc.Validate(context.Background()); err != nil {
 97 | 			return nil, "", fmt.Errorf("OpenAPI v3 spec validation failed for '%s': %w", location, err)
 98 | 		}
 99 | 		return doc, VersionV3, nil
100 | 	} else if _, ok := detector["swagger"]; ok {
101 | 		// Swagger 2.0 - Still load from data as loads.Analyzed expects bytes
102 | 		log.Printf("Loading V2 spec using loads.Analyzed from data (source: %s)", location)
103 | 		doc, err := loads.Analyzed(data, "2.0")
104 | 		if err != nil {
105 | 			return nil, "", fmt.Errorf("failed to load or validate Swagger v2 spec from '%s': %w", location, err)
106 | 		}
107 | 		return doc.Spec(), VersionV2, nil
108 | 	} else {
109 | 		return nil, "", fmt.Errorf("failed to detect OpenAPI/Swagger version in '%s': missing 'openapi' or 'swagger' key", location)
110 | 	}
111 | }
112 | 
113 | // GenerateToolSet converts a loaded spec (v2 or v3) into an MCP ToolSet.
114 | func GenerateToolSet(specDoc interface{}, version string, cfg *config.Config) (*mcp.ToolSet, error) {
115 | 	switch version {
116 | 	case VersionV3:
117 | 		docV3, ok := specDoc.(*openapi3.T)
118 | 		if !ok {
119 | 			return nil, fmt.Errorf("internal error: expected *openapi3.T for v3 spec, got %T", specDoc)
120 | 		}
121 | 		return generateToolSetV3(docV3, cfg)
122 | 	case VersionV2:
123 | 		docV2, ok := specDoc.(*spec.Swagger)
124 | 		if !ok {
125 | 			return nil, fmt.Errorf("internal error: expected *spec.Swagger for v2 spec, got %T", specDoc)
126 | 		}
127 | 		return generateToolSetV2(docV2, cfg)
128 | 	default:
129 | 		return nil, fmt.Errorf("unsupported specification version: %s", version)
130 | 	}
131 | }
132 | 
133 | // --- V3 Specific Implementation ---
134 | 
135 | func generateToolSetV3(doc *openapi3.T, cfg *config.Config) (*mcp.ToolSet, error) {
136 | 	toolSet := createBaseToolSet(doc.Info.Title, doc.Info.Description, cfg)
137 | 	toolSet.Operations = make(map[string]mcp.OperationDetail) // Initialize the map
138 | 
139 | 	// Determine Base URL once
140 | 	baseURL, err := determineBaseURLV3(doc, cfg)
141 | 	if err != nil {
142 | 		log.Printf("Warning: Could not determine base URL for V3 spec: %v. Operations might fail if base URL override is not set.", err)
143 | 		baseURL = "" // Allow proceeding if override is set
144 | 	}
145 | 
146 | 	// // V3 Handles security differently (Components.SecuritySchemes). Rely on config flags for server-side injection.
147 | 	// apiKeyName := cfg.APIKeyName
148 | 	// apiKeyIn := string(cfg.APIKeyLocation)
149 | 	// // Store detected/configured key details internally - Let config handle this
150 | 	// toolSet.SetAPIKeyDetails(apiKeyName, apiKeyIn)
151 | 
152 | 	paths := getSortedPathsV3(doc.Paths)
153 | 	for _, rawPath := range paths { // Rename loop var to rawPath
154 | 		pathItem := doc.Paths.Value(rawPath)
155 | 		for method, op := range pathItem.Operations() {
156 | 			if op == nil || !shouldIncludeOperationV3(op, cfg) {
157 | 				continue
158 | 			}
159 | 
160 | 			// Clean the path
161 | 			cleanPath := rawPath
162 | 			if queryIndex := strings.Index(rawPath, "?"); queryIndex != -1 {
163 | 				cleanPath = rawPath[:queryIndex]
164 | 			}
165 | 
166 | 			toolName := generateToolNameV3(op, method, rawPath) // Still generate name from raw path
167 | 			toolDesc := getOperationDescriptionV3(op)
168 | 
169 | 			// Convert parameters (query, header, path, cookie)
170 | 			parametersSchema, opParams, err := parametersToMCPSchemaAndDetailsV3(op.Parameters, cfg)
171 | 			if err != nil {
172 | 				return nil, fmt.Errorf("error processing v3 parameters for %s %s: %w", method, rawPath, err)
173 | 			}
174 | 
175 | 			// Handle request body
176 | 			requestBody, err := requestBodyToMCPV3(op.RequestBody)
177 | 			if err != nil {
178 | 				log.Printf("Warning: skipping request body for %s %s due to error: %v", method, rawPath, err)
179 | 			} else {
180 | 				// Merge request body schema into the main parameter schema
181 | 				if requestBody.Content != nil {
182 | 					if parametersSchema.Properties == nil {
183 | 						parametersSchema.Properties = make(map[string]mcp.Schema)
184 | 					}
185 | 					for _, mediaTypeSchema := range requestBody.Content {
186 | 						if mediaTypeSchema.Type == "object" && mediaTypeSchema.Properties != nil {
187 | 							for propName, propSchema := range mediaTypeSchema.Properties {
188 | 								parametersSchema.Properties[propName] = propSchema
189 | 							}
190 | 						} else {
191 | 							// If body is not an object, represent as 'requestBody'
192 | 							log.Printf("Warning: V3 request body for %s %s is not an object schema. Representing as 'requestBody' field.", method, rawPath)
193 | 							parametersSchema.Properties["requestBody"] = mediaTypeSchema
194 | 						}
195 | 						break // Only process the first content type
196 | 					}
197 | 
198 | 					// Merge required fields from the body *schema* (not the requestBody boolean)
199 | 					var bodySchemaRequired []string
200 | 					for _, mediaTypeSchema := range requestBody.Content {
201 | 						if len(mediaTypeSchema.Required) > 0 {
202 | 							bodySchemaRequired = mediaTypeSchema.Required
203 | 							break // Use required from the first content type with a schema
204 | 						}
205 | 					}
206 | 
207 | 					if len(bodySchemaRequired) > 0 {
208 | 						if parametersSchema.Required == nil {
209 | 							parametersSchema.Required = make([]string, 0)
210 | 						}
211 | 						for _, r := range bodySchemaRequired { // Range over the correct schema required list
212 | 							if !sliceContains(parametersSchema.Required, r) {
213 | 								parametersSchema.Required = append(parametersSchema.Required, r)
214 | 							}
215 | 						}
216 | 						sort.Strings(parametersSchema.Required)
217 | 					}
218 | 
219 | 					// Optionally, add a note if the requestBody itself was marked as required
220 | 					if requestBody.Required { // Check the boolean field
221 | 						// How to indicate this? Maybe add to description?
222 | 						log.Printf("Note: Request body for %s %s is marked as required.", method, rawPath)
223 | 						// Or add all top-level body props to required? Needs decision.
224 | 					}
225 | 				}
226 | 			}
227 | 
228 | 			// Prepend note about API key handling
229 | 			finalToolDesc := "Note: The API key is handled by the server, no need to provide it. " + toolDesc
230 | 
231 | 			tool := mcp.Tool{
232 | 				Name:        toolName,
233 | 				Description: finalToolDesc,    // Use potentially modified description
234 | 				InputSchema: parametersSchema, // Use InputSchema, assuming it contains combined params/body
235 | 			}
236 | 			toolSet.Tools = append(toolSet.Tools, tool)
237 | 
238 | 			// Store operation details for execution
239 | 			toolSet.Operations[toolName] = mcp.OperationDetail{
240 | 				Method:     method,
241 | 				Path:       cleanPath, // Use the cleaned path here
242 | 				BaseURL:    baseURL,
243 | 				Parameters: opParams,
244 | 			}
245 | 		}
246 | 	}
247 | 	return toolSet, nil
248 | }
249 | 
250 | func determineBaseURLV3(doc *openapi3.T, cfg *config.Config) (string, error) {
251 | 	if cfg.ServerBaseURL != "" {
252 | 		return strings.TrimSuffix(cfg.ServerBaseURL, "/"), nil
253 | 	}
254 | 	if len(doc.Servers) > 0 {
255 | 		baseURL := ""
256 | 		for _, server := range doc.Servers {
257 | 			if baseURL == "" {
258 | 				baseURL = server.URL
259 | 			}
260 | 			if strings.HasPrefix(strings.ToLower(server.URL), "https://") {
261 | 				baseURL = server.URL
262 | 				break
263 | 			}
264 | 			if strings.HasPrefix(strings.ToLower(server.URL), "http://") {
265 | 				baseURL = server.URL
266 | 			}
267 | 		}
268 | 		if baseURL == "" {
269 | 			return "", fmt.Errorf("v3: could not determine a suitable base URL from servers list")
270 | 		}
271 | 		return strings.TrimSuffix(baseURL, "/"), nil
272 | 	}
273 | 	return "", fmt.Errorf("v3: no server base URL specified in config or OpenAPI spec servers list")
274 | }
275 | 
276 | func getSortedPathsV3(paths *openapi3.Paths) []string {
277 | 	if paths == nil {
278 | 		return []string{}
279 | 	}
280 | 	keys := make([]string, 0, len(paths.Map()))
281 | 	for k := range paths.Map() {
282 | 		keys = append(keys, k)
283 | 	}
284 | 	sort.Strings(keys)
285 | 	return keys
286 | }
287 | 
288 | func generateToolNameV3(op *openapi3.Operation, method, path string) string {
289 | 	if op.OperationID != "" {
290 | 		return op.OperationID
291 | 	}
292 | 	return generateDefaultToolName(method, path)
293 | }
294 | 
295 | func getOperationDescriptionV3(op *openapi3.Operation) string {
296 | 	if op.Summary != "" {
297 | 		return op.Summary
298 | 	}
299 | 	return op.Description
300 | }
301 | 
302 | func shouldIncludeOperationV3(op *openapi3.Operation, cfg *config.Config) bool {
303 | 	return shouldInclude(op.OperationID, op.Tags, cfg)
304 | }
305 | 
306 | // parametersToMCPSchemaAndDetailsV3 converts parameters and also returns the parameter details.
307 | func parametersToMCPSchemaAndDetailsV3(params openapi3.Parameters, cfg *config.Config) (mcp.Schema, []mcp.ParameterDetail, error) {
308 | 	mcpSchema := mcp.Schema{Type: "object", Properties: make(map[string]mcp.Schema), Required: []string{}}
309 | 	opParams := []mcp.ParameterDetail{}
310 | 	for _, paramRef := range params {
311 | 		if paramRef.Value == nil {
312 | 			log.Printf("Warning: Skipping parameter with nil value.")
313 | 			continue
314 | 		}
315 | 		param := paramRef.Value
316 | 		if param.Schema == nil {
317 | 			log.Printf("Warning: Skipping parameter '%s' with nil schema.", param.Name)
318 | 			continue
319 | 		}
320 | 
321 | 		// Skip the API key parameter if configured
322 | 		if cfg.APIKeyName != "" && param.Name == cfg.APIKeyName && param.In == string(cfg.APIKeyLocation) {
323 | 			log.Printf("Parser V3: Skipping API key parameter '%s' ('%s') from input schema generation.", param.Name, param.In)
324 | 			continue
325 | 		}
326 | 
327 | 		// Store parameter detail (even if skipped for schema, needed for execution?)
328 | 		// Decision: Keep storing *all* params in opParams for potential server-side use,
329 | 		//           but skip adding the API key to the mcpSchema exposed to the client.
330 | 		opParams = append(opParams, mcp.ParameterDetail{
331 | 			Name: param.Name,
332 | 			In:   param.In,
333 | 		})
334 | 
335 | 		propSchema, err := openapiSchemaToMCPSchemaV3(param.Schema)
336 | 		if err != nil {
337 | 			return mcp.Schema{}, nil, fmt.Errorf("v3 param '%s': %w", param.Name, err)
338 | 		}
339 | 		propSchema.Description = param.Description
340 | 		mcpSchema.Properties[param.Name] = propSchema
341 | 		if param.Required {
342 | 			mcpSchema.Required = append(mcpSchema.Required, param.Name)
343 | 		}
344 | 	}
345 | 	if len(mcpSchema.Required) > 1 {
346 | 		sort.Strings(mcpSchema.Required)
347 | 	}
348 | 	return mcpSchema, opParams, nil
349 | }
350 | 
351 | func requestBodyToMCPV3(rbRef *openapi3.RequestBodyRef) (mcp.RequestBody, error) {
352 | 	mcpRB := mcp.RequestBody{Content: make(map[string]mcp.Schema)}
353 | 	if rbRef == nil || rbRef.Value == nil {
354 | 		return mcpRB, nil
355 | 	}
356 | 	rb := rbRef.Value
357 | 	mcpRB.Description = rb.Description
358 | 	mcpRB.Required = rb.Required
359 | 
360 | 	var mediaType *openapi3.MediaType
361 | 	var chosenMediaTypeKey string
362 | 	if mt, ok := rb.Content["application/json"]; ok {
363 | 		mediaType, chosenMediaTypeKey = mt, "application/json"
364 | 	} else {
365 | 		for key, mt := range rb.Content {
366 | 			mediaType, chosenMediaTypeKey = mt, key
367 | 			break
368 | 		}
369 | 	}
370 | 
371 | 	if mediaType != nil && mediaType.Schema != nil {
372 | 		contentSchema, err := openapiSchemaToMCPSchemaV3(mediaType.Schema)
373 | 		if err != nil {
374 | 			return mcp.RequestBody{}, fmt.Errorf("v3 request body (media type: %s): %w", chosenMediaTypeKey, err)
375 | 		}
376 | 		mcpRB.Content["application/json"] = contentSchema
377 | 	} else if mediaType != nil {
378 | 		mcpRB.Content["application/json"] = mcp.Schema{Type: "string", Description: fmt.Sprintf("Request body with media type %s (no specific schema defined)", chosenMediaTypeKey)}
379 | 	}
380 | 	return mcpRB, nil
381 | }
382 | 
383 | func openapiSchemaToMCPSchemaV3(oapiSchemaRef *openapi3.SchemaRef) (mcp.Schema, error) {
384 | 	if oapiSchemaRef == nil {
385 | 		return mcp.Schema{Type: "string", Description: "Schema reference was nil"}, nil
386 | 	}
387 | 	if oapiSchemaRef.Value == nil {
388 | 		return mcp.Schema{Type: "string", Description: fmt.Sprintf("Schema reference value was nil (ref: %s)", oapiSchemaRef.Ref)}, nil
389 | 	}
390 | 	oapiSchema := oapiSchemaRef.Value
391 | 
392 | 	var primaryType string
393 | 	if oapiSchema.Type != nil && len(*oapiSchema.Type) > 0 {
394 | 		primaryType = (*oapiSchema.Type)[0]
395 | 	}
396 | 
397 | 	mcpSchema := mcp.Schema{
398 | 		Type:        mapJSONSchemaType(primaryType),
399 | 		Description: oapiSchema.Description,
400 | 		Format:      oapiSchema.Format,
401 | 		Enum:        oapiSchema.Enum,
402 | 	}
403 | 
404 | 	switch mcpSchema.Type {
405 | 	case "object":
406 | 		mcpSchema.Properties = make(map[string]mcp.Schema)
407 | 		mcpSchema.Required = oapiSchema.Required
408 | 		for name, propRef := range oapiSchema.Properties {
409 | 			propSchema, err := openapiSchemaToMCPSchemaV3(propRef)
410 | 			if err != nil {
411 | 				return mcp.Schema{}, fmt.Errorf("v3 object property '%s': %w", name, err)
412 | 			}
413 | 			mcpSchema.Properties[name] = propSchema
414 | 		}
415 | 		if len(mcpSchema.Required) > 1 {
416 | 			sort.Strings(mcpSchema.Required)
417 | 		}
418 | 	case "array":
419 | 		if oapiSchema.Items != nil {
420 | 			itemsSchema, err := openapiSchemaToMCPSchemaV3(oapiSchema.Items)
421 | 			if err != nil {
422 | 				return mcp.Schema{}, fmt.Errorf("v3 array items: %w", err)
423 | 			}
424 | 			mcpSchema.Items = &itemsSchema
425 | 		}
426 | 	case "string", "number", "integer", "boolean", "null":
427 | 		// Basic types mapped
428 | 	default:
429 | 		if mcpSchema.Type == "string" && primaryType != "" && primaryType != "string" {
430 | 			mcpSchema.Description += fmt.Sprintf(" (Original type '%s' unknown or unsupported)", primaryType)
431 | 		}
432 | 	}
433 | 	return mcpSchema, nil
434 | }
435 | 
436 | // --- V2 Specific Implementation ---
437 | 
438 | func generateToolSetV2(doc *spec.Swagger, cfg *config.Config) (*mcp.ToolSet, error) {
439 | 	toolSet := createBaseToolSet(doc.Info.Title, doc.Info.Description, cfg)
440 | 	toolSet.Operations = make(map[string]mcp.OperationDetail) // Initialize map
441 | 
442 | 	// Determine Base URL once
443 | 	baseURL, err := determineBaseURLV2(doc, cfg)
444 | 	if err != nil {
445 | 		log.Printf("Warning: Could not determine base URL for V2 spec: %v. Operations might fail if base URL override is not set.", err)
446 | 		baseURL = "" // Allow proceeding if override is set
447 | 	}
448 | 
449 | 	// Detect API Key (Security Definitions)
450 | 	apiKeyName := cfg.APIKeyName
451 | 	apiKeyIn := string(cfg.APIKeyLocation)
452 | 
453 | 	if apiKeyName == "" && apiKeyIn == "" { // Only infer if not provided by config
454 | 		for name, secDef := range doc.SecurityDefinitions {
455 | 			if secDef.Type == "apiKey" {
456 | 				apiKeyName = secDef.Name
457 | 				apiKeyIn = secDef.In // "query" or "header"
458 | 				log.Printf("Parser V2: Detected API key from security definition '%s': Name='%s', In='%s'", name, apiKeyName, apiKeyIn)
459 | 				break // Assume only one apiKey definition for simplicity
460 | 			}
461 | 		}
462 | 	}
463 | 	// Store detected/configured key details internally
464 | 	toolSet.SetAPIKeyDetails(apiKeyName, apiKeyIn)
465 | 
466 | 	// --- Iterate through Paths ---
467 | 	paths := getSortedPathsV2(doc.Paths)
468 | 	for _, rawPath := range paths { // Rename loop var to rawPath
469 | 		pathItem := doc.Paths.Paths[rawPath]
470 | 		ops := map[string]*spec.Operation{
471 | 			"GET":     pathItem.Get,
472 | 			"PUT":     pathItem.Put,
473 | 			"POST":    pathItem.Post,
474 | 			"DELETE":  pathItem.Delete,
475 | 			"OPTIONS": pathItem.Options,
476 | 			"HEAD":    pathItem.Head,
477 | 			"PATCH":   pathItem.Patch,
478 | 		}
479 | 
480 | 		for method, op := range ops {
481 | 			if op == nil || !shouldIncludeOperationV2(op, cfg) {
482 | 				continue
483 | 			}
484 | 
485 | 			// Clean the path
486 | 			cleanPath := rawPath
487 | 			if queryIndex := strings.Index(rawPath, "?"); queryIndex != -1 {
488 | 				cleanPath = rawPath[:queryIndex]
489 | 			}
490 | 
491 | 			toolName := generateToolNameV2(op, method, rawPath) // Still generate name from raw path
492 | 			toolDesc := getOperationDescriptionV2(op)
493 | 
494 | 			// Convert parameters and potential body schema
495 | 			parametersSchema, bodySchema, opParams, err := parametersToMCPSchemaAndDetailsV2(op.Parameters, doc.Definitions, apiKeyName)
496 | 			if err != nil {
497 | 				return nil, fmt.Errorf("error processing v2 parameters for %s %s: %w", method, rawPath, err)
498 | 			}
499 | 
500 | 			// Combine request body into parameters schema if it exists
501 | 			if bodySchema.Type != "" { // Check if bodySchema was actually populated
502 | 				if bodySchema.Type == "object" && bodySchema.Properties != nil {
503 | 					if parametersSchema.Properties == nil {
504 | 						parametersSchema.Properties = make(map[string]mcp.Schema)
505 | 					}
506 | 					for propName, propSchema := range bodySchema.Properties {
507 | 						parametersSchema.Properties[propName] = propSchema
508 | 					}
509 | 					if len(bodySchema.Required) > 0 {
510 | 						if parametersSchema.Required == nil {
511 | 							parametersSchema.Required = make([]string, 0)
512 | 						}
513 | 						for _, r := range bodySchema.Required {
514 | 							if !sliceContains(parametersSchema.Required, r) {
515 | 								parametersSchema.Required = append(parametersSchema.Required, r)
516 | 							}
517 | 						}
518 | 						sort.Strings(parametersSchema.Required)
519 | 					}
520 | 				} else {
521 | 					// If body is not an object, represent as 'requestBody'
522 | 					log.Printf("Warning: V2 request body for %s %s is not an object schema. Representing as 'requestBody' field.", method, rawPath)
523 | 					if parametersSchema.Properties == nil {
524 | 						parametersSchema.Properties = make(map[string]mcp.Schema)
525 | 					}
526 | 					parametersSchema.Properties["requestBody"] = bodySchema
527 | 				}
528 | 			}
529 | 
530 | 			// Prepend note about API key handling
531 | 			finalToolDesc := "Note: The API key is handled by the server, no need to provide it. " + toolDesc
532 | 
533 | 			tool := mcp.Tool{
534 | 				Name:        toolName,
535 | 				Description: finalToolDesc,    // Use potentially modified description
536 | 				InputSchema: parametersSchema, // Use InputSchema, assuming it contains combined params/body
537 | 			}
538 | 			toolSet.Tools = append(toolSet.Tools, tool)
539 | 
540 | 			// Store operation details for execution
541 | 			toolSet.Operations[toolName] = mcp.OperationDetail{
542 | 				Method:     method,
543 | 				Path:       cleanPath, // Use the cleaned path here
544 | 				BaseURL:    baseURL,
545 | 				Parameters: opParams,
546 | 			}
547 | 		}
548 | 	}
549 | 
550 | 	return toolSet, nil
551 | }
552 | 
553 | func determineBaseURLV2(doc *spec.Swagger, cfg *config.Config) (string, error) {
554 | 	if cfg.ServerBaseURL != "" {
555 | 		return strings.TrimSuffix(cfg.ServerBaseURL, "/"), nil
556 | 	}
557 | 
558 | 	host := doc.Host
559 | 	if host == "" {
560 | 		return "", fmt.Errorf("v2: missing 'host' in spec")
561 | 	}
562 | 
563 | 	scheme := "https"
564 | 	if len(doc.Schemes) > 0 {
565 | 		// Prefer https, then http, then first
566 | 		preferred := []string{"https", "http"}
567 | 		found := false
568 | 		for _, p := range preferred {
569 | 			for _, s := range doc.Schemes {
570 | 				if s == p {
571 | 					scheme = s
572 | 					found = true
573 | 					break
574 | 				}
575 | 			}
576 | 			if found {
577 | 				break
578 | 			}
579 | 		}
580 | 		if !found {
581 | 			scheme = doc.Schemes[0]
582 | 		} // fallback to first scheme
583 | 	} // else default to https
584 | 
585 | 	basePath := doc.BasePath
586 | 
587 | 	return strings.TrimSuffix(scheme+"://"+host+basePath, "/"), nil
588 | }
589 | 
590 | func getSortedPathsV2(paths *spec.Paths) []string {
591 | 	if paths == nil {
592 | 		return []string{}
593 | 	}
594 | 	keys := make([]string, 0, len(paths.Paths))
595 | 	for k := range paths.Paths {
596 | 		keys = append(keys, k)
597 | 	}
598 | 	sort.Strings(keys)
599 | 	return keys
600 | }
601 | 
602 | func generateToolNameV2(op *spec.Operation, method, path string) string {
603 | 	if op.ID != "" {
604 | 		return op.ID
605 | 	}
606 | 	return generateDefaultToolName(method, path)
607 | }
608 | 
609 | func getOperationDescriptionV2(op *spec.Operation) string {
610 | 	if op.Summary != "" {
611 | 		return op.Summary
612 | 	}
613 | 	return op.Description
614 | }
615 | 
616 | func shouldIncludeOperationV2(op *spec.Operation, cfg *config.Config) bool {
617 | 	return shouldInclude(op.ID, op.Tags, cfg)
618 | }
619 | 
620 | // parametersToMCPSchemaAndDetailsV2 converts V2 parameters and also returns details and request body.
621 | func parametersToMCPSchemaAndDetailsV2(params []spec.Parameter, definitions spec.Definitions, apiKeyName string) (mcp.Schema, mcp.Schema, []mcp.ParameterDetail, error) {
622 | 	mcpSchema := mcp.Schema{Type: "object", Properties: make(map[string]mcp.Schema), Required: []string{}}
623 | 	bodySchema := mcp.Schema{} // Initialize empty
624 | 	opParams := []mcp.ParameterDetail{}
625 | 	hasBodyParam := false
626 | 	var bodyParam *spec.Parameter // Declare bodyParam here to be accessible later
627 | 
628 | 	// First pass: Separate body param, process others
629 | 	for _, param := range params {
630 | 		// Skip the API key parameter if it's configured/detected
631 | 		if apiKeyName != "" && param.Name == apiKeyName && (param.In == "query" || param.In == "header") {
632 | 			log.Printf("Parser V2: Skipping API key parameter '%s' ('%s') from input schema generation.", param.Name, param.In)
633 | 			continue
634 | 		}
635 | 
636 | 		if param.In == "body" {
637 | 			if hasBodyParam {
638 | 				return mcp.Schema{}, mcp.Schema{}, nil, fmt.Errorf("v2: multiple 'body' parameters found")
639 | 			}
640 | 			hasBodyParam = true
641 | 			bodyParam = &param // Assign to outer scope variable
642 | 			continue           // Don't process body param further in this loop
643 | 		}
644 | 
645 | 		if param.In != "query" && param.In != "path" && param.In != "header" && param.In != "formData" {
646 | 			log.Printf("Parser V2: Skipping unsupported parameter type '%s' for parameter '%s'", param.In, param.Name)
647 | 			continue
648 | 		}
649 | 
650 | 		// Add non-body param detail
651 | 		opParams = append(opParams, mcp.ParameterDetail{
652 | 			Name: param.Name,
653 | 			In:   param.In, // query, header, path, formData
654 | 		})
655 | 
656 | 		// Convert non-body param schema and add to mcpSchema
657 | 		propSchema, err := swaggerParamToMCPSchema(&param, definitions)
658 | 		if err != nil {
659 | 			return mcp.Schema{}, mcp.Schema{}, nil, fmt.Errorf("v2 param '%s': %w", param.Name, err)
660 | 		}
661 | 		mcpSchema.Properties[param.Name] = propSchema
662 | 		if param.Required {
663 | 			mcpSchema.Required = append(mcpSchema.Required, param.Name)
664 | 		}
665 | 	}
666 | 
667 | 	// Second pass: Process the body parameter if found
668 | 	if bodyParam != nil {
669 | 		bodySchema.Description = bodyParam.Description
670 | 
671 | 		if bodyParam.Schema != nil {
672 | 			// Convert the body schema (resolving $refs)
673 | 			bodySchemaFields, err := swaggerSchemaToMCPSchemaV2(bodyParam.Schema, definitions)
674 | 			if err != nil {
675 | 				return mcp.Schema{}, mcp.Schema{}, nil, fmt.Errorf("v2 request body schema: %w", err)
676 | 			}
677 | 			// Update our local bodySchema with the converted fields
678 | 			bodySchema.Type = bodySchemaFields.Type
679 | 			bodySchema.Properties = bodySchemaFields.Properties
680 | 			bodySchema.Items = bodySchemaFields.Items
681 | 			bodySchema.Format = bodySchemaFields.Format
682 | 			bodySchema.Enum = bodySchemaFields.Enum
683 | 			bodySchema.Required = bodySchemaFields.Required // Required fields from the *schema* itself
684 | 
685 | 			// Merge bodySchema properties into the main mcpSchema
686 | 			if bodySchema.Type == "object" && bodySchema.Properties != nil {
687 | 				for propName, propSchema := range bodySchema.Properties {
688 | 					mcpSchema.Properties[propName] = propSchema
689 | 				}
690 | 				// Merge required fields from the body's schema into the main required list
691 | 				if len(bodySchema.Required) > 0 {
692 | 					mcpSchema.Required = append(mcpSchema.Required, bodySchema.Required...)
693 | 				}
694 | 			} else {
695 | 				// Handle non-object body schema (e.g., array, string)
696 | 				// Add a single property named after the body parameter
697 | 				mcpSchema.Properties[bodyParam.Name] = bodySchemaFields // Use the converted schema
698 | 				if bodyParam.Required {                                 // Check the parameter's required status
699 | 					mcpSchema.Required = append(mcpSchema.Required, bodyParam.Name)
700 | 				}
701 | 			}
702 | 
703 | 		} else {
704 | 			// Body param defined without a schema? Treat as simple string.
705 | 			log.Printf("Warning: V2 body parameter '%s' defined without a schema. Treating as string.", bodyParam.Name)
706 | 			bodySchema.Type = "string"
707 | 			mcpSchema.Properties[bodyParam.Name] = bodySchema
708 | 			if bodyParam.Required {
709 | 				mcpSchema.Required = append(mcpSchema.Required, bodyParam.Name)
710 | 			}
711 | 		}
712 | 
713 | 		// Always add the body parameter to the OperationDetail list
714 | 		opParams = append(opParams, mcp.ParameterDetail{
715 | 			Name: bodyParam.Name,
716 | 			In:   bodyParam.In,
717 | 		})
718 | 	}
719 | 
720 | 	// Sort and deduplicate the final required list
721 | 	if len(mcpSchema.Required) > 1 {
722 | 		sort.Strings(mcpSchema.Required)
723 | 		seen := make(map[string]struct{}, len(mcpSchema.Required))
724 | 		j := 0
725 | 		for _, r := range mcpSchema.Required {
726 | 			if _, ok := seen[r]; !ok {
727 | 				seen[r] = struct{}{}
728 | 				mcpSchema.Required[j] = r
729 | 				j++
730 | 			}
731 | 		}
732 | 		mcpSchema.Required = mcpSchema.Required[:j]
733 | 	}
734 | 
735 | 	return mcpSchema, bodySchema, opParams, nil
736 | }
737 | 
738 | // swaggerParamToMCPSchema converts a V2 Parameter (non-body) to an MCP Schema.
739 | func swaggerParamToMCPSchema(param *spec.Parameter, definitions spec.Definitions) (mcp.Schema, error) {
740 | 	// This needs to handle types like string, integer, array based on param.Type, param.Format, param.Items
741 | 	// Simplified version:
742 | 	mcpSchema := mcp.Schema{
743 | 		Type:        mapJSONSchemaType(param.Type), // Use the same mapping
744 | 		Description: param.Description,
745 | 		Format:      param.Format,
746 | 		Enum:        param.Enum,
747 | 		// TODO: Map items for array type, map constraints (maximum, etc.)
748 | 	}
749 | 	if param.Type == "array" && param.Items != nil {
750 | 		// Need to convert param.Items (which is *spec.Items) to MCP schema
751 | 		itemsSchema, err := swaggerItemsToMCPSchema(param.Items, definitions)
752 | 		if err != nil {
753 | 			return mcp.Schema{}, fmt.Errorf("v2 array param '%s' items: %w", param.Name, err)
754 | 		}
755 | 		mcpSchema.Items = &itemsSchema
756 | 	}
757 | 	return mcpSchema, nil
758 | }
759 | 
760 | // swaggerItemsToMCPSchema converts V2 Items object
761 | func swaggerItemsToMCPSchema(items *spec.Items, definitions spec.Definitions) (mcp.Schema, error) {
762 | 	if items == nil {
763 | 		return mcp.Schema{Type: "string", Description: "nil items"}, nil
764 | 	}
765 | 	// Similar logic to swaggerParamToMCPSchema but for Items structure
766 | 	mcpSchema := mcp.Schema{
767 | 		Type:        mapJSONSchemaType(items.Type),
768 | 		Description: "", // Items don't have descriptions typically
769 | 		Format:      items.Format,
770 | 		Enum:        items.Enum,
771 | 	}
772 | 	if items.Type == "array" && items.Items != nil {
773 | 		subItemsSchema, err := swaggerItemsToMCPSchema(items.Items, definitions)
774 | 		if err != nil {
775 | 			return mcp.Schema{}, fmt.Errorf("v2 nested array items: %w", err)
776 | 		}
777 | 		mcpSchema.Items = &subItemsSchema
778 | 	}
779 | 	// TODO: Handle $ref within items? Not directly supported by spec.Items
780 | 	return mcpSchema, nil
781 | }
782 | 
783 | // swaggerSchemaToMCPSchemaV2 converts a Swagger v2 schema (from definitions or body param) to mcp.Schema
784 | func swaggerSchemaToMCPSchemaV2(oapiSchema *spec.Schema, definitions spec.Definitions) (mcp.Schema, error) {
785 | 	if oapiSchema == nil {
786 | 		return mcp.Schema{Type: "string", Description: "Schema was nil"}, nil
787 | 	}
788 | 
789 | 	// Handle $ref
790 | 	if oapiSchema.Ref.String() != "" {
791 | 		refSchema, err := resolveRefV2(oapiSchema.Ref, definitions)
792 | 		if err != nil {
793 | 			return mcp.Schema{}, err
794 | 		}
795 | 		// Recursively convert the resolved schema, careful with cycles
796 | 		return swaggerSchemaToMCPSchemaV2(refSchema, definitions)
797 | 	}
798 | 
799 | 	var primaryType string
800 | 	if len(oapiSchema.Type) > 0 {
801 | 		primaryType = oapiSchema.Type[0]
802 | 	}
803 | 
804 | 	mcpSchema := mcp.Schema{
805 | 		Type:        mapJSONSchemaType(primaryType),
806 | 		Description: oapiSchema.Description,
807 | 		Format:      oapiSchema.Format,
808 | 		Enum:        oapiSchema.Enum,
809 | 		// TODO: Map V2 constraints (Maximum, Minimum, etc.)
810 | 	}
811 | 
812 | 	switch mcpSchema.Type {
813 | 	case "object":
814 | 		mcpSchema.Properties = make(map[string]mcp.Schema)
815 | 		mcpSchema.Required = oapiSchema.Required
816 | 		for name, propSchema := range oapiSchema.Properties {
817 | 			// propSchema here is spec.Schema, need recursive call
818 | 			propMCPSchema, err := swaggerSchemaToMCPSchemaV2(&propSchema, definitions)
819 | 			if err != nil {
820 | 				return mcp.Schema{}, fmt.Errorf("v2 object property '%s': %w", name, err)
821 | 			}
822 | 			mcpSchema.Properties[name] = propMCPSchema
823 | 		}
824 | 		if len(mcpSchema.Required) > 1 {
825 | 			sort.Strings(mcpSchema.Required)
826 | 		}
827 | 	case "array":
828 | 		if oapiSchema.Items != nil && oapiSchema.Items.Schema != nil {
829 | 			// V2 Items has a single Schema field
830 | 			itemsSchema, err := swaggerSchemaToMCPSchemaV2(oapiSchema.Items.Schema, definitions)
831 | 			if err != nil {
832 | 				return mcp.Schema{}, fmt.Errorf("v2 array items: %w", err)
833 | 			}
834 | 			mcpSchema.Items = &itemsSchema
835 | 		} else if oapiSchema.Items != nil && len(oapiSchema.Items.Schemas) > 0 {
836 | 			// Handle tuple-like arrays (less common, maybe simplify to single type?)
837 | 			// For now, take the first schema
838 | 			itemsSchema, err := swaggerSchemaToMCPSchemaV2(&oapiSchema.Items.Schemas[0], definitions)
839 | 			if err != nil {
840 | 				return mcp.Schema{}, fmt.Errorf("v2 tuple array items: %w", err)
841 | 			}
842 | 			mcpSchema.Items = &itemsSchema
843 | 			mcpSchema.Description += " (Note: original was tuple-like array, showing first type)"
844 | 		}
845 | 	case "string", "number", "integer", "boolean", "null":
846 | 		// Basic types mapped
847 | 	default:
848 | 		if mcpSchema.Type == "string" && primaryType != "" && primaryType != "string" {
849 | 			mcpSchema.Description += fmt.Sprintf(" (Original type '%s' unknown or unsupported)", primaryType)
850 | 		}
851 | 	}
852 | 	return mcpSchema, nil
853 | }
854 | 
855 | func resolveRefV2(ref spec.Ref, definitions spec.Definitions) (*spec.Schema, error) {
856 | 	// Simple local definition resolution
857 | 	refStr := ref.String()
858 | 	if !strings.HasPrefix(refStr, "#/definitions/") {
859 | 		return nil, fmt.Errorf("unsupported $ref format: %s", refStr)
860 | 	}
861 | 	defName := strings.TrimPrefix(refStr, "#/definitions/")
862 | 	schema, ok := definitions[defName]
863 | 	if !ok {
864 | 		return nil, fmt.Errorf("$ref '%s' not found in definitions", refStr)
865 | 	}
866 | 	return &schema, nil
867 | }
868 | 
869 | // --- Common Helper Functions ---
870 | 
871 | func createBaseToolSet(title, desc string, cfg *config.Config) *mcp.ToolSet {
872 | 	// Prioritize config overrides if they are set
873 | 	toolSetName := title // Default to spec title
874 | 	if cfg.DefaultToolName != "" {
875 | 		toolSetName = cfg.DefaultToolName // Use config override if provided
876 | 	}
877 | 
878 | 	toolSetDesc := desc // Default to spec description
879 | 	if cfg.DefaultToolDesc != "" {
880 | 		toolSetDesc = cfg.DefaultToolDesc // Use config override if provided
881 | 	}
882 | 
883 | 	toolSet := &mcp.ToolSet{
884 | 		MCPVersion:  "0.1.0",
885 | 		Name:        toolSetName, // Use determined name
886 | 		Description: toolSetDesc, // Use determined description
887 | 		Tools:       []mcp.Tool{},
888 | 		Operations:  make(map[string]mcp.OperationDetail), // Initialize map
889 | 	}
890 | 
891 | 	// The old overwrite logic is removed as it's handled above
892 | 	// if title != "" {
893 | 	// 	toolSet.Name = title
894 | 	// }
895 | 	// if desc != "" {
896 | 	// 	toolSet.Description = desc
897 | 	// }
898 | 	return toolSet
899 | }
900 | 
901 | // generateDefaultToolName creates a name if operationId is missing.
902 | func generateDefaultToolName(method, path string) string {
903 | 	pathParts := strings.Split(strings.Trim(path, "/"), "/")
904 | 	var nameParts []string
905 | 	nameParts = append(nameParts, strings.ToUpper(method[:1])+strings.ToLower(method[1:]))
906 | 	for _, part := range pathParts {
907 | 		if part == "" {
908 | 			continue
909 | 		}
910 | 		if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
911 | 			paramName := strings.Trim(part, "{}")
912 | 			nameParts = append(nameParts, "By"+strings.ToUpper(paramName[:1])+paramName[1:])
913 | 		} else {
914 | 			sanitizedPart := strings.ReplaceAll(part, "-", "_")
915 | 			sanitizedPart = strings.Title(sanitizedPart) // Basic capitalization
916 | 			nameParts = append(nameParts, sanitizedPart)
917 | 		}
918 | 	}
919 | 	return strings.Join(nameParts, "")
920 | }
921 | 
922 | // shouldInclude determines if an operation should be included based on config filters.
923 | func shouldInclude(opID string, opTags []string, cfg *config.Config) bool {
924 | 	// Exclusion rules take precedence
925 | 	if len(cfg.ExcludeOperations) > 0 && opID != "" && sliceContains(cfg.ExcludeOperations, opID) {
926 | 		return false
927 | 	}
928 | 	if len(cfg.ExcludeTags) > 0 {
929 | 		for _, tag := range opTags {
930 | 			if sliceContains(cfg.ExcludeTags, tag) {
931 | 				return false
932 | 			}
933 | 		}
934 | 	}
935 | 
936 | 	// Inclusion rules
937 | 	hasInclusionRule := len(cfg.IncludeOperations) > 0 || len(cfg.IncludeTags) > 0
938 | 	if !hasInclusionRule {
939 | 		return true
940 | 	} // No inclusion rules, include by default
941 | 
942 | 	if len(cfg.IncludeOperations) > 0 {
943 | 		if opID != "" && sliceContains(cfg.IncludeOperations, opID) {
944 | 			return true
945 | 		}
946 | 	} else if len(cfg.IncludeTags) > 0 {
947 | 		for _, tag := range opTags {
948 | 			if sliceContains(cfg.IncludeTags, tag) {
949 | 				return true
950 | 			}
951 | 		}
952 | 	}
953 | 	return false // Did not match any inclusion rule
954 | }
955 | 
956 | // mapJSONSchemaType ensures the type is one recognized by JSON Schema / MCP.
957 | func mapJSONSchemaType(oapiType string) string {
958 | 	switch strings.ToLower(oapiType) { // Normalize type
959 | 	case "integer", "number", "string", "boolean", "array", "object":
960 | 		return strings.ToLower(oapiType)
961 | 	case "null":
962 | 		return "string" // Represent null as string for MCP?
963 | 	case "file": // Swagger 2.0 specific type
964 | 		return "string" // Represent file uploads as string (e.g., path or content)?
965 | 	default:
966 | 		return "string"
967 | 	}
968 | }
969 | 
970 | // sliceContains checks if a string slice contains a specific string.
971 | func sliceContains(slice []string, item string) bool {
972 | 	for _, s := range slice {
973 | 		if s == item {
974 | 			return true
975 | 		}
976 | 	}
977 | 	return false
978 | }
979 | 
```
Page 1/3FirstPrevNextLast