├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── cmd └── emcee │ └── main.go ├── go.mod ├── go.sum ├── internal ├── http.go ├── secret.go └── secret_test.go ├── jsonrpc ├── error.go ├── id.go ├── request.go ├── response.go └── version.go ├── main_test.go ├── mcp ├── protocol.go ├── server.go ├── server_test.go ├── transport.go └── transport_test.go ├── testdata ├── api.weather.gov │ └── openapi.json └── claude_desktop_config.json └── tools └── install.sh /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | timeout-minutes: 10 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version-file: "go.mod" 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version-file: "go.mod" 23 | 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | distribution: goreleaser 28 | version: "~> v2" 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | GH_PAT: ${{ secrets.GH_PAT }} # used to update Homebrew formula 33 | KO_DOCKER_REPO: ghcr.io/${{ github.repository_owner }}/emcee 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ~/ 2 | /emcee 3 | dist/ 4 | 5 | .DS_Store -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | builds: 10 | - env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - darwin 15 | 16 | main: ./cmd/emcee 17 | 18 | archives: 19 | - formats: [tar.gz] 20 | name_template: >- 21 | {{ .ProjectName }}_ 22 | {{- title .Os }}_ 23 | {{- if eq .Arch "amd64" }}x86_64 24 | {{- else if eq .Arch "386" }}i386 25 | {{- else }}{{ .Arch }}{{ end }} 26 | {{- if .Arm }}v{{ .Arm }}{{ end }} 27 | 28 | brews: 29 | - repository: 30 | owner: loopwork-ai 31 | name: homebrew-tap 32 | token: "{{ .Env.GH_PAT }}" 33 | directory: Formula 34 | 35 | kos: 36 | - platforms: 37 | - linux/amd64 38 | - linux/arm64 39 | tags: 40 | - latest 41 | - '{{ .Tag }}' 42 | bare: true 43 | flags: 44 | - -trimpath 45 | ldflags: 46 | - -s -w 47 | - -extldflags "-static" 48 | - -X main.Version={{.Tag}} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Loopwork Limited 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![emcee flow diagram](https://github.com/user-attachments/assets/bcac98a5-497f-4b34-9e8d-d4bc08852ea1) 2 | 3 | # emcee 4 | 5 | **emcee** is a tool that provides a [Model Context Protocol (MCP)][mcp] server 6 | for any web application with an [OpenAPI][openapi] specification. 7 | You can use emcee to connect [Claude Desktop][claude] and [other apps][mcp-clients] 8 | to external tools and data services, 9 | similar to [ChatGPT plugins][chatgpt-plugins]. 10 | 11 | ## Quickstart 12 | 13 | If you're on macOS and have [Homebrew][homebrew] installed, 14 | you can get up-and-running quickly. 15 | 16 | ```bash 17 | # Install emcee 18 | brew install loopwork-ai/tap/emcee 19 | ``` 20 | 21 | Make sure you have [Claude Desktop](https://claude.ai/download) installed. 22 | 23 | To configure Claude Desktop for use with emcee: 24 | 25 | 1. Open Claude Desktop Settings (,) 26 | 2. Select the "Developer" section in the sidebar 27 | 3. Click "Edit Config" to open the configuration file 28 | 29 | ![Claude Desktop settings Edit Config button](https://github.com/user-attachments/assets/761c6de5-62c2-4c53-83e6-54362040acd5) 30 | 31 | The configuration file should be located in the Application Support directory. 32 | You can also open it directly in VSCode using: 33 | 34 | ```console 35 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 36 | ``` 37 | 38 | Add the following configuration to add the weather.gov MCP server: 39 | 40 | ```json 41 | { 42 | "mcpServers": { 43 | "weather": { 44 | "command": "emcee", 45 | "args": [ 46 | "https://api.weather.gov/openapi.json" 47 | ] 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | After saving the file, quit and re-open Claude. 54 | You should now see 🔨57 in the bottom right corner of your chat box. 55 | Click on that to see a list of all the tools made available to Claude through MCP. 56 | 57 | Start a new chat and ask it about the weather where you are. 58 | 59 | > What's the weather in Portland, OR? 60 | 61 | Claude will consult the tools made available to it through MCP 62 | and request to use one if deemed to be suitable for answering your question. 63 | You can review this request and either approve or deny it. 64 | 65 | Allow tool from weather MCP dialog 66 | 67 | If you allow, Claude will communicate with the MCP 68 | and use the result to inform its response. 69 | 70 | ![Claude response with MCP tool use](https://github.com/user-attachments/assets/d5b63002-1ed3-4b03-bc71-8357427bb06b) 71 | 72 | --- 73 | 74 | > [!TIP] 75 | > Building agents? Want to deploy remote MCP servers? 76 | > Reach out to us at emcee@loopwork.com 77 | 78 | --- 79 | 80 | ## Why use emcee? 81 | 82 | MCP provides a standardized way to connect AI models to tools and data sources. 83 | It's still early days, but there are already a variety of [available servers][mcp-servers] 84 | for connecting to browsers, developer tools, and other systems. 85 | 86 | We think emcee is a convenient way to connect to services 87 | that don't have an existing MCP server implementation — 88 | _especially for services you're building yourself_. 89 | Got a web app with an OpenAPI spec? 90 | You might be surprised how far you can get 91 | without a dashboard or client library. 92 | 93 | ## Installation 94 | 95 | ### Installer Script 96 | 97 | Use the [installer script][installer] to download and install a 98 | [pre-built release][releases] of emcee for your platform 99 | (Linux x86-64/i386/arm64 and macOS Intel/Apple Silicon). 100 | 101 | ```console 102 | # fish 103 | sh (curl -fsSL https://get.emcee.sh | psub) 104 | 105 | # bash, zsh 106 | sh <(curl -fsSL https://get.emcee.sh) 107 | ``` 108 | 109 | ### Homebrew 110 | 111 | Install emcee using [Homebrew][homebrew] from [Loopwork's tap][homebrew-tap]. 112 | 113 | ```console 114 | brew install loopwork-ai/tap/emcee 115 | ``` 116 | 117 | ### Docker 118 | 119 | Prebuilt [Docker images][docker-images] with emcee are available. 120 | 121 | ```console 122 | docker run -it ghcr.io/loopwork-ai/emcee 123 | ``` 124 | 125 | ### Build From Source 126 | 127 | Requires [go 1.24][golang] or later. 128 | 129 | ```console 130 | git clone https://github.com/loopwork-ai/emcee.git 131 | cd emcee 132 | go build -o emcee cmd/emcee/main.go 133 | ``` 134 | 135 | Once built, you can run in place (`./emcee`) 136 | or move it somewhere in your `PATH`, like `/usr/local/bin`. 137 | 138 | ## Usage 139 | 140 | ```console 141 | Usage: 142 | emcee [spec-path-or-url] [flags] 143 | 144 | Flags: 145 | --basic-auth string Basic auth value (either user:pass or base64 encoded, will be prefixed with 'Basic ') 146 | --bearer-auth string Bearer token value (will be prefixed with 'Bearer ') 147 | -h, --help help for emcee 148 | --raw-auth string Raw value for Authorization header 149 | --retries int Maximum number of retries for failed requests (default 3) 150 | -r, --rps int Maximum requests per second (0 for no limit) 151 | -s, --silent Disable all logging 152 | --timeout duration HTTP request timeout (default 1m0s) 153 | -v, --verbose Enable debug level logging to stderr 154 | --version version for emcee 155 | ``` 156 | 157 | emcee implements [Standard Input/Output (stdio)](https://modelcontextprotocol.io/docs/concepts/transports#standard-input-output-stdio) transport for MCP, 158 | which uses [JSON-RPC 2.0](https://www.jsonrpc.org/) as its wire format. 159 | 160 | When you run emcee from the command-line, 161 | it starts a program that listens on stdin, 162 | outputs to stdout, 163 | and logs to stderr. 164 | 165 | ### Authentication 166 | 167 | For APIs that require authentication, 168 | emcee supports several authentication methods: 169 | 170 | | Authentication Type | Example Usage | Resulting Header | 171 | |------------------------|---------------|----------------------------| 172 | | **Bearer Token** | `--bearer-auth="abc123"` | `Authorization: Bearer abc123` | 173 | | **Basic Auth** | `--basic-auth="user:pass"` | `Authorization: Basic dXNlcjpwYXNz` | 174 | | **Raw Value** | `--raw-auth="Custom xyz789"` | `Authorization: Custom xyz789` | 175 | 176 | These authentication values can be provided directly 177 | or as [1Password secret references][secret-reference-syntax]. 178 | 179 | When using 1Password references: 180 | - Use the format `op://vault/item/field` 181 | (e.g. `--bearer-auth="op://Shared/X/credential"`) 182 | - Ensure the 1Password CLI ([op][op]) is installed and available in your `PATH` 183 | - Sign in to 1Password before running emcee or launching Claude Desktop 184 | 185 | ```console 186 | # Install op 187 | brew install 1password-cli 188 | 189 | # Sign in 1Password CLI 190 | op signin 191 | ``` 192 | 193 | ```json 194 | { 195 | "mcpServers": { 196 | "twitter": { 197 | "command": "emcee", 198 | "args": [ 199 | "--bearer-auth=op://shared/x/credential", 200 | "https://api.twitter.com/2/openapi.json" 201 | ] 202 | } 203 | } 204 | } 205 | ``` 206 | 207 | 1Password Access Requested 208 | 209 | > [!IMPORTANT] 210 | > emcee doesn't use auth credentials when downloading 211 | > OpenAPI specifications from URLs provided as command arguments. 212 | > If your OpenAPI specification requires authentication to access, 213 | > first download it to a local file using your preferred HTTP client, 214 | > then provide the local file path to emcee. 215 | 216 | ### Transforming OpenAPI Specifications 217 | 218 | You can transform OpenAPI specifications before passing them to emcee using standard Unix utilities. This is useful for: 219 | - Selecting specific endpoints to expose as tools 220 | with [jq][jq] or [yq][yq] 221 | - Modifying descriptions or parameters 222 | with [OpenAPI Overlays][openapi-overlays] 223 | - Combining multiple specifications 224 | with [Redocly][redocly-cli] 225 | 226 | For example, 227 | you can use `jq` to include only the `point` tool from `weather.gov`. 228 | 229 | ```console 230 | cat path/to/openapi.json | \ 231 | jq 'if .paths then .paths |= with_entries(select(.key == "/points/{point}")) else . end' | \ 232 | emcee 233 | ``` 234 | 235 | 236 | ### JSON-RPC 237 | 238 | You can interact directly with the provided MCP server 239 | by sending JSON-RPC requests. 240 | 241 | > [!NOTE] 242 | > emcee provides only MCP tool capabilities. 243 | > Other features like resources, prompts, and sampling aren't yet supported. 244 | 245 | #### List Tools 246 | 247 |
248 | 249 | Request 250 | 251 | ```json 252 | {"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1} 253 | ``` 254 | 255 |
256 | 257 |
258 | 259 | Response 260 | 261 | ```jsonc 262 | { 263 | "jsonrpc":"2.0", 264 | "result": { 265 | "tools": [ 266 | // ... 267 | { 268 | "name": "tafs", 269 | "description": "Returns Terminal Aerodrome Forecasts for the specified airport station.", 270 | "inputSchema": { 271 | "type": "object", 272 | "properties": { 273 | "stationId": { 274 | "description": "Observation station ID", 275 | "type": "string" 276 | } 277 | }, 278 | "required": ["stationId"] 279 | } 280 | }, 281 | // ... 282 | ] 283 | }, 284 | "id": 1 285 | } 286 | ``` 287 |
288 | 289 | #### Call Tool 290 | 291 |
292 | 293 | Request 294 | 295 | ```json 296 | {"jsonrpc": "2.0", "method": "tools/call", "params": { "name": "taf", "arguments": { "stationId": "KPDX" }}, "id": 1} 297 | ``` 298 | 299 |
300 | 301 |
302 | 303 | Response 304 | 305 | ```jsonc 306 | { 307 | "jsonrpc":"2.0", 308 | "content": [ 309 | { 310 | "type": "text", 311 | "text": /* Weather forecast in GeoJSON format */, 312 | "annotations": { 313 | "audience": ["assistant"] 314 | } 315 | } 316 | ] 317 | "id": 1 318 | } 319 | ``` 320 | 321 |
322 | 323 | ## Debugging 324 | 325 | The [MCP Inspector][mcp-inspector] is a tool for testing and debugging MCP servers. 326 | If Claude and/or emcee aren't working as expected, 327 | the inspector can help you understand what's happening. 328 | 329 | ```console 330 | npx @modelcontextprotocol/inspector emcee https://api.weather.gov/openapi.json 331 | # 🔍 MCP Inspector is up and running at http://localhost:5173 🚀 332 | ``` 333 | 334 | ```console 335 | open http://localhost:5173 336 | ``` 337 | 338 | ## License 339 | 340 | emcee is licensed under the Apache License, Version 2.0. 341 | 342 | [chatgpt-plugins]: https://openai.com/index/chatgpt-plugins/ 343 | [claude]: https://claude.ai/download 344 | [docker-images]: https://github.com/loopwork-ai/emcee/pkgs/container/emcee 345 | [golang]: https://go.dev 346 | [homebrew]: https://brew.sh 347 | [homebrew-tap]: https://github.com/loopwork-ai/homebrew-tap 348 | [installer]: https://github.com/loopwork-ai/emcee/blob/main/tools/install.sh 349 | [jq]: https://github.com/jqlang/jq 350 | [mcp]: https://modelcontextprotocol.io/ 351 | [mcp-clients]: https://modelcontextprotocol.info/docs/clients/ 352 | [mcp-inspector]: https://github.com/modelcontextprotocol/inspector 353 | [mcp-servers]: https://modelcontextprotocol.io/examples 354 | [op]: https://developer.1password.com/docs/cli/get-started/ 355 | [openapi]: https://openapi.org 356 | [openapi-overlays]: https://www.openapis.org/blog/2024/10/22/announcing-overlay-specification 357 | [redocly-cli]: https://redocly.com/docs/cli/commands 358 | [releases]: https://github.com/loopwork-ai/emcee/releases 359 | [secret-reference-syntax]: https://developer.1password.com/docs/cli/secret-reference-syntax/ 360 | [yq]: https://github.com/mikefarah/yq -------------------------------------------------------------------------------- /cmd/emcee/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "path/filepath" 12 | "strings" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/spf13/cobra" 17 | "golang.org/x/sync/errgroup" 18 | 19 | "github.com/loopwork-ai/emcee/internal" 20 | "github.com/loopwork-ai/emcee/mcp" 21 | ) 22 | 23 | var rootCmd = &cobra.Command{ 24 | Use: "emcee [spec-path-or-url]", 25 | Short: "Creates an MCP server for an OpenAPI specification", 26 | Long: `emcee is a CLI tool that provides an Model Context Protocol (MCP) stdio transport for a given OpenAPI specification. 27 | It takes an OpenAPI specification path or URL as input and processes JSON-RPC requests from stdin, making corresponding API calls and returning JSON-RPC responses to stdout. 28 | 29 | The spec-path-or-url argument can be: 30 | - A local file path (e.g. ./openapi.json) 31 | - An HTTP(S) URL (e.g. https://api.example.com/openapi.json) 32 | - "-" to read from stdin 33 | 34 | By default, a GET request with no additional headers is made to the spec URL to download the OpenAPI specification. 35 | 36 | If additional authentication is required to download the specification, you can first download it to a local file using your preferred HTTP client with the necessary authentication headers, and then provide the local file path to emcee. 37 | 38 | Authentication values can be provided directly or as 1Password secret references (e.g. op://vault/item/field). When using 1Password references: 39 | - The 1Password CLI (op) must be installed and available in your PATH 40 | - You must be signed in to 1Password 41 | - The reference must be in the format op://vault/item/field 42 | - The secret will be securely retrieved at runtime using the 1Password CLI 43 | `, 44 | Args: cobra.ExactArgs(1), 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | // Set up context and signal handling 47 | ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) 48 | defer cancel() 49 | 50 | // Set up error group 51 | g, ctx := errgroup.WithContext(ctx) 52 | 53 | // Set up logger 54 | var logger *slog.Logger 55 | switch { 56 | case silent: 57 | logger = slog.New(slog.NewTextHandler(io.Discard, nil)) 58 | case verbose: 59 | logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 60 | Level: slog.LevelDebug, 61 | })) 62 | default: 63 | logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 64 | Level: slog.LevelInfo, 65 | })) 66 | } 67 | 68 | g.Go(func() error { 69 | var opts []mcp.ServerOption 70 | 71 | // Set server info 72 | opts = append(opts, mcp.WithServerInfo(cmd.Name(), version)) 73 | 74 | // Set logger 75 | opts = append(opts, mcp.WithLogger(logger)) 76 | 77 | // Set default headers if auth is provided 78 | if bearerAuth != "" { 79 | resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, bearerAuth) 80 | if err != nil { 81 | return fmt.Errorf("error resolving bearer auth: %w", err) 82 | } 83 | if wasSecret { 84 | logger.Debug("resolved bearer auth from 1Password") 85 | } 86 | opts = append(opts, mcp.WithAuth("Bearer "+resolvedAuth)) 87 | } else if basicAuth != "" { 88 | resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, basicAuth) 89 | if err != nil { 90 | return fmt.Errorf("error resolving basic auth: %w", err) 91 | } 92 | if wasSecret { 93 | logger.Debug("resolved basic auth from 1Password") 94 | } 95 | // Check if already base64 encoded 96 | if strings.Contains(resolvedAuth, ":") { 97 | encoded := base64.StdEncoding.EncodeToString([]byte(resolvedAuth)) 98 | opts = append(opts, mcp.WithAuth("Basic "+encoded)) 99 | } else { 100 | // Assume it's already base64 encoded 101 | opts = append(opts, mcp.WithAuth("Basic "+resolvedAuth)) 102 | } 103 | } else if rawAuth != "" { 104 | resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, rawAuth) 105 | if err != nil { 106 | return fmt.Errorf("error resolving raw auth: %w", err) 107 | } 108 | if wasSecret { 109 | logger.Debug("resolved raw auth from 1Password") 110 | } 111 | opts = append(opts, mcp.WithAuth(resolvedAuth)) 112 | } 113 | 114 | // Set HTTP client 115 | client, err := internal.RetryableClient(retries, timeout, rps, logger) 116 | if err != nil { 117 | return fmt.Errorf("error creating client: %w", err) 118 | } 119 | opts = append(opts, mcp.WithClient(client)) 120 | 121 | // Read OpenAPI specification data 122 | var rpcInput io.Reader = os.Stdin 123 | var specData []byte 124 | if args[0] == "-" { 125 | logger.Info("reading spec from stdin") 126 | 127 | // When reading the OpenAPI spec from stdin, we need to read RPC input from /dev/tty 128 | // since stdin is being used for the spec data and isn't available for interactive I/O 129 | tty, err := os.Open("/dev/tty") 130 | if err != nil { 131 | return fmt.Errorf("error opening /dev/tty: %w", err) 132 | } 133 | defer tty.Close() 134 | rpcInput = tty 135 | 136 | // Read spec from stdin 137 | specData, err = io.ReadAll(os.Stdin) 138 | if err != nil { 139 | return fmt.Errorf("error reading OpenAPI spec from stdin: %w", err) 140 | } 141 | } else if strings.HasPrefix(args[0], "http://") || strings.HasPrefix(args[0], "https://") { 142 | logger.Info("reading spec from URL", "url", args[0]) 143 | 144 | // Create HTTP request 145 | req, err := http.NewRequest(http.MethodGet, args[0], nil) 146 | if err != nil { 147 | return fmt.Errorf("error creating request: %w", err) 148 | } 149 | 150 | // Make HTTP request 151 | resp, err := client.Do(req) 152 | if err != nil { 153 | return fmt.Errorf("error downloading spec: %w", err) 154 | } 155 | if resp.Body == nil { 156 | return fmt.Errorf("no response body from %s", args[0]) 157 | } 158 | defer resp.Body.Close() 159 | 160 | // Read spec from response body 161 | specData, err = io.ReadAll(resp.Body) 162 | if err != nil { 163 | return fmt.Errorf("error reading spec from %s: %w", args[0], err) 164 | } 165 | } else { 166 | logger.Info("reading spec from file", "file", args[0]) 167 | 168 | // Clean the file path to remove any . or .. segments and ensure consistent separators 169 | cleanPath := filepath.Clean(args[0]) 170 | 171 | // Check if file exists and is readable before attempting to read 172 | info, err := os.Stat(cleanPath) 173 | if err != nil { 174 | if os.IsNotExist(err) { 175 | return fmt.Errorf("spec file does not exist: %s", cleanPath) 176 | } 177 | return fmt.Errorf("error accessing spec file %s: %w", cleanPath, err) 178 | } 179 | 180 | // Ensure it's a regular file, not a directory 181 | if info.IsDir() { 182 | return fmt.Errorf("specified path is a directory, not a file: %s", cleanPath) 183 | } 184 | 185 | // Check file size to prevent loading extremely large files 186 | if info.Size() > 100*1024*1024 { // 100MB limit 187 | return fmt.Errorf("spec file too large (max 100MB): %s", cleanPath) 188 | } 189 | 190 | // Read spec from file 191 | specData, err = os.ReadFile(cleanPath) 192 | if err != nil { 193 | return fmt.Errorf("error reading spec file %s: %w", cleanPath, err) 194 | } 195 | } 196 | 197 | // Set spec data 198 | opts = append(opts, mcp.WithSpecData(specData)) 199 | 200 | // Create server 201 | server, err := mcp.NewServer(opts...) 202 | if err != nil { 203 | return fmt.Errorf("error creating server: %w", err) 204 | } 205 | 206 | // Create and run transport 207 | transport := mcp.NewStdioTransport(rpcInput, os.Stdout, os.Stderr) 208 | return transport.Run(ctx, server.HandleRequest) 209 | }) 210 | 211 | return g.Wait() 212 | }, 213 | } 214 | 215 | var ( 216 | bearerAuth string 217 | basicAuth string 218 | rawAuth string 219 | 220 | retries int 221 | timeout time.Duration 222 | rps int 223 | 224 | verbose bool 225 | silent bool 226 | 227 | version = "dev" 228 | commit = "none" 229 | date = "unknown" 230 | ) 231 | 232 | func init() { 233 | rootCmd.Flags().StringVar(&bearerAuth, "bearer-auth", "", "Bearer token value (will be prefixed with 'Bearer ')") 234 | rootCmd.Flags().StringVar(&basicAuth, "basic-auth", "", "Basic auth value (either user:pass or base64 encoded, will be prefixed with 'Basic ')") 235 | rootCmd.Flags().StringVar(&rawAuth, "raw-auth", "", "Raw value for Authorization header") 236 | rootCmd.MarkFlagsMutuallyExclusive("bearer-auth", "basic-auth", "raw-auth") 237 | 238 | rootCmd.Flags().IntVar(&retries, "retries", 3, "Maximum number of retries for failed requests") 239 | rootCmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "HTTP request timeout") 240 | rootCmd.Flags().IntVarP(&rps, "rps", "r", 0, "Maximum requests per second (0 for no limit)") 241 | 242 | rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug level logging to stderr") 243 | rootCmd.Flags().BoolVarP(&silent, "silent", "s", false, "Disable all logging") 244 | rootCmd.MarkFlagsMutuallyExclusive("verbose", "silent") 245 | 246 | rootCmd.Version = fmt.Sprintf("%s (commit: %s, built at: %s)", version, commit, date) 247 | } 248 | 249 | func main() { 250 | if err := rootCmd.Execute(); err != nil { 251 | fmt.Fprintln(os.Stderr, err) 252 | os.Exit(1) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/loopwork-ai/emcee 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/hashicorp/go-retryablehttp v0.7.7 7 | github.com/pb33f/libopenapi v0.20.0 8 | github.com/spf13/cobra v1.8.1 9 | github.com/stretchr/testify v1.10.0 10 | golang.org/x/sync v0.10.0 11 | golang.org/x/sys v0.29.0 12 | ) 13 | 14 | require ( 15 | github.com/bahlo/generic-list-go v0.2.0 // indirect 16 | github.com/buger/jsonparser v1.1.1 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/mailru/easyjson v0.9.0 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/speakeasy-api/jsonpath v0.4.1 // indirect 23 | github.com/spf13/pflag v1.0.5 // indirect 24 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect 25 | gopkg.in/yaml.v3 v3.0.1 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 9 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 10 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 11 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 12 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 13 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 14 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 15 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 16 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 17 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 18 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 19 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 20 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 21 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 22 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 23 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 24 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 25 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 26 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 27 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 28 | github.com/pb33f/libopenapi v0.20.0 h1:UX/RUPfHu1ymNp1Xt65KQyrI+bqQJgJ71Zg6Hvrhkns= 29 | github.com/pb33f/libopenapi v0.20.0/go.mod h1:Bb6gRAm8kxCXiMUnIpPpqWVgaPSUiAU3RTAg6nLOgqM= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 33 | github.com/speakeasy-api/jsonpath v0.4.1 h1:hlU06CfNqImaMtiJdR+mBFnECZhFYlT4ZQ2e4yy5cRA= 34 | github.com/speakeasy-api/jsonpath v0.4.1/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= 35 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 36 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 37 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 38 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 39 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 40 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 41 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd h1:dLuIF2kX9c+KknGJUdJi1Il1SDiTSK158/BB9kdgAew= 42 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo= 43 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 44 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 45 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 46 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 49 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | -------------------------------------------------------------------------------- /internal/http.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/hashicorp/go-retryablehttp" 9 | ) 10 | 11 | // HeaderTransport is a custom RoundTripper that adds default headers to requests 12 | type HeaderTransport struct { 13 | Base http.RoundTripper 14 | Headers http.Header 15 | } 16 | 17 | // RoundTrip adds the default headers to the request 18 | func (t *HeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { 19 | for key, values := range t.Headers { 20 | for _, value := range values { 21 | req.Header.Add(key, value) 22 | } 23 | } 24 | base := t.Base 25 | if base == nil { 26 | base = http.DefaultTransport 27 | } 28 | return base.RoundTrip(req) 29 | } 30 | 31 | // RetryableClient returns a new http.Client with a retryablehttp.Client 32 | // configured with the provided parameters. 33 | func RetryableClient(retries int, timeout time.Duration, rps int, logger interface{}) (*http.Client, error) { 34 | if retries < 0 { 35 | return nil, fmt.Errorf("retries must be greater than 0") 36 | } 37 | if timeout < 0 { 38 | return nil, fmt.Errorf("timeout must be greater than 0") 39 | } 40 | if rps < 0 { 41 | return nil, fmt.Errorf("rps must be greater than 0") 42 | } 43 | 44 | retryClient := retryablehttp.NewClient() 45 | retryClient.RetryMax = retries 46 | retryClient.RetryWaitMin = 1 * time.Second 47 | retryClient.RetryWaitMax = 30 * time.Second 48 | retryClient.HTTPClient.Timeout = timeout 49 | retryClient.Logger = logger 50 | if rps > 0 { 51 | retryClient.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration { 52 | // Ensure we wait at least 1/rps between requests 53 | minWait := time.Second / time.Duration(rps) 54 | if min < minWait { 55 | min = minWait 56 | } 57 | return retryablehttp.DefaultBackoff(min, max, attemptNum, resp) 58 | } 59 | } 60 | 61 | return retryClient.StandardClient(), nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/secret.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | // Command is a variable that allows overriding the command creation for testing 13 | CommandContext = exec.CommandContext 14 | // LookPath is a variable that allows overriding the lookup behavior for testing 15 | LookPath = exec.LookPath 16 | ) 17 | 18 | // ResolveSecretReference attempts to resolve a 1Password secret reference (e.g. op://vault/item/field) 19 | // Returns the resolved value and whether it was a secret reference 20 | func ResolveSecretReference(ctx context.Context, value string) (string, bool, error) { 21 | if !strings.HasPrefix(value, "op://") { 22 | return value, false, nil 23 | } 24 | 25 | // Check if op CLI is available 26 | if _, err := LookPath("op"); err != nil { 27 | return "", true, fmt.Errorf("1Password CLI (op) not found in PATH: %w", err) 28 | } 29 | 30 | // Create command to read secret 31 | cmd := CommandContext(ctx, "op", "read", value) 32 | output, err := cmd.Output() 33 | if err != nil { 34 | var exitErr *exec.ExitError 35 | if errors.As(err, &exitErr) { 36 | return "", true, fmt.Errorf("failed to read secret from 1Password: %s", string(exitErr.Stderr)) 37 | } 38 | return "", true, fmt.Errorf("failed to read secret from 1Password: %w", err) 39 | } 40 | 41 | // Trim any whitespace/newlines from the output 42 | return strings.TrimSpace(string(output)), true, nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/secret_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "testing" 7 | ) 8 | 9 | func TestResolveSecretReference(t *testing.T) { 10 | // Save the original functions and restore them after the test 11 | originalCommand := CommandContext 12 | originalLookPath := LookPath 13 | t.Cleanup(func() { 14 | CommandContext = originalCommand 15 | LookPath = originalLookPath 16 | }) 17 | 18 | tests := []struct { 19 | name string 20 | input string 21 | mockCommandContext func(ctx context.Context, name string, args ...string) *exec.Cmd 22 | mockLookPath func(string) (string, error) 23 | wantValue string 24 | wantSecret bool 25 | wantErr bool 26 | }{ 27 | { 28 | name: "non-secret value", 29 | input: "regular-value", 30 | wantValue: "regular-value", 31 | wantSecret: false, 32 | }, 33 | { 34 | name: "successful secret resolution", 35 | input: "op://vault/item/field", 36 | mockLookPath: func(string) (string, error) { 37 | return "/usr/local/bin/op", nil 38 | }, 39 | mockCommandContext: func(ctx context.Context, name string, args ...string) *exec.Cmd { 40 | return exec.CommandContext(ctx, "echo", "secret-value") 41 | }, 42 | wantValue: "secret-value", 43 | wantSecret: true, 44 | }, 45 | { 46 | name: "op CLI not found", 47 | input: "op://vault/item/field", 48 | mockLookPath: func(string) (string, error) { 49 | return "", exec.ErrNotFound 50 | }, 51 | wantValue: "", 52 | wantSecret: true, 53 | wantErr: true, 54 | }, 55 | { 56 | name: "op command execution failed", 57 | input: "op://vault/item/field", 58 | mockLookPath: func(string) (string, error) { 59 | return "/usr/local/bin/op", nil 60 | }, 61 | mockCommandContext: func(ctx context.Context, name string, args ...string) *exec.Cmd { 62 | // Return a command that will fail 63 | return exec.CommandContext(ctx, "false") 64 | }, 65 | wantValue: "", 66 | wantSecret: true, 67 | wantErr: true, 68 | }, 69 | { 70 | name: "empty input", 71 | input: "", 72 | wantValue: "", 73 | wantSecret: false, 74 | }, 75 | { 76 | name: "malformed op reference", 77 | input: "op://invalid", 78 | wantValue: "", 79 | wantSecret: true, 80 | wantErr: true, 81 | }, 82 | } 83 | 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | if tt.mockCommandContext != nil { 87 | CommandContext = tt.mockCommandContext 88 | } 89 | if tt.mockLookPath != nil { 90 | LookPath = tt.mockLookPath 91 | } 92 | 93 | got, isSecret, err := ResolveSecretReference(context.Background(), tt.input) 94 | if (err != nil) != tt.wantErr { 95 | t.Errorf("ResolveSecretReference() error = %v, wantErr %v", err, tt.wantErr) 96 | return 97 | } 98 | if got != tt.wantValue { 99 | t.Errorf("ResolveSecretReference() got = %v, want %v", got, tt.wantValue) 100 | } 101 | if isSecret != tt.wantSecret { 102 | t.Errorf("ResolveSecretReference() isSecret = %v, want %v", isSecret, tt.wantSecret) 103 | } 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /jsonrpc/error.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // ErrorCode represents a JSON-RPC error code 8 | type ErrorCode int 9 | 10 | // JSON-RPC 2.0 error codes as defined in https://www.jsonrpc.org/specification 11 | const ( 12 | // Parse error (-32700) 13 | // Invalid JSON was received by the server. 14 | // An error occurred on the server while parsing the JSON text. 15 | ErrParse ErrorCode = -32700 16 | 17 | // Invalid Request (-32600) 18 | // The JSON sent is not a valid Request object. 19 | ErrInvalidRequest ErrorCode = -32600 20 | 21 | // Method not found (-32601) 22 | // The method does not exist / is not available. 23 | ErrMethodNotFound ErrorCode = -32601 24 | 25 | // Invalid params (-32602) 26 | // Invalid method parameter(s). 27 | ErrInvalidParams ErrorCode = -32602 28 | 29 | // Internal error (-32603) 30 | // Internal JSON-RPC error. 31 | ErrInternal ErrorCode = -32603 32 | 33 | // Server error (-32000 to -32099) 34 | // Reserved for implementation-defined server-errors. 35 | ErrServer ErrorCode = -32000 36 | ) 37 | 38 | // errorDetails maps error codes to their standard messages 39 | var errorDetails = map[ErrorCode]string{ 40 | ErrParse: "Parse error", 41 | ErrInvalidRequest: "Invalid Request", 42 | ErrMethodNotFound: "Method not found", 43 | ErrInvalidParams: "Invalid params", 44 | ErrInternal: "Internal error", 45 | ErrServer: "Server error", 46 | } 47 | 48 | // Error represents a JSON-RPC error object 49 | type Error struct { 50 | Code ErrorCode `json:"code"` 51 | Message string `json:"message"` 52 | Data interface{} `json:"data,omitempty"` 53 | } 54 | 55 | var _ error = &Error{} 56 | 57 | func (e *Error) Error() string { 58 | return fmt.Sprintf("%d: %s", e.Code, e.Message) 59 | } 60 | 61 | // NewError creates a new JSON-RPC error with the given code and optional data 62 | func NewError(code ErrorCode, data interface{}) *Error { 63 | msg, ok := errorDetails[code] 64 | if !ok { 65 | if code >= -32099 && code <= -32000 { 66 | msg = "Server error" 67 | } else { 68 | msg = "Unknown error" 69 | } 70 | } 71 | 72 | return &Error{ 73 | Code: code, 74 | Message: msg, 75 | Data: data, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /jsonrpc/id.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // ID represents a JSON-RPC ID which must be either a string or number 9 | type ID struct { 10 | value interface{} 11 | } 12 | 13 | // NewID creates a JSON-RPC ID from a string or number 14 | func NewID(id interface{}) (ID, error) { 15 | switch v := id.(type) { 16 | case ID: 17 | return v, nil 18 | case string: 19 | return ID{value: v}, nil 20 | case int, int32, int64, float32, float64: 21 | return ID{value: v}, nil 22 | case nil: 23 | return ID{}, fmt.Errorf("id cannot be null") 24 | default: 25 | return ID{}, fmt.Errorf("id must be string or number, got %T", id) 26 | } 27 | } 28 | 29 | func (id ID) Value() interface{} { 30 | return id.value 31 | } 32 | 33 | func (id ID) IsNil() bool { 34 | return id.value == nil 35 | } 36 | 37 | // Equal compares two IDs for equality 38 | func (id ID) Equal(other interface{}) bool { 39 | // If comparing with raw value 40 | switch v := other.(type) { 41 | case string, int, int32, int64, float32, float64: 42 | return id.value == v 43 | case ID: 44 | return id.value == v.value 45 | default: 46 | return false 47 | } 48 | } 49 | 50 | var _ fmt.GoStringer = ID{} 51 | 52 | // GoString implements fmt.GoStringer 53 | func (id ID) GoString() string { 54 | switch v := id.value.(type) { 55 | case string: 56 | return fmt.Sprintf("%q", v) 57 | case float64, float32: 58 | return fmt.Sprintf("%g", v) 59 | case int, int32, int64: 60 | return fmt.Sprintf("%d", v) 61 | case nil: 62 | return "nil" 63 | default: 64 | return fmt.Sprintf("%v", v) 65 | } 66 | } 67 | 68 | var _ json.Marshaler = ID{} 69 | 70 | func (id ID) MarshalJSON() ([]byte, error) { 71 | switch id.value { 72 | case nil: 73 | return json.Marshal(0) 74 | default: 75 | return json.Marshal(id.value) 76 | } 77 | } 78 | 79 | var _ json.Unmarshaler = &ID{} 80 | 81 | // UnmarshalJSON implements json.Unmarshaler 82 | func (id *ID) UnmarshalJSON(data []byte) error { 83 | var raw interface{} 84 | if err := json.Unmarshal(data, &raw); err != nil { 85 | return err 86 | } 87 | 88 | switch v := raw.(type) { 89 | case string: 90 | id.value = v 91 | return nil 92 | case float64: // JSON numbers are decoded as float64 93 | id.value = int(v) 94 | return nil 95 | case nil: 96 | return fmt.Errorf("id cannot be null") 97 | default: 98 | return fmt.Errorf("id must be string or number, got %T", raw) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /jsonrpc/request.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import "encoding/json" 4 | 5 | // Request represents a JSON-RPC request object 6 | type Request struct { 7 | Version string `json:"jsonrpc"` 8 | Method string `json:"method"` 9 | Params json.RawMessage `json:"params,omitempty"` 10 | ID ID `json:"id"` 11 | } 12 | 13 | // NewRequest creates a new Request object 14 | func NewRequest(method string, params json.RawMessage, id interface{}) Request { 15 | reqID, _ := NewID(id) 16 | 17 | return Request{ 18 | Version: Version, 19 | Method: method, 20 | Params: params, 21 | ID: reqID, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /jsonrpc/response.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | // Result represents a map of string keys to arbitrary values 4 | type Result interface{} 5 | 6 | // Response represents a JSON-RPC response object 7 | type Response struct { 8 | Version string `json:"jsonrpc"` 9 | Result Result `json:"result,omitempty"` 10 | Error *Error `json:"error,omitempty"` 11 | ID ID `json:"id"` 12 | } 13 | 14 | // NewResponse creates a new Response object 15 | func NewResponse(id interface{}, result Result, err *Error) Response { 16 | respID, _ := NewID(id) 17 | 18 | return Response{ 19 | Version: Version, 20 | ID: respID, 21 | Result: result, 22 | Error: err, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jsonrpc/version.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | const ( 4 | Version = "2.0" // JSON-RPC protocol version 5 | ) 6 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestIntegration(t *testing.T) { 16 | // Build the emcee binary for testing 17 | tmpDir := t.TempDir() 18 | binaryPath := filepath.Join(tmpDir, "emcee") 19 | buildCmd := exec.Command("go", "build", "-o", binaryPath, "cmd/emcee/main.go") 20 | require.NoError(t, buildCmd.Run(), "Failed to build emcee binary") 21 | 22 | // Start emcee with the embedded test OpenAPI spec 23 | specPath := "testdata/api.weather.gov/openapi.json" 24 | cmd := exec.Command(binaryPath, specPath) 25 | stdin, err := cmd.StdinPipe() 26 | require.NoError(t, err) 27 | stdout, err := cmd.StdoutPipe() 28 | require.NoError(t, err) 29 | 30 | err = cmd.Start() 31 | require.NoError(t, err) 32 | 33 | // Ensure cleanup 34 | defer func() { 35 | stdin.Close() 36 | cmd.Process.Kill() 37 | cmd.Wait() 38 | }() 39 | 40 | // Give the process a moment to initialize 41 | time.Sleep(100 * time.Millisecond) 42 | 43 | // Prepare and send JSON-RPC request 44 | request := map[string]interface{}{ 45 | "jsonrpc": "2.0", 46 | "method": "tools/list", 47 | "params": map[string]interface{}{}, 48 | "id": 1, 49 | } 50 | 51 | requestJSON, err := json.Marshal(request) 52 | require.NoError(t, err) 53 | requestJSON = append(requestJSON, '\n') 54 | 55 | _, err = stdin.Write(requestJSON) 56 | require.NoError(t, err) 57 | 58 | // Read response using a scanner 59 | scanner := bufio.NewScanner(stdout) 60 | require.True(t, scanner.Scan(), "Expected to read a response line") 61 | 62 | var response struct { 63 | JSONRPC string `json:"jsonrpc"` 64 | Result struct { 65 | Tools []struct { 66 | Name string `json:"name"` 67 | Description string `json:"description"` 68 | InputSchema json.RawMessage `json:"inputSchema"` 69 | } `json:"tools"` 70 | } `json:"result"` 71 | ID int `json:"id"` 72 | } 73 | 74 | err = json.Unmarshal(scanner.Bytes(), &response) 75 | require.NoError(t, err, "Failed to parse JSON response") 76 | 77 | // Verify response 78 | assert.Equal(t, "2.0", response.JSONRPC) 79 | assert.Equal(t, 1, response.ID) 80 | assert.NotEmpty(t, response.Result.Tools, "Expected at least one tool in response") 81 | 82 | // Find and verify the point tool 83 | var pointTool struct { 84 | Name string 85 | Description string 86 | InputSchema struct { 87 | Type string `json:"type"` 88 | Properties map[string]interface{} `json:"properties"` 89 | Required []string `json:"required"` 90 | } 91 | } 92 | 93 | foundPointTool := false 94 | for _, tool := range response.Result.Tools { 95 | if tool.Name == "point" { 96 | foundPointTool = true 97 | err := json.Unmarshal(tool.InputSchema, &pointTool.InputSchema) 98 | require.NoError(t, err) 99 | pointTool.Name = tool.Name 100 | pointTool.Description = tool.Description 101 | break 102 | } 103 | } 104 | 105 | require.True(t, foundPointTool, "Expected to find point tool") 106 | assert.Equal(t, "point", pointTool.Name) 107 | assert.Contains(t, pointTool.Description, "Returns metadata about a given latitude/longitude point") 108 | 109 | // Verify point tool has proper parameter schema 110 | assert.Equal(t, "object", pointTool.InputSchema.Type) 111 | assert.Contains(t, pointTool.InputSchema.Properties, "point", "Point tool should have 'point' parameter") 112 | 113 | pointParam := pointTool.InputSchema.Properties["point"].(map[string]interface{}) 114 | assert.Equal(t, "string", pointParam["type"]) 115 | assert.Contains(t, pointParam["description"].(string), "Point (latitude, longitude)") 116 | assert.Contains(t, pointTool.InputSchema.Required, "point", "Point parameter should be required") 117 | 118 | var zoneTool struct { 119 | Name string 120 | Description string 121 | InputSchema struct { 122 | Type string `json:"type"` 123 | Properties map[string]interface{} `json:"properties"` 124 | Required []string `json:"required"` 125 | } 126 | } 127 | 128 | foundZoneTool := false 129 | for _, tool := range response.Result.Tools { 130 | if tool.Name == "zone" { 131 | foundZoneTool = true 132 | err := json.Unmarshal(tool.InputSchema, &zoneTool.InputSchema) 133 | require.NoError(t, err) 134 | zoneTool.Name = tool.Name 135 | zoneTool.Description = tool.Description 136 | break 137 | } 138 | } 139 | 140 | require.True(t, foundZoneTool, "Expected to find zone tool") 141 | assert.Equal(t, "zone", zoneTool.Name) 142 | assert.Contains(t, zoneTool.Description, "Returns metadata about a given zone") 143 | 144 | // Verify zone tool has proper parameter schema 145 | assert.Equal(t, "object", zoneTool.InputSchema.Type) 146 | assert.Contains(t, zoneTool.InputSchema.Properties, "zoneId", "Zone tool should have 'zoneId' parameter") 147 | 148 | typeParam := zoneTool.InputSchema.Properties["type"].(map[string]interface{}) 149 | assert.Equal(t, "string", typeParam["type"]) 150 | assert.Contains(t, typeParam["description"].(string), "Zone type") 151 | assert.Contains(t, typeParam["description"].(string), "Allowed values: land, marine, ") 152 | assert.Contains(t, zoneTool.InputSchema.Required, "type", "type parameter should be required") 153 | 154 | zoneIdParam := zoneTool.InputSchema.Properties["zoneId"].(map[string]interface{}) 155 | assert.Equal(t, "string", zoneIdParam["type"]) 156 | assert.Contains(t, zoneIdParam["description"].(string), "NWS public zone/county identifier") 157 | assert.Contains(t, zoneIdParam["description"].(string), "UGC identifier for a NWS") 158 | assert.Contains(t, zoneTool.InputSchema.Required, "zoneId", "zoneId parameter should be required") 159 | } 160 | -------------------------------------------------------------------------------- /mcp/protocol.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | // Version is the Model Context Protocol version 4 | const Version = "2024-11-05" 5 | 6 | // Role represents the sender or recipient of messages and data in a conversation 7 | type Role string 8 | 9 | const ( 10 | // RoleUser represents the user 11 | RoleUser Role = "user" 12 | 13 | // RoleAssistant represents the assistant 14 | RoleAssistant Role = "assistant" 15 | ) 16 | 17 | // Content types 18 | type ( 19 | // Annotations represents optional annotations for objects 20 | Annotations struct { 21 | // Describes who the intended customer of this object or data is 22 | Audience []Role `json:"audience,omitempty"` 23 | // Describes how important this data is for operating the server (0-1) 24 | Priority *float64 `json:"priority,omitempty"` 25 | } 26 | 27 | // Content represents the base content type 28 | Content struct { 29 | Type string `json:"type"` 30 | Text string `json:"text,omitempty"` 31 | Data string `json:"data,omitempty"` 32 | MimeType string `json:"mimeType,omitempty"` 33 | Annotations *Annotations `json:"annotations,omitempty"` 34 | } 35 | ) 36 | 37 | // NewTextContent creates a new TextContent with the given text and optional annotations 38 | func NewTextContent(text string, audience []Role, priority *float64) Content { 39 | return Content{ 40 | Type: "text", 41 | Text: text, 42 | Annotations: &Annotations{ 43 | Audience: audience, 44 | Priority: priority, 45 | }, 46 | } 47 | } 48 | 49 | // NewImageContent creates a new ImageContent with the given data and optional annotations 50 | func NewImageContent(data string, mimeType string, audience []Role, priority *float64) Content { 51 | return Content{ 52 | Type: "image", 53 | Data: data, 54 | MimeType: mimeType, 55 | Annotations: &Annotations{ 56 | Audience: audience, 57 | Priority: priority, 58 | }, 59 | } 60 | } 61 | 62 | // Initialize 63 | type ( 64 | // ServerCapabilities represents the server's supported capabilities 65 | ServerCapabilities struct { 66 | Experimental map[string]interface{} `json:"experimental,omitempty"` 67 | Logging *struct{} `json:"logging,omitempty"` 68 | Prompts *struct { 69 | ListChanged bool `json:"listChanged"` 70 | } `json:"prompts,omitempty"` 71 | Resources *struct { 72 | Subscribe bool `json:"subscribe"` 73 | ListChanged bool `json:"listChanged"` 74 | } `json:"resources,omitempty"` 75 | Tools *struct { 76 | ListChanged bool `json:"listChanged"` 77 | } `json:"tools,omitempty"` 78 | } 79 | 80 | // ServerInfo represents information about an MCP implementation 81 | ServerInfo struct { 82 | Name string `json:"name"` 83 | Version string `json:"version"` 84 | } 85 | 86 | // InitializeRequest represents a request to initialize the server 87 | InitializeRequest struct{} 88 | 89 | // InitializeResponse represents the server's response to an initialize request 90 | InitializeResponse struct { 91 | ProtocolVersion string `json:"protocolVersion"` 92 | Capabilities ServerCapabilities `json:"capabilities"` 93 | ServerInfo ServerInfo `json:"serverInfo"` 94 | Instructions string `json:"instructions,omitempty"` 95 | } 96 | 97 | // InitializedNotification represents a notification that initialization is complete 98 | InitializedNotification struct{} 99 | ) 100 | 101 | // Resources 102 | type ( 103 | // Resource represents a known resource that the server can read 104 | Resource struct { 105 | URI string `json:"uri"` 106 | Name string `json:"name"` 107 | Description string `json:"description,omitempty"` 108 | MimeType string `json:"mimeType,omitempty"` 109 | Size int64 `json:"size,omitempty"` 110 | Annotations map[string]interface{} `json:"annotations,omitempty"` 111 | } 112 | 113 | // ResourceContents represents the contents of a specific resource 114 | ResourceContents struct { 115 | URI string `json:"uri"` 116 | MimeType string `json:"mimeType,omitempty"` 117 | Text string `json:"text,omitempty"` 118 | Blob string `json:"blob,omitempty"` 119 | } 120 | 121 | // ResourceTemplate represents a template for resources 122 | ResourceTemplate struct { 123 | URITemplate string `json:"uriTemplate"` 124 | Name string `json:"name"` 125 | Description string `json:"description,omitempty"` 126 | MimeType string `json:"mimeType,omitempty"` 127 | Annotations map[string]interface{} `json:"annotations,omitempty"` 128 | } 129 | 130 | // ListResourcesRequest represents a request to list available resources 131 | ListResourcesRequest struct { 132 | Cursor string `json:"cursor,omitempty"` 133 | } 134 | 135 | // ListResourcesResponse represents the response for resources/list 136 | ListResourcesResponse struct { 137 | Resources []Resource `json:"resources"` 138 | NextCursor string `json:"nextCursor,omitempty"` 139 | } 140 | 141 | // ListResourceTemplatesRequest represents a request to list resource templates 142 | ListResourceTemplatesRequest struct { 143 | Cursor string `json:"cursor,omitempty"` 144 | } 145 | 146 | // ListResourceTemplatesResponse represents the response for resources/templates/list 147 | ListResourceTemplatesResponse struct { 148 | ResourceTemplates []ResourceTemplate `json:"resourceTemplates"` 149 | NextCursor string `json:"nextCursor,omitempty"` 150 | } 151 | 152 | // ReadResourceRequest represents a request to read a resource 153 | ReadResourceRequest struct { 154 | URI string `json:"uri"` 155 | } 156 | 157 | // ReadResourceResponse represents the response for resources/read 158 | ReadResourceResponse struct { 159 | Contents []ResourceContents `json:"contents"` 160 | } 161 | 162 | // SubscribeRequest represents a request to subscribe to resource updates 163 | SubscribeRequest struct { 164 | URI string `json:"uri"` 165 | } 166 | 167 | // UnsubscribeRequest represents a request to unsubscribe from resource updates 168 | UnsubscribeRequest struct { 169 | URI string `json:"uri"` 170 | } 171 | 172 | // ResourceListChangedNotification represents a notification that the resource list has changed 173 | ResourceListChangedNotification struct{} 174 | 175 | // ResourceUpdatedNotification represents a notification that a resource has been updated 176 | ResourceUpdatedNotification struct { 177 | URI string `json:"uri"` 178 | } 179 | ) 180 | 181 | // Prompts 182 | type ( 183 | // Prompt represents a prompt or prompt template 184 | Prompt struct { 185 | Name string `json:"name"` 186 | Description string `json:"description,omitempty"` 187 | Arguments []PromptArgument `json:"arguments,omitempty"` 188 | } 189 | 190 | // PromptArgument represents an argument for a prompt 191 | PromptArgument struct { 192 | Name string `json:"name"` 193 | Description string `json:"description,omitempty"` 194 | Required bool `json:"required,omitempty"` 195 | } 196 | 197 | // PromptMessage represents a message in a prompt 198 | PromptMessage struct { 199 | Role Role `json:"role"` 200 | Content Content `json:"content"` 201 | } 202 | 203 | // ListPromptsRequest represents a request to list available prompts 204 | ListPromptsRequest struct { 205 | Cursor string `json:"cursor,omitempty"` 206 | } 207 | 208 | // ListPromptsResponse represents the response for prompts/list 209 | ListPromptsResponse struct { 210 | Prompts []Prompt `json:"prompts"` 211 | NextCursor string `json:"nextCursor,omitempty"` 212 | } 213 | 214 | // GetPromptRequest represents a request to get a specific prompt 215 | GetPromptRequest struct { 216 | Name string `json:"name"` 217 | Arguments map[string]string `json:"arguments,omitempty"` 218 | } 219 | 220 | // GetPromptResponse represents the response for prompts/get 221 | GetPromptResponse struct { 222 | Description string `json:"description,omitempty"` 223 | Messages []PromptMessage `json:"messages"` 224 | } 225 | 226 | // PromptListChangedNotification represents a notification that the prompt list has changed 227 | PromptListChangedNotification struct{} 228 | ) 229 | 230 | // Tools 231 | type ( 232 | // Tool represents a single tool in the tools/list response 233 | Tool struct { 234 | Name string `json:"name"` 235 | Description string `json:"description,omitempty"` 236 | InputSchema InputSchema `json:"inputSchema"` 237 | } 238 | 239 | // InputSchema represents the JSON Schema for tool parameters 240 | InputSchema struct { 241 | Type string `json:"type"` 242 | Properties map[string]interface{} `json:"properties,omitempty"` 243 | Required []string `json:"required,omitempty"` 244 | } 245 | 246 | // ToolsListRequest represents a request to list available tools 247 | ToolsListRequest struct { 248 | Cursor string `json:"cursor,omitempty"` 249 | } 250 | 251 | // ToolsListResponse represents the response for the tools/list method 252 | ToolsListResponse struct { 253 | Tools []Tool `json:"tools"` 254 | NextCursor string `json:"nextCursor,omitempty"` 255 | } 256 | 257 | // ToolCallRequest represents a request to call a specific tool 258 | ToolCallRequest struct { 259 | Name string `json:"name"` 260 | Arguments map[string]interface{} `json:"arguments,omitempty"` 261 | } 262 | 263 | // ToolCallResponse represents the response from a tool call 264 | ToolCallResponse struct { 265 | Content []Content `json:"content"` 266 | IsError bool `json:"isError,omitempty"` 267 | } 268 | 269 | // ToolsChangedNotification represents a notification that the tools list has changed 270 | ToolsChangedNotification struct{} 271 | ) 272 | 273 | // Sampling-related types 274 | type ( 275 | // SamplingMessage represents a message for LLM sampling 276 | SamplingMessage struct { 277 | Role Role `json:"role"` 278 | Content Content `json:"content"` 279 | } 280 | 281 | // ModelPreferences represents preferences for model selection 282 | ModelPreferences struct { 283 | Hints []ModelHint `json:"hints,omitempty"` 284 | CostPriority float64 `json:"costPriority,omitempty"` 285 | SpeedPriority float64 `json:"speedPriority,omitempty"` 286 | IntelligencePriority float64 `json:"intelligencePriority,omitempty"` 287 | } 288 | 289 | // ModelHint represents hints for model selection 290 | ModelHint struct { 291 | Name string `json:"name,omitempty"` 292 | } 293 | 294 | // CreateMessageRequest represents a request to create a message via sampling 295 | CreateMessageRequest struct { 296 | Messages []SamplingMessage `json:"messages"` 297 | ModelPreferences *ModelPreferences `json:"modelPreferences,omitempty"` 298 | SystemPrompt string `json:"systemPrompt,omitempty"` 299 | IncludeContext string `json:"includeContext,omitempty"` 300 | Temperature float64 `json:"temperature,omitempty"` 301 | MaxTokens int `json:"maxTokens"` 302 | StopSequences []string `json:"stopSequences,omitempty"` 303 | Metadata map[string]interface{} `json:"metadata,omitempty"` 304 | } 305 | 306 | // CreateMessageResponse represents the response for sampling/createMessage 307 | CreateMessageResponse struct { 308 | Role Role `json:"role"` 309 | Content Content `json:"content"` 310 | Model string `json:"model"` 311 | StopReason string `json:"stopReason,omitempty"` 312 | } 313 | ) 314 | 315 | // Completions 316 | type ( 317 | // CompleteRequest represents a request for completion options 318 | CompleteRequest struct { 319 | Ref interface{} `json:"ref"` 320 | Argument struct { 321 | Name string `json:"name"` 322 | Value string `json:"value"` 323 | } `json:"argument"` 324 | } 325 | 326 | // CompleteResponse represents the response for completion/complete 327 | CompleteResponse struct { 328 | Completion struct { 329 | Values []string `json:"values"` 330 | Total int `json:"total,omitempty"` 331 | HasMore bool `json:"hasMore,omitempty"` 332 | } `json:"completion"` 333 | } 334 | ) 335 | 336 | // Roots 337 | type ( 338 | // Root represents a root directory or file 339 | Root struct { 340 | URI string `json:"uri"` 341 | Name string `json:"name,omitempty"` 342 | } 343 | 344 | // ListRootsRequest represents a request to list root directories 345 | ListRootsRequest struct{} 346 | 347 | // ListRootsResponse represents the response for roots/list 348 | ListRootsResponse struct { 349 | Roots []Root `json:"roots"` 350 | } 351 | 352 | // RootsListChangedNotification represents a notification that the roots list has changed 353 | RootsListChangedNotification struct{} 354 | ) 355 | 356 | // Logging 357 | type ( 358 | // SetLevelRequest represents a request to set logging level 359 | SetLevelRequest struct { 360 | Level string `json:"level"` 361 | } 362 | 363 | // LogNotification represents a log message from the server 364 | LogNotification struct { 365 | Level string `json:"level"` 366 | Logger string `json:"logger,omitempty"` 367 | Data interface{} `json:"data"` 368 | } 369 | ) 370 | 371 | // Progress 372 | type ( 373 | // ProgressNotification represents a progress update for a long-running request 374 | ProgressNotification struct { 375 | ProgressToken string `json:"progressToken"` 376 | Progress float64 `json:"progress"` 377 | Total float64 `json:"total,omitempty"` 378 | } 379 | ) 380 | 381 | // Ping 382 | type ( 383 | // PingRequest represents a ping request 384 | PingRequest struct{} 385 | 386 | // PingResponse represents the response for ping/ping 387 | PingResponse struct{} 388 | ) 389 | -------------------------------------------------------------------------------- /mcp/server.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "log/slog" 11 | "net/http" 12 | "net/url" 13 | "path" 14 | "reflect" 15 | "strings" 16 | 17 | "github.com/pb33f/libopenapi" 18 | "github.com/pb33f/libopenapi/datamodel/high/base" 19 | v3 "github.com/pb33f/libopenapi/datamodel/high/v3" 20 | "gopkg.in/yaml.v3" 21 | 22 | "github.com/loopwork-ai/emcee/internal" 23 | "github.com/loopwork-ai/emcee/jsonrpc" 24 | ) 25 | 26 | // Server represents an MCP server that processes JSON-RPC requests 27 | type Server struct { 28 | auth string 29 | doc libopenapi.Document 30 | model *v3.Document 31 | baseURL string 32 | client *http.Client 33 | info ServerInfo 34 | logger *slog.Logger 35 | } 36 | 37 | // ServerOption configures a Server 38 | type ServerOption func(*Server) error 39 | 40 | // WithAuth sets the authentication header for the server 41 | func WithAuth(auth string) ServerOption { 42 | return func(s *Server) error { 43 | auth = strings.TrimSpace(auth) 44 | parts := strings.SplitN(auth, " ", 2) 45 | if len(parts) != 2 { 46 | return fmt.Errorf("invalid auth header format: %s", auth) 47 | } 48 | s.auth = fmt.Sprintf("%s %s", parts[0], parts[1]) 49 | return nil 50 | } 51 | } 52 | 53 | // WithClient sets the HTTP client 54 | func WithClient(client *http.Client) ServerOption { 55 | return func(s *Server) error { 56 | s.client = client 57 | return nil 58 | } 59 | } 60 | 61 | // WithLogger sets the logger for the server 62 | func WithLogger(logger *slog.Logger) ServerOption { 63 | return func(s *Server) error { 64 | s.logger = logger 65 | return nil 66 | } 67 | } 68 | 69 | // WithServerInfo sets server info 70 | func WithServerInfo(name, version string) ServerOption { 71 | return func(s *Server) error { 72 | s.info = ServerInfo{ 73 | Name: name, 74 | Version: version, 75 | } 76 | return nil 77 | } 78 | } 79 | 80 | // WithSpecData sets the OpenAPI spec from a byte slice 81 | func WithSpecData(data []byte) ServerOption { 82 | return func(s *Server) error { 83 | if len(data) == 0 { 84 | return fmt.Errorf("no OpenAPI spec data provided") 85 | } 86 | 87 | doc, err := libopenapi.NewDocument(data) 88 | if err != nil { 89 | return fmt.Errorf("error parsing OpenAPI spec: %v", err) 90 | } 91 | 92 | s.doc = doc 93 | model, errs := doc.BuildV3Model() 94 | if len(errs) > 0 { 95 | return fmt.Errorf("error building OpenAPI model: %v", errs[0]) 96 | } 97 | 98 | s.model = &model.Model 99 | 100 | // Require server URL information 101 | if len(model.Model.Servers) == 0 || model.Model.Servers[0].URL == "" { 102 | return fmt.Errorf("OpenAPI spec must include at least one server URL") 103 | } 104 | s.baseURL = strings.TrimSuffix(model.Model.Servers[0].URL, "/") 105 | 106 | return nil 107 | } 108 | } 109 | 110 | // NewServer creates a new MCP server instance 111 | func NewServer(opts ...ServerOption) (*Server, error) { 112 | s := &Server{ 113 | client: &http.Client{ 114 | Transport: http.DefaultTransport, 115 | }, 116 | } 117 | 118 | // Apply options 119 | for _, opt := range opts { 120 | if err := opt(s); err != nil { 121 | return nil, err 122 | } 123 | } 124 | 125 | // Apply custom transport to inject auth header, if provided 126 | if s.auth != "" { 127 | headers := http.Header{} 128 | headers.Add("Authorization", s.auth) 129 | 130 | s.client.Transport = &internal.HeaderTransport{ 131 | Base: s.client.Transport, 132 | Headers: headers, 133 | } 134 | } 135 | 136 | // Validate required fields 137 | if s.doc == nil { 138 | return nil, fmt.Errorf("OpenAPI spec URL is required") 139 | } 140 | 141 | if s.logger != nil { 142 | s.logger.Info("server initialized with OpenAPI spec") 143 | } 144 | 145 | return s, nil 146 | } 147 | 148 | // HandleRequest processes a single JSON-RPC request and returns a response 149 | func (s *Server) HandleRequest(request jsonrpc.Request) *jsonrpc.Response { 150 | if s.logger != nil { 151 | reqJSON, _ := json.MarshalIndent(request, "", " ") 152 | s.logger.Debug("incoming request", 153 | "request", string(reqJSON), 154 | "method", request.Method) 155 | s.logger.Info("handling request", "method", request.Method) 156 | } 157 | 158 | // Handle notifications first 159 | if strings.HasPrefix(request.Method, "notifications/") || request.Method == "initialized" { 160 | s.logger.Info("received notification", "method", request.Method) 161 | return nil 162 | } 163 | 164 | var response jsonrpc.Response 165 | switch request.Method { 166 | case "initialize": 167 | response = handleRequest(request, s.handleInitialize) 168 | case "tools/list": 169 | response = handleRequest(request, s.handleToolsList) 170 | case "tools/call": 171 | response = handleRequest(request, s.handleToolsCall) 172 | case "ping/ping": 173 | response = handleRequest(request, s.handlePing) 174 | default: 175 | if s.logger != nil { 176 | s.logger.Warn("unknown method requested", "method", request.Method) 177 | } 178 | response = jsonrpc.NewResponse(request.ID, nil, jsonrpc.NewError(jsonrpc.ErrMethodNotFound, nil)) 179 | } 180 | 181 | if s.logger != nil { 182 | if response.Error != nil { 183 | s.logger.Error("request failed", 184 | "method", request.Method, 185 | "error", response.Error) 186 | } 187 | respJSON, _ := json.MarshalIndent(response, "", " ") 188 | s.logger.Debug("outgoing response", 189 | "response", string(respJSON)) 190 | } 191 | 192 | return &response 193 | } 194 | 195 | // handleRequest is a helper to unmarshal params and call a handler with proper error handling 196 | func handleRequest[Req, Resp any](request jsonrpc.Request, handler func(*Req) (*Resp, error)) jsonrpc.Response { 197 | var req Req 198 | if request.Params != nil { 199 | if err := json.Unmarshal(request.Params, &req); err != nil { 200 | return jsonrpc.NewResponse(request.ID, nil, jsonrpc.NewError(jsonrpc.ErrInvalidParams, err)) 201 | } 202 | } 203 | resp, err := handler(&req) 204 | if err != nil { 205 | if rpcErr, ok := err.(*jsonrpc.Error); ok { 206 | return jsonrpc.NewResponse(request.ID, nil, rpcErr) 207 | } 208 | return jsonrpc.NewResponse(request.ID, nil, jsonrpc.NewError(jsonrpc.ErrInternal, err)) 209 | } 210 | 211 | // Convert response to interface{} to ensure proper JSON serialization 212 | var result interface{} = resp 213 | if resp != nil { 214 | // If it's a pointer, get the underlying value 215 | if rv := reflect.ValueOf(resp); rv.Kind() == reflect.Ptr && !rv.IsNil() { 216 | result = rv.Elem().Interface() 217 | } 218 | } 219 | 220 | return jsonrpc.NewResponse(request.ID, result, nil) 221 | } 222 | 223 | func (s *Server) handleInitialize(request *InitializeRequest) (*InitializeResponse, error) { 224 | response := &InitializeResponse{ 225 | ProtocolVersion: Version, 226 | Capabilities: ServerCapabilities{ 227 | Tools: &struct { 228 | ListChanged bool `json:"listChanged"` 229 | }{ 230 | ListChanged: false, 231 | }, 232 | }, 233 | ServerInfo: s.info, 234 | } 235 | return response, nil 236 | } 237 | 238 | // Update the tools list generation to use the helper 239 | func (s *Server) handleToolsList(request *ToolsListRequest) (*ToolsListResponse, error) { 240 | tools := []Tool{} 241 | if s.model.Paths == nil || s.model.Paths.PathItems == nil { 242 | if s.logger != nil { 243 | s.logger.Info("no tools found in OpenAPI spec") 244 | } 245 | return &ToolsListResponse{Tools: tools}, nil 246 | } 247 | 248 | toolCount := 0 249 | // Iterate through paths and operations 250 | for pair := s.model.Paths.PathItems.First(); pair != nil; pair = pair.Next() { 251 | pathItem := pair.Value() 252 | 253 | // Process each operation type 254 | operations := []struct { 255 | method string 256 | op *v3.Operation 257 | }{ 258 | {"GET", pathItem.Get}, 259 | {"POST", pathItem.Post}, 260 | {"PUT", pathItem.Put}, 261 | {"DELETE", pathItem.Delete}, 262 | {"PATCH", pathItem.Patch}, 263 | } 264 | 265 | for _, op := range operations { 266 | if op.op == nil || op.op.OperationId == "" { 267 | continue 268 | } 269 | if s.logger != nil { 270 | s.logger.Debug("discovered tool", 271 | "operation_id", op.op.OperationId, 272 | "method", op.method, 273 | "description", op.op.Description) 274 | } 275 | toolCount++ 276 | 277 | // Create input schema 278 | inputSchema := InputSchema{ 279 | Type: "object", 280 | Properties: make(map[string]interface{}), 281 | Required: []string{}, 282 | } 283 | 284 | // Add path parameters 285 | if pathItem.Parameters != nil { 286 | for _, param := range pathItem.Parameters { 287 | if param != nil && param.Schema != nil { 288 | schema := make(map[string]interface{}) 289 | if paramSchema := param.Schema.Schema(); paramSchema != nil { 290 | schemaType := "string" // default to string if not specified 291 | if len(paramSchema.Type) > 0 { 292 | schemaType = paramSchema.Type[0] 293 | } 294 | schema["type"] = schemaType 295 | if paramSchema.Pattern != "" { 296 | schema["pattern"] = paramSchema.Pattern 297 | } 298 | // Add enum values to description if they exist 299 | schema["description"] = buildSchemaDescription(param.Description, paramSchema) 300 | } else { 301 | schema["description"] = param.Description 302 | } 303 | inputSchema.Properties[param.Name] = schema 304 | if param.Required != nil && *param.Required { 305 | inputSchema.Required = append(inputSchema.Required, param.Name) 306 | } 307 | } 308 | } 309 | } 310 | 311 | // Add operation parameters 312 | if op.op.Parameters != nil { 313 | for _, param := range op.op.Parameters { 314 | if param != nil && param.Schema != nil { 315 | schema := make(map[string]interface{}) 316 | if paramSchema := param.Schema.Schema(); paramSchema != nil { 317 | schemaType := "string" // default to string if not specified 318 | if len(paramSchema.Type) > 0 { 319 | schemaType = paramSchema.Type[0] 320 | } 321 | schema["type"] = schemaType 322 | if paramSchema.Pattern != "" { 323 | schema["pattern"] = paramSchema.Pattern 324 | } 325 | // Add enum values to description if they exist 326 | schema["description"] = buildSchemaDescription(param.Description, paramSchema) 327 | } else { 328 | schema["description"] = param.Description 329 | } 330 | inputSchema.Properties[param.Name] = schema 331 | if param.Required != nil && *param.Required { 332 | inputSchema.Required = append(inputSchema.Required, param.Name) 333 | } 334 | } 335 | } 336 | } 337 | 338 | // Add request body if present 339 | if op.op.RequestBody != nil && op.op.RequestBody.Content != nil { 340 | if mediaType, ok := op.op.RequestBody.Content.Get("application/json"); ok && mediaType != nil { 341 | if mediaType.Schema != nil && mediaType.Schema.Schema() != nil { 342 | schema := mediaType.Schema.Schema() 343 | if schema.Properties != nil { 344 | for pair := schema.Properties.First(); pair != nil; pair = pair.Next() { 345 | propName := pair.Key() 346 | propSchema := pair.Value().Schema() 347 | if propSchema != nil { 348 | schemaType := "string" 349 | if len(propSchema.Type) > 0 { 350 | schemaType = propSchema.Type[0] 351 | } 352 | // Add enum values to description if they exist 353 | description := buildSchemaDescription("", propSchema) 354 | inputSchema.Properties[propName] = map[string]interface{}{ 355 | "type": schemaType, 356 | "description": description, 357 | } 358 | } 359 | } 360 | if schema.Required != nil { 361 | inputSchema.Required = append(inputSchema.Required, schema.Required...) 362 | } 363 | } 364 | } 365 | } 366 | } 367 | 368 | description := op.op.Description 369 | if description == "" { 370 | description = op.op.Summary 371 | } 372 | 373 | toolName := getToolName(op.op.OperationId) 374 | tools = append(tools, Tool{ 375 | Name: toolName, 376 | Description: description, 377 | InputSchema: inputSchema, 378 | }) 379 | } 380 | } 381 | 382 | if s.logger != nil { 383 | s.logger.Info("tools discovery completed", "count", toolCount) 384 | } 385 | 386 | return &ToolsListResponse{Tools: tools}, nil 387 | } 388 | 389 | // Update the tools call handler to use the new finder 390 | func (s *Server) handleToolsCall(request *ToolCallRequest) (*ToolCallResponse, error) { 391 | method, p, operation, pathItem, found := s.findOperationByToolName(request.Name) 392 | if !found { 393 | return nil, jsonrpc.NewError(jsonrpc.ErrMethodNotFound, nil) 394 | } 395 | 396 | // Build URL from base URL and path 397 | baseURL, err := url.Parse(s.baseURL) 398 | if err != nil { 399 | return nil, jsonrpc.NewError(jsonrpc.ErrInternal, err) 400 | } 401 | 402 | // Ensure the path starts with a slash 403 | if !strings.HasPrefix(p, "/") { 404 | p = "/" + p 405 | } 406 | 407 | // Clean the path to handle multiple slashes 408 | p = path.Clean(p) 409 | 410 | // Create a new URL with the base URL's scheme and host 411 | u := &url.URL{ 412 | Scheme: baseURL.Scheme, 413 | Host: baseURL.Host, 414 | } 415 | 416 | // If the base URL has a path, join it with the operation path 417 | if baseURL.Path != "" { 418 | // Clean the base path 419 | basePath := path.Clean(baseURL.Path) 420 | // Join paths and ensure leading slash 421 | u.Path = "/" + strings.TrimPrefix(path.Join(basePath, p), "/") 422 | } else { 423 | u.Path = p 424 | } 425 | 426 | // Set default scheme if not present 427 | if u.Scheme == "" { 428 | u.Scheme = "http" 429 | } 430 | 431 | // Process parameters based on their location (path, query, header) 432 | queryParams := url.Values{} 433 | headerParams := make(http.Header) 434 | var bodyParams map[string]interface{} 435 | 436 | // Handle path item parameters first 437 | if pathItem.Parameters != nil { 438 | for _, param := range pathItem.Parameters { 439 | if param != nil { 440 | if value, ok := request.Arguments[param.Name]; ok { 441 | switch param.In { 442 | case "path": 443 | // Only escape characters that are invalid in URL path segments 444 | value := fmt.Sprint(value) 445 | u.Path = strings.ReplaceAll(u.Path, "{"+param.Name+"}", pathSegmentEscape(value)) 446 | case "query": 447 | queryParams.Set(param.Name, fmt.Sprint(value)) 448 | case "header": 449 | headerParams.Add(param.Name, fmt.Sprint(value)) 450 | } 451 | } 452 | } 453 | } 454 | } 455 | 456 | // Handle operation parameters 457 | if operation.Parameters != nil { 458 | for _, param := range operation.Parameters { 459 | if param != nil { 460 | if value, ok := request.Arguments[param.Name]; ok { 461 | switch param.In { 462 | case "path": 463 | // Only escape characters that are invalid in URL path segments 464 | value := fmt.Sprint(value) 465 | u.Path = strings.ReplaceAll(u.Path, "{"+param.Name+"}", pathSegmentEscape(value)) 466 | case "query": 467 | // Handle array values for query parameters 468 | switch v := value.(type) { 469 | case []interface{}: 470 | // Join array values with commas for parameters like tweet.fields 471 | values := make([]string, len(v)) 472 | for i, item := range v { 473 | values[i] = fmt.Sprint(item) 474 | } 475 | queryParams.Set(param.Name, strings.Join(values, ",")) 476 | default: 477 | queryParams.Set(param.Name, fmt.Sprint(value)) 478 | } 479 | case "header": 480 | headerParams.Add(param.Name, fmt.Sprint(value)) 481 | } 482 | } 483 | } 484 | } 485 | } 486 | 487 | // Handle request body 488 | if operation.RequestBody != nil && operation.RequestBody.Content != nil { 489 | if mediaType, ok := operation.RequestBody.Content.Get("application/json"); ok && mediaType != nil { 490 | if mediaType.Schema != nil && mediaType.Schema.Schema() != nil { 491 | schema := mediaType.Schema.Schema() 492 | if schema.Properties != nil { 493 | bodyParams = make(map[string]interface{}) 494 | for pair := schema.Properties.First(); pair != nil; pair = pair.Next() { 495 | propName := pair.Key() 496 | if value, ok := request.Arguments[propName]; ok { 497 | bodyParams[propName] = value 498 | } 499 | } 500 | } 501 | } 502 | } 503 | } 504 | 505 | // Add query parameters to URL 506 | if len(queryParams) > 0 { 507 | u.RawQuery = queryParams.Encode() 508 | } 509 | 510 | // Create and send request 511 | var reqBody io.Reader 512 | if len(bodyParams) > 0 { 513 | jsonBody, err := json.Marshal(bodyParams) 514 | if err != nil { 515 | return nil, jsonrpc.NewError(jsonrpc.ErrInvalidParams, err) 516 | } 517 | reqBody = bytes.NewReader(jsonBody) 518 | } 519 | 520 | req, err := http.NewRequest(method, u.String(), reqBody) 521 | if err != nil { 522 | return nil, jsonrpc.NewError(jsonrpc.ErrInternal, err) 523 | } 524 | 525 | // Add headers 526 | for key, values := range headerParams { 527 | for _, value := range values { 528 | req.Header.Add(key, value) 529 | } 530 | } 531 | 532 | if reqBody != nil { 533 | req.Header.Set("Content-Type", "application/json") 534 | } 535 | 536 | // Send request 537 | resp, err := s.client.Do(req) 538 | if err != nil { 539 | return nil, jsonrpc.NewError(jsonrpc.ErrInternal, err) 540 | } 541 | defer resp.Body.Close() 542 | 543 | // Read response 544 | body, err := io.ReadAll(resp.Body) 545 | if err != nil { 546 | return nil, jsonrpc.NewError(jsonrpc.ErrInternal, err) 547 | } 548 | 549 | // Handle error responses 550 | if resp.StatusCode >= 400 { 551 | textContent := NewTextContent(fmt.Sprintf("Request failed with status %d: %s", resp.StatusCode, string(body)), []Role{RoleAssistant}, nil) 552 | return nil, jsonrpc.NewError(jsonrpc.ErrInternal, textContent) 553 | } 554 | 555 | // Process response based on content type 556 | contentType := resp.Header.Get("Content-Type") 557 | var content Content 558 | 559 | // Create content based on response content type 560 | if strings.HasPrefix(contentType, "image/") { 561 | encoded := base64.StdEncoding.EncodeToString(body) 562 | content = NewImageContent(encoded, contentType, []Role{RoleAssistant}, nil) 563 | } else if strings.Contains(contentType, "application/json") { 564 | var prettyJSON bytes.Buffer 565 | if err := json.Indent(&prettyJSON, body, "", " "); err == nil { 566 | body = prettyJSON.Bytes() 567 | } 568 | content = NewTextContent(string(body), []Role{RoleAssistant}, nil) 569 | } else { 570 | content = NewTextContent(string(body), []Role{RoleAssistant}, nil) 571 | } 572 | 573 | return &ToolCallResponse{ 574 | Content: []Content{content}, 575 | IsError: false, 576 | }, nil 577 | } 578 | 579 | func (s *Server) handlePing(request *PingRequest) (*PingResponse, error) { 580 | return &PingResponse{}, nil 581 | } 582 | 583 | // pathSegmentEscape escapes invalid URL path segment characters according to RFC 3986. 584 | // It preserves valid path characters including comma, colon, and @ sign. 585 | func pathSegmentEscape(s string) string { 586 | // RFC 3986 section 3.3 defines path segment characters: 587 | // segment = *pchar 588 | // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 589 | // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 590 | // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" 591 | hexCount := 0 592 | for i := 0; i < len(s); i++ { 593 | c := s[i] 594 | if shouldEscape(c) { 595 | hexCount++ 596 | } 597 | } 598 | 599 | if hexCount == 0 { 600 | return s 601 | } 602 | 603 | var buf [3]byte 604 | t := make([]byte, len(s)+2*hexCount) 605 | j := 0 606 | for i := 0; i < len(s); i++ { 607 | c := s[i] 608 | if shouldEscape(c) { 609 | buf[0] = '%' 610 | buf[1] = "0123456789ABCDEF"[c>>4] 611 | buf[2] = "0123456789ABCDEF"[c&15] 612 | t[j] = buf[0] 613 | t[j+1] = buf[1] 614 | t[j+2] = buf[2] 615 | j += 3 616 | } else { 617 | t[j] = c 618 | j++ 619 | } 620 | } 621 | return string(t) 622 | } 623 | 624 | // shouldEscape reports whether the byte c should be escaped. 625 | // It follows RFC 3986 section 3.3 path segment rules. 626 | func shouldEscape(c byte) bool { 627 | // RFC 3986 section 2.3 Unreserved Characters (and some sub-delims) 628 | if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' { 629 | return false 630 | } 631 | switch c { 632 | case '-', '.', '_', '~': // unreserved 633 | return false 634 | case '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '@': // sub-delims + ':' + '@' 635 | return false 636 | } 637 | return true 638 | } 639 | 640 | // getToolName creates a unique tool name from an operation ID, ensuring it's within the 64-character limit 641 | // while maintaining a bijective mapping between operation IDs and tool names 642 | func getToolName(operationId string) string { 643 | if len(operationId) <= 64 { 644 | return operationId 645 | } 646 | // Generate a short hash of the full operation ID 647 | hash := sha256.Sum256([]byte(operationId)) 648 | // Use base64 encoding for shorter hash representation (first 8 chars) 649 | shortHash := base64.RawURLEncoding.EncodeToString(hash[:])[:8] 650 | // Create a deterministic name that fits within limits while preserving uniqueness 651 | return operationId[:55] + "_" + shortHash 652 | } 653 | 654 | // findOperationByToolName maps a tool name back to its corresponding OpenAPI operation 655 | func (s *Server) findOperationByToolName(toolName string) (method, path string, operation *v3.Operation, pathItem *v3.PathItem, found bool) { 656 | if s.model.Paths == nil || s.model.Paths.PathItems == nil { 657 | return "", "", nil, nil, false 658 | } 659 | 660 | for pair := s.model.Paths.PathItems.First(); pair != nil; pair = pair.Next() { 661 | pathStr := pair.Key() 662 | item := pair.Value() 663 | 664 | operations := []struct { 665 | method string 666 | op *v3.Operation 667 | }{ 668 | {"GET", item.Get}, 669 | {"POST", item.Post}, 670 | {"PUT", item.Put}, 671 | {"DELETE", item.Delete}, 672 | {"PATCH", item.Patch}, 673 | } 674 | 675 | for _, op := range operations { 676 | if op.op != nil && op.op.OperationId != "" { 677 | if getToolName(op.op.OperationId) == toolName { 678 | return op.method, pathStr, op.op, item, true 679 | } 680 | } 681 | } 682 | } 683 | return "", "", nil, nil, false 684 | } 685 | 686 | // buildSchemaDescription builds a description string from parameter and schema descriptions, and enum values 687 | func buildSchemaDescription(paramDesc string, paramSchema *base.Schema) string { 688 | description := paramDesc 689 | 690 | if paramSchema.Description != "" { 691 | if description != "" && description != paramSchema.Description { 692 | description = fmt.Sprintf("%s. %s", description, paramSchema.Description) 693 | } else { 694 | description = paramSchema.Description 695 | } 696 | } 697 | 698 | var enumValues []string 699 | if len(paramSchema.Enum) > 0 { 700 | enumValues = getEnumValues(paramSchema.Enum) 701 | } 702 | 703 | if len(enumValues) > 0 { 704 | if description != "" { 705 | description = fmt.Sprintf("%s (Allowed values: %s)", description, strings.Join(enumValues, ", ")) 706 | } else { 707 | description = fmt.Sprintf("Allowed values: %s", strings.Join(enumValues, ", ")) 708 | } 709 | } 710 | 711 | return description 712 | } 713 | 714 | // getEnumValues extracts enum values from a schema's enum field 715 | func getEnumValues(enum []*yaml.Node) []string { 716 | if len(enum) == 0 { 717 | return nil 718 | } 719 | values := make([]string, len(enum)) 720 | for i, v := range enum { 721 | values[i] = v.Value 722 | } 723 | return values 724 | } 725 | -------------------------------------------------------------------------------- /mcp/server_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "path" 10 | "strings" 11 | "testing" 12 | 13 | "io" 14 | "log/slog" 15 | 16 | "github.com/loopwork-ai/emcee/internal" 17 | "github.com/loopwork-ai/emcee/jsonrpc" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | func newTestSpec(serverURL string) []byte { 23 | spec := map[string]interface{}{ 24 | "openapi": "3.0.0", 25 | "info": map[string]interface{}{ 26 | "title": "Test API", 27 | "version": "1.0.0", 28 | }, 29 | "servers": []map[string]interface{}{ 30 | {"url": serverURL}, 31 | }, 32 | "paths": map[string]interface{}{ 33 | "/pets": map[string]interface{}{ 34 | "get": map[string]interface{}{ 35 | "operationId": "listPets", 36 | "summary": "List all pets", 37 | "description": "Returns all pets from the system", 38 | "parameters": []map[string]interface{}{ 39 | {"name": "limit", "in": "query", "description": "Maximum number of pets to return", "schema": map[string]interface{}{"type": "integer"}}, 40 | {"name": "type", "in": "query", "description": "Type of pets to filter by", "schema": map[string]interface{}{"type": "string"}}, 41 | { 42 | "name": "fields", 43 | "in": "query", 44 | "description": "Fields to return", 45 | "schema": map[string]interface{}{ 46 | "type": "array", 47 | "items": map[string]interface{}{ 48 | "type": "string", 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | "post": map[string]interface{}{ 55 | "operationId": "createPet", 56 | "summary": "Create a pet", 57 | "description": "Creates a new pet in the system", 58 | "requestBody": map[string]interface{}{ 59 | "required": true, 60 | "content": map[string]interface{}{ 61 | "application/json": map[string]interface{}{ 62 | "schema": map[string]interface{}{ 63 | "type": "object", 64 | "properties": map[string]interface{}{ 65 | "name": map[string]interface{}{ 66 | "type": "string", 67 | }, 68 | "age": map[string]interface{}{ 69 | "type": "integer", 70 | }, 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | }, 78 | "/pets/image": map[string]interface{}{ 79 | "get": map[string]interface{}{ 80 | "operationId": "getPetImage", 81 | "summary": "Get a pet's image", 82 | "description": "Returns a pet's image in PNG format", 83 | "responses": map[string]interface{}{ 84 | "200": map[string]interface{}{ 85 | "description": "A pet image", 86 | "content": map[string]interface{}{ 87 | "image/png": map[string]interface{}{ 88 | "schema": map[string]interface{}{ 89 | "type": "string", 90 | "format": "binary", 91 | }, 92 | }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | "/pets/{petId}": map[string]interface{}{ 99 | "get": map[string]interface{}{ 100 | "operationId": "getPet", 101 | "summary": "Get a specific pet", 102 | "description": "Returns a specific pet by ID", 103 | "parameters": []map[string]interface{}{ 104 | { 105 | "name": "petId", 106 | "in": "path", 107 | "required": true, 108 | "description": "The ID of the pet to retrieve", 109 | "schema": map[string]interface{}{ 110 | "type": "string", 111 | }, 112 | }, 113 | }, 114 | "responses": map[string]interface{}{ 115 | "200": map[string]interface{}{ 116 | "description": "A pet", 117 | "content": map[string]interface{}{ 118 | "application/json": map[string]interface{}{ 119 | "schema": map[string]interface{}{ 120 | "type": "object", 121 | "properties": map[string]interface{}{ 122 | "id": map[string]interface{}{ 123 | "type": "integer", 124 | }, 125 | "name": map[string]interface{}{ 126 | "type": "string", 127 | }, 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | } 138 | 139 | data, err := json.MarshalIndent(spec, "", " ") 140 | if err != nil { 141 | panic(err) 142 | } 143 | return data 144 | } 145 | 146 | func setupTestServer(t *testing.T) (*Server, *httptest.Server) { 147 | t.Helper() 148 | 149 | // Create a small test image 150 | imgData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG header 151 | 152 | // Track if auth header was checked 153 | authHeaderChecked := false 154 | 155 | var ts *httptest.Server 156 | ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 157 | // Verify auth header if present 158 | if authHeader := r.Header.Get("Authorization"); authHeader != "" { 159 | assert.Equal(t, "Bearer test-token", authHeader, "Authorization header should match") 160 | authHeaderChecked = true 161 | } 162 | 163 | switch r.URL.Path { 164 | case "/openapi.json": 165 | w.Header().Set("Content-Type", "application/json") 166 | w.Write(newTestSpec(ts.URL)) 167 | case "/pets": 168 | w.Header().Set("Content-Type", "application/json") 169 | switch r.Method { 170 | case "GET": 171 | // Verify query parameters are present 172 | limit := r.URL.Query().Get("limit") 173 | petType := r.URL.Query().Get("type") 174 | assert.Equal(t, "5", limit) 175 | assert.Equal(t, "dog", petType) 176 | 177 | // For auth test case, verify the auth header was checked 178 | if r.Header.Get("Authorization") != "" { 179 | assert.True(t, authHeaderChecked, "Auth header should have been checked") 180 | } 181 | 182 | pets := []map[string]interface{}{ 183 | {"id": 1, "name": "Fluffy", "type": "dog"}, 184 | {"id": 2, "name": "Rover", "type": "dog"}, 185 | } 186 | err := json.NewEncoder(w).Encode(pets) 187 | if err != nil { 188 | http.Error(w, err.Error(), http.StatusInternalServerError) 189 | return 190 | } 191 | case "POST": 192 | // Verify Content-Type header 193 | assert.Equal(t, "application/json", r.Header.Get("Content-Type")) 194 | 195 | var pet map[string]interface{} 196 | if err := json.NewDecoder(r.Body).Decode(&pet); err != nil { 197 | http.Error(w, err.Error(), http.StatusBadRequest) 198 | return 199 | } 200 | 201 | // Verify request body parameters 202 | assert.Equal(t, "Whiskers", pet["name"]) 203 | assert.Equal(t, float64(5), pet["age"]) 204 | 205 | // Add ID and return 206 | pet["id"] = 3 207 | err := json.NewEncoder(w).Encode(pet) 208 | if err != nil { 209 | http.Error(w, err.Error(), http.StatusInternalServerError) 210 | return 211 | } 212 | } 213 | case "/pets/image": 214 | w.Header().Set("Content-Type", "image/png") 215 | _, err := w.Write(imgData) 216 | if err != nil { 217 | http.Error(w, err.Error(), http.StatusInternalServerError) 218 | return 219 | } 220 | case "/pets/special%20pet": 221 | w.Header().Set("Content-Type", "application/json") 222 | pet := map[string]interface{}{ 223 | "id": 1, 224 | "name": "Special Pet", 225 | } 226 | json.NewEncoder(w).Encode(pet) 227 | default: 228 | http.NotFound(w, r) 229 | } 230 | })) 231 | 232 | client := ts.Client() 233 | client.Transport = &internal.HeaderTransport{ 234 | Base: client.Transport, 235 | Headers: http.Header{"Authorization": []string{"Bearer test-token"}}, 236 | } 237 | 238 | // Create a server instance with the test server URL and spec 239 | server, err := NewServer( 240 | WithClient(ts.Client()), 241 | WithServerInfo("Test API", "1.0.0"), 242 | WithSpecData(newTestSpec(ts.URL)), 243 | ) 244 | require.NoError(t, err) 245 | 246 | return server, ts 247 | } 248 | 249 | func TestServer_HandleInitialize(t *testing.T) { 250 | // Create a test HTTP server to serve the spec 251 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 252 | w.Write(newTestSpec(r.Host)) 253 | })) 254 | defer ts.Close() 255 | 256 | // Test with OpenAPI spec that includes server info 257 | server, err := NewServer( 258 | WithClient(http.DefaultClient), 259 | WithServerInfo("Test API", "1.0.0"), 260 | WithSpecData(newTestSpec(ts.URL)), 261 | ) 262 | require.NoError(t, err) 263 | 264 | // Create a basic initialize request 265 | request := jsonrpc.NewRequest("initialize", json.RawMessage(`{}`), 1) 266 | 267 | // Get the response 268 | response := server.HandleRequest(request) 269 | 270 | // Assert no error 271 | assert.Equal(t, "2.0", response.Version) 272 | assert.Equal(t, 1, response.ID.Value()) 273 | assert.Nil(t, response.Error) 274 | 275 | // Parse the response result 276 | var result InitializeResponse 277 | resultBytes, err := json.Marshal(response.Result) 278 | require.NoError(t, err) 279 | err = json.Unmarshal(resultBytes, &result) 280 | require.NoError(t, err) 281 | 282 | // Verify the response structure 283 | assert.Equal(t, "2024-11-05", result.ProtocolVersion) 284 | assert.Equal(t, "Test API", result.ServerInfo.Name) 285 | assert.Equal(t, "1.0.0", result.ServerInfo.Version) 286 | assert.False(t, result.Capabilities.Tools.ListChanged) 287 | 288 | // Test with empty OpenAPI spec 289 | emptySpec := map[string]interface{}{ 290 | "openapi": "3.0.0", 291 | "servers": []map[string]interface{}{ 292 | {"url": ts.URL}, 293 | }, 294 | "paths": map[string]interface{}{}, 295 | } 296 | emptySpecData, err := json.Marshal(emptySpec) 297 | require.NoError(t, err) 298 | 299 | tsEmpty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 300 | w.Write(emptySpecData) 301 | })) 302 | defer tsEmpty.Close() 303 | 304 | serverEmpty, err := NewServer( 305 | WithClient(http.DefaultClient), 306 | WithSpecData(emptySpecData), 307 | ) 308 | require.NoError(t, err) 309 | 310 | // Get response from empty spec server 311 | responseEmpty := serverEmpty.HandleRequest(request) 312 | var resultEmpty InitializeResponse 313 | resultBytes, err = json.Marshal(responseEmpty.Result) 314 | require.NoError(t, err) 315 | err = json.Unmarshal(resultBytes, &resultEmpty) 316 | require.NoError(t, err) 317 | } 318 | 319 | func TestHandleToolsList(t *testing.T) { 320 | server, ts := setupTestServer(t) 321 | defer ts.Close() 322 | 323 | request := jsonrpc.NewRequest("tools/list", nil, 1) 324 | 325 | response := server.HandleRequest(request) 326 | 327 | // Verify response structure 328 | assert.Equal(t, "2.0", response.Version) 329 | assert.Equal(t, 1, response.ID.Value()) 330 | assert.Nil(t, response.Error) 331 | 332 | // Convert response.Result to ToolsListResponse 333 | var toolsResp ToolsListResponse 334 | resultBytes, err := json.Marshal(response.Result) 335 | require.NoError(t, err) 336 | err = json.Unmarshal(resultBytes, &toolsResp) 337 | require.NoError(t, err) 338 | 339 | assert.Len(t, toolsResp.Tools, 4) // GET and POST /pets, plus GET /pets/image 340 | 341 | // Verify GET operation 342 | var getOp, postOp, imageOp, getPetOp Tool 343 | for _, tool := range toolsResp.Tools { 344 | switch tool.Name { 345 | case "listPets": 346 | getOp = tool 347 | case "createPet": 348 | postOp = tool 349 | case "getPetImage": 350 | imageOp = tool 351 | case "getPet": 352 | getPetOp = tool 353 | } 354 | } 355 | 356 | assert.Equal(t, "listPets", getOp.Name) 357 | assert.Equal(t, "Returns all pets from the system", getOp.Description) 358 | 359 | // Verify POST operation 360 | assert.Equal(t, "createPet", postOp.Name) 361 | assert.Equal(t, "Creates a new pet in the system", postOp.Description) 362 | assert.Contains(t, postOp.InputSchema.Properties, "name") 363 | assert.Contains(t, postOp.InputSchema.Properties, "age") 364 | 365 | // Verify Image operation 366 | assert.Equal(t, "getPetImage", imageOp.Name) 367 | assert.Equal(t, "Returns a pet's image in PNG format", imageOp.Description) 368 | assert.Empty(t, imageOp.InputSchema.Properties) // No input parameters needed 369 | 370 | // Verify GET /pets/special pet operation 371 | assert.Equal(t, "getPet", getPetOp.Name) 372 | assert.Equal(t, "Returns a specific pet by ID", getPetOp.Description) 373 | assert.Contains(t, getPetOp.InputSchema.Properties, "petId") 374 | } 375 | 376 | func TestHandleToolsCall(t *testing.T) { 377 | server, ts := setupTestServer(t) 378 | defer ts.Close() 379 | 380 | tests := []struct { 381 | name string 382 | server *Server 383 | setup func(*testing.T, *httptest.Server) http.HandlerFunc 384 | request jsonrpc.Request 385 | validate func(*testing.T, jsonrpc.Response, string) 386 | }{ 387 | { 388 | name: "GET request with query parameters", 389 | server: server, 390 | setup: func(t *testing.T, ts *httptest.Server) http.HandlerFunc { 391 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 392 | // Verify query parameters are present 393 | limit := r.URL.Query().Get("limit") 394 | petType := r.URL.Query().Get("type") 395 | assert.Equal(t, "5", limit) 396 | assert.Equal(t, "dog", petType) 397 | 398 | w.Header().Set("Content-Type", "application/json") 399 | pets := []map[string]interface{}{ 400 | {"id": 1, "name": "Fluffy", "type": "dog"}, 401 | {"id": 2, "name": "Rover", "type": "dog"}, 402 | } 403 | json.NewEncoder(w).Encode(pets) 404 | }) 405 | }, 406 | request: jsonrpc.NewRequest("tools/call", json.RawMessage(`{"name": "listPets", "arguments": {"limit": 5, "type": "dog"}}`), 1), 407 | validate: func(t *testing.T, response jsonrpc.Response, url string) { 408 | assert.Equal(t, "2.0", response.Version) 409 | assert.Equal(t, 1, response.ID.Value()) 410 | assert.Nil(t, response.Error) 411 | 412 | var result ToolCallResponse 413 | resultBytes, err := json.Marshal(response.Result) 414 | require.NoError(t, err) 415 | err = json.Unmarshal(resultBytes, &result) 416 | require.NoError(t, err) 417 | 418 | assert.Len(t, result.Content, 1) 419 | assert.False(t, result.IsError) 420 | 421 | content := result.Content[0] 422 | assert.Equal(t, "text", content.Type) 423 | assert.NotNil(t, content.Annotations) 424 | assert.Contains(t, content.Annotations.Audience, RoleAssistant) 425 | 426 | var textContent Content 427 | contentBytes, err := json.Marshal(content) 428 | assert.NoError(t, err) 429 | err = json.Unmarshal(contentBytes, &textContent) 430 | assert.NoError(t, err) 431 | 432 | var pets []interface{} 433 | err = json.Unmarshal([]byte(textContent.Text), &pets) 434 | assert.NoError(t, err) 435 | assert.Len(t, pets, 2) 436 | 437 | for _, pet := range pets { 438 | petMap := pet.(map[string]interface{}) 439 | assert.Equal(t, "dog", petMap["type"]) 440 | } 441 | }, 442 | }, 443 | { 444 | name: "GET request with array query parameters", 445 | server: server, 446 | setup: func(t *testing.T, ts *httptest.Server) http.HandlerFunc { 447 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 448 | // Verify the query parameters 449 | assert.Equal(t, "dog", r.URL.Query().Get("type")) 450 | // The fields parameter should be a comma-separated list 451 | assert.Equal(t, "name,age,breed", r.URL.Query().Get("fields")) 452 | 453 | w.Header().Set("Content-Type", "application/json") 454 | json.NewEncoder(w).Encode(map[string]interface{}{ 455 | "success": true, 456 | "query": map[string]string{ 457 | "type": r.URL.Query().Get("type"), 458 | "fields": r.URL.Query().Get("fields"), 459 | }, 460 | }) 461 | }) 462 | }, 463 | request: jsonrpc.NewRequest("tools/call", json.RawMessage(`{ 464 | "name": "listPets", 465 | "arguments": { 466 | "type": "dog", 467 | "fields": ["name", "age", "breed"] 468 | } 469 | }`), 7), 470 | validate: func(t *testing.T, response jsonrpc.Response, requestURL string) { 471 | assert.Equal(t, "2.0", response.Version) 472 | assert.Equal(t, 7, response.ID.Value()) 473 | assert.Nil(t, response.Error) 474 | 475 | var result ToolCallResponse 476 | resultBytes, err := json.Marshal(response.Result) 477 | require.NoError(t, err) 478 | err = json.Unmarshal(resultBytes, &result) 479 | require.NoError(t, err) 480 | 481 | assert.Len(t, result.Content, 1) 482 | assert.False(t, result.IsError) 483 | 484 | // Parse the URL to verify parameters 485 | parsedURL, err := url.Parse(requestURL) 486 | require.NoError(t, err) 487 | 488 | params := parsedURL.Query() 489 | assert.Equal(t, "dog", params.Get("type")) 490 | assert.Equal(t, "name,age,breed", params.Get("fields")) 491 | }, 492 | }, 493 | } 494 | 495 | for _, tt := range tests { 496 | t.Run(tt.name, func(t *testing.T) { 497 | var capturedURL string 498 | if tt.setup != nil { 499 | handler := tt.setup(t, ts) 500 | // Wrap the handler to capture the URL 501 | wrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 502 | capturedURL = r.URL.String() 503 | handler.ServeHTTP(w, r) 504 | }) 505 | ts.Config.Handler = wrappedHandler 506 | } 507 | 508 | var response jsonrpc.Response 509 | if tt.server != nil { 510 | response = *tt.server.HandleRequest(tt.request) 511 | } else { 512 | response = *server.HandleRequest(tt.request) 513 | } 514 | 515 | tt.validate(t, response, capturedURL) 516 | }) 517 | } 518 | } 519 | 520 | func TestHandleInvalidMethod(t *testing.T) { 521 | server, ts := setupTestServer(t) 522 | defer ts.Close() 523 | 524 | request := jsonrpc.NewRequest("invalid/method", nil, 1) 525 | 526 | response := server.HandleRequest(request) 527 | 528 | assert.Equal(t, "2.0", response.Version) 529 | assert.Equal(t, 1, response.ID.Value()) 530 | assert.NotNil(t, response.Error) 531 | assert.Equal(t, int(jsonrpc.ErrMethodNotFound), int(response.Error.Code)) 532 | assert.Equal(t, "Method not found", response.Error.Message) 533 | } 534 | 535 | func TestWithSpecData(t *testing.T) { 536 | tests := []struct { 537 | name string 538 | spec string 539 | wantErr bool 540 | assert func(*testing.T, *Server) 541 | }{ 542 | { 543 | name: "valid spec with servers", 544 | spec: `{ 545 | "openapi": "3.0.0", 546 | "info": { 547 | "title": "Test API", 548 | "version": "1.0.0" 549 | }, 550 | "servers": [ 551 | { 552 | "url": "https://api.example.com" 553 | } 554 | ], 555 | "paths": {} 556 | }`, 557 | assert: func(t *testing.T, s *Server) { 558 | assert.Equal(t, "https://api.example.com", s.baseURL) 559 | }, 560 | }, 561 | { 562 | name: "invalid spec", 563 | spec: `{"openapi": "3.0.0", "invalid": true`, 564 | wantErr: true, 565 | }, 566 | { 567 | name: "spec without servers", 568 | spec: `{ 569 | "openapi": "3.0.0", 570 | "info": { 571 | "title": "Test API", 572 | "version": "1.0.0" 573 | }, 574 | "paths": {} 575 | }`, 576 | wantErr: true, 577 | }, 578 | { 579 | name: "spec with empty server URL", 580 | spec: `{ 581 | "openapi": "3.0.0", 582 | "servers": [ 583 | { 584 | "url": "" 585 | } 586 | ], 587 | "paths": {} 588 | }`, 589 | wantErr: true, 590 | }, 591 | } 592 | 593 | for _, tt := range tests { 594 | t.Run(tt.name, func(t *testing.T) { 595 | server, err := NewServer(WithSpecData([]byte(tt.spec))) 596 | if tt.wantErr { 597 | assert.Error(t, err) 598 | if tt.name == "spec without servers" { 599 | assert.Contains(t, err.Error(), "must include at least one server URL") 600 | } 601 | return 602 | } 603 | 604 | assert.NoError(t, err) 605 | assert.NotNil(t, server.doc) 606 | assert.NotNil(t, server.model) 607 | 608 | if tt.assert != nil { 609 | tt.assert(t, server) 610 | } 611 | }) 612 | } 613 | } 614 | 615 | func TestWithAuth(t *testing.T) { 616 | tests := []struct { 617 | name string 618 | auth string 619 | wantErr bool 620 | assert func(*testing.T, *Server) 621 | }{ 622 | { 623 | name: "valid bearer token", 624 | auth: "Bearer mytoken123", 625 | assert: func(t *testing.T, s *Server) { 626 | assert.Equal(t, "Bearer mytoken123", s.auth) 627 | }, 628 | }, 629 | { 630 | name: "valid basic auth", 631 | auth: "Basic dXNlcjpwYXNz", 632 | assert: func(t *testing.T, s *Server) { 633 | assert.Equal(t, "Basic dXNlcjpwYXNz", s.auth) 634 | }, 635 | }, 636 | { 637 | name: "missing auth type", 638 | auth: "mytoken123", 639 | wantErr: true, 640 | }, 641 | { 642 | name: "empty auth", 643 | auth: "", 644 | wantErr: true, 645 | }, 646 | { 647 | name: "whitespace only", 648 | auth: " ", 649 | wantErr: true, 650 | }, 651 | } 652 | 653 | // Create a minimal valid spec for server initialization 654 | validSpec := `{ 655 | "openapi": "3.0.0", 656 | "servers": [{"url": "https://api.example.com"}], 657 | "paths": {} 658 | }` 659 | 660 | for _, tt := range tests { 661 | t.Run(tt.name, func(t *testing.T) { 662 | server, err := NewServer( 663 | WithSpecData([]byte(validSpec)), 664 | WithAuth(tt.auth), 665 | ) 666 | 667 | if tt.wantErr { 668 | assert.Error(t, err) 669 | assert.Nil(t, server) 670 | return 671 | } 672 | 673 | assert.NoError(t, err) 674 | assert.NotNil(t, server) 675 | 676 | if tt.assert != nil { 677 | tt.assert(t, server) 678 | } 679 | 680 | // Verify the auth header is properly set in the client transport 681 | transport, ok := server.client.Transport.(*internal.HeaderTransport) 682 | assert.True(t, ok) 683 | assert.Equal(t, tt.auth, transport.Headers.Get("Authorization")) 684 | }) 685 | } 686 | } 687 | 688 | func TestPathJoining(t *testing.T) { 689 | tests := []struct { 690 | name string 691 | baseURL string 692 | path string 693 | expected string 694 | }{ 695 | { 696 | name: "simple paths", 697 | baseURL: "https://api.example.com", 698 | path: "/pets", 699 | expected: "https://api.example.com/pets", 700 | }, 701 | { 702 | name: "base URL with trailing slash", 703 | baseURL: "https://api.example.com/", 704 | path: "/pets", 705 | expected: "https://api.example.com/pets", 706 | }, 707 | { 708 | name: "base URL with path", 709 | baseURL: "https://api.example.com/v1", 710 | path: "/pets", 711 | expected: "https://api.example.com/v1/pets", 712 | }, 713 | { 714 | name: "base URL with path and trailing slash", 715 | baseURL: "https://api.example.com/v1/", 716 | path: "/pets", 717 | expected: "https://api.example.com/v1/pets", 718 | }, 719 | { 720 | name: "path without leading slash", 721 | baseURL: "https://api.example.com/v1", 722 | path: "pets", 723 | expected: "https://api.example.com/v1/pets", 724 | }, 725 | { 726 | name: "multiple path segments", 727 | baseURL: "https://api.example.com/v1", 728 | path: "/pets/dogs", 729 | expected: "https://api.example.com/v1/pets/dogs", 730 | }, 731 | { 732 | name: "multiple slashes in path", 733 | baseURL: "https://api.example.com/v1/", 734 | path: "//pets///dogs", 735 | expected: "https://api.example.com/v1/pets/dogs", 736 | }, 737 | } 738 | 739 | for _, tt := range tests { 740 | t.Run(tt.name, func(t *testing.T) { 741 | // Create a mock HTTP server first 742 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 743 | // Parse the request URL and compare with expected 744 | actualPath := path.Clean(r.URL.Path) 745 | expectedPath := path.Clean(tt.path) 746 | if !strings.HasPrefix(expectedPath, "/") { 747 | expectedPath = "/" + expectedPath 748 | } 749 | assert.Equal(t, expectedPath, actualPath) 750 | 751 | w.WriteHeader(http.StatusOK) 752 | w.Write([]byte("{}")) 753 | })) 754 | defer ts.Close() 755 | 756 | // Create a test spec with the test server URL 757 | spec := fmt.Sprintf(`{ 758 | "openapi": "3.0.0", 759 | "servers": [{"url": "%s"}], 760 | "paths": { 761 | "%s": { 762 | "get": { 763 | "operationId": "testOperation" 764 | } 765 | } 766 | } 767 | }`, ts.URL, tt.path) 768 | 769 | server, err := NewServer( 770 | WithSpecData([]byte(spec)), 771 | WithClient(ts.Client()), 772 | ) 773 | require.NoError(t, err) 774 | 775 | // Make a test request 776 | request := jsonrpc.NewRequest("tools/call", json.RawMessage(`{"name": "testOperation"}`), 1) 777 | response := server.HandleRequest(request) 778 | 779 | // Verify the request was successful 780 | assert.Nil(t, response.Error) 781 | }) 782 | } 783 | } 784 | 785 | func TestToolNameMapping(t *testing.T) { 786 | tests := []struct { 787 | name string 788 | operationId string 789 | wantLen int 790 | wantPrefix string 791 | }{ 792 | { 793 | name: "short operation ID", 794 | operationId: "listPets", 795 | wantLen: 8, 796 | wantPrefix: "listPets", 797 | }, 798 | { 799 | name: "exactly 64 characters", 800 | operationId: strings.Repeat("a", 64), 801 | wantLen: 64, 802 | wantPrefix: strings.Repeat("a", 64), 803 | }, 804 | { 805 | name: "long operation ID", 806 | operationId: "thisIsAVeryLongOperationIdThatExceedsTheSixtyFourCharacterLimitAndNeedsToBeHandledProperly", 807 | wantLen: 64, 808 | wantPrefix: "thisIsAVeryLongOperationIdThatExceedsTheSixtyFourCharac", // 55 chars 809 | }, 810 | { 811 | name: "multiple long IDs generate different names", 812 | operationId: "anotherVeryLongOperationIdThatExceedsTheSixtyFourCharacterLimitAndNeedsToBeHandledProperly", 813 | wantLen: 64, 814 | wantPrefix: "anotherVeryLongOperationIdThatExceedsTheSixtyFourCharac", // 55 chars 815 | }, 816 | } 817 | 818 | // Store generated names to check for uniqueness 819 | generatedNames := make(map[string]string) 820 | 821 | for _, tt := range tests { 822 | t.Run(tt.name, func(t *testing.T) { 823 | // Get the tool name 824 | toolName := getToolName(tt.operationId) 825 | 826 | // Check length constraints 827 | assert.Equal(t, tt.wantLen, len(toolName), "tool name length mismatch") 828 | 829 | // For long names, verify the structure 830 | if len(tt.operationId) > 64 { 831 | // Verify the prefix is exactly 55 characters 832 | prefix := toolName[:55] 833 | assert.Equal(t, tt.wantPrefix, prefix, "prefix mismatch") 834 | 835 | // Check that there's an underscore separator at position 55 836 | assert.Equal(t, "_", string(toolName[55]), "underscore separator not found at position 55") 837 | 838 | // Verify hash part length (should be 8 characters) 839 | hash := toolName[56:] 840 | assert.Equal(t, 8, len(hash), "hash suffix should be 8 characters") 841 | 842 | // Verify the hash is URL-safe base64 843 | for _, c := range hash { 844 | assert.Contains(t, 845 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", 846 | string(c), 847 | "hash should only contain URL-safe base64 characters") 848 | } 849 | } else { 850 | // For short names, verify exact match 851 | assert.Equal(t, tt.wantPrefix, toolName, "tool name mismatch for short operation ID") 852 | } 853 | 854 | // Check bijectivity - each operation ID should generate a unique tool name 855 | if existing, exists := generatedNames[toolName]; exists { 856 | assert.Equal(t, tt.operationId, existing, 857 | "tool name collision detected: same name generated for different operation IDs") 858 | } 859 | generatedNames[toolName] = tt.operationId 860 | }) 861 | } 862 | } 863 | 864 | func TestFindOperationByToolName(t *testing.T) { 865 | // Create a test spec with a mix of short and long operation IDs 866 | longOpId := "thisIsAVeryLongOperationIdThatExceedsTheSixtyFourCharacterLimitAndNeedsToBeHandledProperly" 867 | spec := fmt.Sprintf(`{ 868 | "openapi": "3.0.0", 869 | "servers": [{"url": "https://api.example.com"}], 870 | "paths": { 871 | "/pets": { 872 | "get": { 873 | "operationId": "listPets", 874 | "description": "List pets" 875 | } 876 | }, 877 | "/very/long/path": { 878 | "post": { 879 | "operationId": "%s", 880 | "description": "Operation with long ID" 881 | } 882 | } 883 | } 884 | }`, longOpId) 885 | 886 | server, err := NewServer(WithSpecData([]byte(spec))) 887 | require.NoError(t, err) 888 | 889 | tests := []struct { 890 | name string 891 | toolName string 892 | wantMethod string 893 | wantPath string 894 | wantFound bool 895 | }{ 896 | { 897 | name: "find short operation ID", 898 | toolName: "listPets", 899 | wantMethod: "GET", 900 | wantPath: "/pets", 901 | wantFound: true, 902 | }, 903 | { 904 | name: "find long operation ID", 905 | toolName: getToolName(longOpId), 906 | wantMethod: "POST", 907 | wantPath: "/very/long/path", 908 | wantFound: true, 909 | }, 910 | { 911 | name: "operation not found", 912 | toolName: "nonexistentOperation", 913 | wantFound: false, 914 | }, 915 | } 916 | 917 | for _, tt := range tests { 918 | t.Run(tt.name, func(t *testing.T) { 919 | method, path, operation, pathItem, found := server.findOperationByToolName(tt.toolName) 920 | 921 | assert.Equal(t, tt.wantFound, found) 922 | if tt.wantFound { 923 | assert.Equal(t, tt.wantMethod, method) 924 | assert.Equal(t, tt.wantPath, path) 925 | assert.NotNil(t, operation) 926 | assert.NotNil(t, pathItem) 927 | } else { 928 | assert.Empty(t, method) 929 | assert.Empty(t, path) 930 | assert.Nil(t, operation) 931 | assert.Nil(t, pathItem) 932 | } 933 | }) 934 | } 935 | } 936 | 937 | func TestHandleInitializedNotification(t *testing.T) { 938 | // Create a test server with a logger 939 | server, ts := setupTestServer(t) 940 | defer ts.Close() 941 | 942 | // Add a logger to the server 943 | server.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) 944 | 945 | // Create an initialized notification 946 | notification := jsonrpc.NewRequest("initialized", nil, 1) 947 | 948 | // Handle the notification 949 | response := server.HandleRequest(notification) 950 | 951 | // Verify that notifications don't generate a response 952 | assert.Nil(t, response, "notifications should not generate a response") 953 | } 954 | -------------------------------------------------------------------------------- /mcp/transport.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "time" 11 | 12 | "github.com/loopwork-ai/emcee/jsonrpc" 13 | "golang.org/x/sync/errgroup" 14 | "golang.org/x/sys/unix" 15 | ) 16 | 17 | // Transport handles the communication between stdin/stdout and the MCP server 18 | type Transport struct { 19 | reader io.Reader 20 | writer io.Writer 21 | logger io.Writer 22 | } 23 | 24 | // NewStdioTransport creates a new stdio transport 25 | func NewStdioTransport(in io.Reader, out io.Writer, logger io.Writer) *Transport { 26 | return &Transport{ 27 | reader: in, 28 | writer: out, 29 | logger: logger, 30 | } 31 | } 32 | 33 | // setupNonBlockingFd duplicates a file descriptor and sets it to non-blocking mode 34 | func setupNonBlockingFd(f interface{}) (fd int, cleanup func() error, err error) { 35 | file, ok := f.(*os.File) 36 | if !ok { 37 | return -1, func() error { return nil }, nil 38 | } 39 | 40 | fd, err = unix.Dup(int(file.Fd())) 41 | if err != nil { 42 | return -1, func() error { return nil }, fmt.Errorf("failed to duplicate file descriptor: %w", err) 43 | } 44 | 45 | cleanup = func() error { return unix.Close(fd) } 46 | 47 | if err := unix.SetNonblock(fd, true); err != nil { 48 | cleanup() 49 | return -1, func() error { return nil }, fmt.Errorf("failed to set non-blocking mode: %w", err) 50 | } 51 | 52 | return fd, cleanup, nil 53 | } 54 | 55 | // Run starts the transport loop, reading from stdin and writing to stdout 56 | func (t *Transport) Run(ctx context.Context, handler func(jsonrpc.Request) *jsonrpc.Response) error { 57 | g, ctx := errgroup.WithContext(ctx) 58 | lines := make(chan string) 59 | responses := make(chan *jsonrpc.Response) 60 | 61 | // Writer goroutine 62 | g.Go(func() error { 63 | fd, cleanup, err := setupNonBlockingFd(t.writer) 64 | if err != nil { 65 | return err 66 | } 67 | defer cleanup() 68 | 69 | var buf bytes.Buffer 70 | buf.Grow(4096) 71 | for { 72 | select { 73 | case <-ctx.Done(): 74 | return nil 75 | case response, ok := <-responses: 76 | if !ok { 77 | return nil 78 | } 79 | 80 | buf.Reset() 81 | enc := json.NewEncoder(&buf) 82 | if err := enc.Encode(response); err != nil { 83 | fmt.Fprintf(t.logger, "Error marshaling response: %v\n", err) 84 | continue 85 | } 86 | 87 | data := buf.Bytes() 88 | for len(data) > 0 { 89 | select { 90 | case <-ctx.Done(): 91 | return nil 92 | default: 93 | var n int 94 | var err error 95 | 96 | if fd != -1 { 97 | n, err = unix.Write(fd, data) 98 | } else { 99 | n, err = t.writer.Write(data) 100 | } 101 | 102 | if err != nil { 103 | if fd != -1 && err == unix.EAGAIN { 104 | time.Sleep(time.Millisecond) 105 | continue 106 | } 107 | if err == io.EOF { 108 | return nil 109 | } 110 | return err 111 | } 112 | if n == 0 { 113 | return nil 114 | } 115 | 116 | data = data[n:] 117 | } 118 | } 119 | } 120 | } 121 | }) 122 | 123 | // Reader goroutine 124 | g.Go(func() error { 125 | fd, cleanup, err := setupNonBlockingFd(t.reader) 126 | if err != nil { 127 | return err 128 | } 129 | defer cleanup() 130 | defer close(lines) 131 | 132 | buf := make([]byte, 1) 133 | line := make([]byte, 0, 4096) 134 | for { 135 | select { 136 | case <-ctx.Done(): 137 | return nil 138 | default: 139 | var n int 140 | var err error 141 | 142 | if fd != -1 { 143 | n, err = unix.Read(fd, buf) 144 | } else { 145 | n, err = t.reader.Read(buf) 146 | } 147 | 148 | if err != nil { 149 | if fd != -1 && err == unix.EAGAIN { 150 | time.Sleep(time.Millisecond) 151 | continue 152 | } 153 | if err == io.EOF { 154 | return nil 155 | } 156 | return err 157 | } 158 | if n == 0 { 159 | return nil 160 | } 161 | 162 | if buf[0] == '\n' || buf[0] == '\r' { 163 | if len(line) > 0 { 164 | select { 165 | case <-ctx.Done(): 166 | return nil 167 | case lines <- string(line): 168 | line = line[:0] 169 | } 170 | } 171 | continue 172 | } 173 | 174 | line = append(line, buf[0]) 175 | } 176 | } 177 | }) 178 | 179 | // Handler goroutine 180 | g.Go(func() error { 181 | defer close(responses) 182 | for { 183 | select { 184 | case <-ctx.Done(): 185 | return nil 186 | case line, ok := <-lines: 187 | if !ok { 188 | return nil 189 | } 190 | if line == "" { 191 | continue 192 | } 193 | 194 | var request jsonrpc.Request 195 | if err := json.Unmarshal([]byte(line), &request); err != nil { 196 | response := jsonrpc.NewResponse(nil, nil, jsonrpc.NewError(jsonrpc.ErrParse, err)) 197 | select { 198 | case <-ctx.Done(): 199 | return nil 200 | case responses <- &response: 201 | } 202 | continue 203 | } 204 | 205 | response := handler(request) 206 | if response != nil { 207 | select { 208 | case <-ctx.Done(): 209 | return nil 210 | case responses <- response: 211 | } 212 | } 213 | } 214 | } 215 | }) 216 | 217 | return g.Wait() 218 | } 219 | -------------------------------------------------------------------------------- /mcp/transport_test.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | 11 | "net/http/httptest" 12 | 13 | "github.com/loopwork-ai/emcee/jsonrpc" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | type mockServer struct { 19 | handleRequestFunc func(jsonrpc.Request) *jsonrpc.Response 20 | } 21 | 22 | func (m *mockServer) Handle(req jsonrpc.Request) *jsonrpc.Response { 23 | return m.handleRequestFunc(req) 24 | } 25 | 26 | func TestTransport_Run(t *testing.T) { 27 | tests := []struct { 28 | name string 29 | input string 30 | mockResponse jsonrpc.Response 31 | expectedOut string 32 | expectedErr string 33 | expectSuccess bool 34 | }{ 35 | { 36 | name: "successful request", 37 | input: `{"jsonrpc": "2.0", "method": "tools/list", "id": 1}`, 38 | mockResponse: jsonrpc.NewResponse(1, map[string]interface{}{ 39 | "tools": []interface{}{}, 40 | }, nil), 41 | expectedOut: `{"jsonrpc":"2.0","result":{"tools":[]},"id":1} 42 | `, 43 | expectSuccess: true, 44 | }, 45 | { 46 | name: "invalid JSON request", 47 | input: `{"jsonrpc": "2.0" method: invalid}`, 48 | expectedOut: `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error","data":{"Offset":19}},"id":0} 49 | `, 50 | expectSuccess: true, 51 | }, 52 | { 53 | name: "multiple requests", 54 | input: `{"jsonrpc": "2.0", "method": "tools/list", "id": 1} 55 | {"jsonrpc": "2.0", "method": "tools/call", "id": 2}`, 56 | mockResponse: jsonrpc.NewResponse(0, "success", nil), 57 | expectedOut: `{"jsonrpc":"2.0","result":"success","id":0} 58 | {"jsonrpc":"2.0","result":"success","id":0} 59 | `, 60 | expectSuccess: true, 61 | }, 62 | { 63 | name: "empty input", 64 | input: "", 65 | expectSuccess: true, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | // Setup mock server 72 | mockServer := &mockServer{ 73 | handleRequestFunc: func(req jsonrpc.Request) *jsonrpc.Response { 74 | return &tt.mockResponse 75 | }, 76 | } 77 | 78 | // Ensure input ends with newline 79 | input := tt.input 80 | if input != "" && !strings.HasSuffix(input, "\n") { 81 | input += "\n" 82 | } 83 | 84 | // Setup input and output buffers 85 | in := strings.NewReader(input) 86 | out := &bytes.Buffer{} 87 | errOut := &bytes.Buffer{} 88 | 89 | // Create and run transport 90 | transport := NewStdioTransport(in, out, errOut) 91 | err := transport.Run(context.Background(), mockServer.Handle) 92 | 93 | if tt.expectSuccess { 94 | assert.NoError(t, err) 95 | if tt.expectedOut != "" { 96 | assert.Equal(t, tt.expectedOut, out.String()) 97 | } 98 | if tt.expectedErr != "" { 99 | assert.Equal(t, tt.expectedErr, errOut.String()) 100 | } 101 | } else { 102 | assert.Error(t, err) 103 | } 104 | }) 105 | } 106 | } 107 | 108 | func TestTransport_Integration(t *testing.T) { 109 | // Create a minimal OpenAPI spec for testing 110 | specData := []byte(`{ 111 | "openapi": "3.0.0", 112 | "info": { 113 | "title": "Test API", 114 | "version": "1.0.0" 115 | }, 116 | "servers": [ 117 | { 118 | "url": "http://api.example.com" 119 | } 120 | ], 121 | "paths": {} 122 | }`) 123 | 124 | // Create a test HTTP server to serve the spec 125 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 126 | w.Write(specData) 127 | })) 128 | defer ts.Close() 129 | 130 | // Create a real server instance 131 | server, err := NewServer( 132 | WithClient(http.DefaultClient), 133 | WithSpecData(specData), 134 | ) 135 | require.NoError(t, err) 136 | 137 | // Test tools/list request 138 | input := `{"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1} 139 | ` 140 | in := strings.NewReader(input) 141 | out := &bytes.Buffer{} 142 | errOut := &bytes.Buffer{} 143 | 144 | transport := NewStdioTransport(in, out, errOut) 145 | err = transport.Run(context.Background(), server.HandleRequest) 146 | require.NoError(t, err) 147 | 148 | // Verify the response 149 | var response jsonrpc.Response 150 | err = json.NewDecoder(bytes.NewReader(out.Bytes())).Decode(&response) 151 | require.NoError(t, err) 152 | assert.Equal(t, "2.0", response.Version) 153 | assert.Equal(t, 1, response.ID.Value()) 154 | assert.Nil(t, response.Error) 155 | 156 | // Verify the response contains a tools list 157 | result, ok := response.Result.(map[string]interface{}) 158 | require.True(t, ok) 159 | tools, ok := result["tools"].([]interface{}) 160 | require.True(t, ok) 161 | assert.NotNil(t, tools) 162 | 163 | // Test tools/call request 164 | input = `{"jsonrpc": "2.0", "method": "tools/call", "params": {}, "id": 2} 165 | ` 166 | in = strings.NewReader(input) 167 | out = &bytes.Buffer{} 168 | errOut = &bytes.Buffer{} 169 | 170 | transport = NewStdioTransport(in, out, errOut) 171 | err = transport.Run(context.Background(), server.HandleRequest) 172 | require.NoError(t, err) 173 | 174 | // Verify the response 175 | err = json.NewDecoder(bytes.NewReader(out.Bytes())).Decode(&response) 176 | require.NoError(t, err) 177 | assert.Equal(t, "2.0", response.Version) 178 | assert.Equal(t, 2, response.ID.Value()) 179 | assert.Equal(t, jsonrpc.ErrMethodNotFound, response.Error.Code) 180 | assert.Equal(t, "Method not found", response.Error.Message) 181 | 182 | // Test tools/call request 183 | input = `{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "test"}, "id": 4} 184 | ` 185 | in = strings.NewReader(input) 186 | out = &bytes.Buffer{} 187 | errOut = &bytes.Buffer{} 188 | 189 | transport = NewStdioTransport(in, out, errOut) 190 | err = transport.Run(context.Background(), server.HandleRequest) 191 | require.NoError(t, err) 192 | 193 | // Verify the response 194 | err = json.NewDecoder(bytes.NewReader(out.Bytes())).Decode(&response) 195 | require.NoError(t, err) 196 | assert.Equal(t, "2.0", response.Version) 197 | assert.Equal(t, 4, response.ID.Value()) 198 | 199 | assert.Equal(t, jsonrpc.ErrMethodNotFound, response.Error.Code) 200 | assert.Equal(t, "Method not found", response.Error.Message) 201 | 202 | // Test tools/call request 203 | input = `{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "test", "arguments": {}}, "id": 3} 204 | ` 205 | in = strings.NewReader(input) 206 | out = &bytes.Buffer{} 207 | errOut = &bytes.Buffer{} 208 | 209 | transport = NewStdioTransport(in, out, errOut) 210 | err = transport.Run(context.Background(), server.HandleRequest) 211 | require.NoError(t, err) 212 | 213 | // Verify the response 214 | err = json.NewDecoder(bytes.NewReader(out.Bytes())).Decode(&response) 215 | require.NoError(t, err) 216 | assert.Equal(t, "2.0", response.Version) 217 | assert.Equal(t, 3, response.ID.Value()) 218 | assert.NotNil(t, response.Error) 219 | 220 | // Test tools/list request 221 | input = `{"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1} 222 | ` 223 | in = strings.NewReader(input) 224 | out = &bytes.Buffer{} 225 | errOut = &bytes.Buffer{} 226 | 227 | transport = NewStdioTransport(in, out, errOut) 228 | err = transport.Run(context.Background(), server.HandleRequest) 229 | require.NoError(t, err) 230 | 231 | // Verify the response 232 | err = json.NewDecoder(bytes.NewReader(out.Bytes())).Decode(&response) 233 | require.NoError(t, err) 234 | assert.Equal(t, "2.0", response.Version) 235 | assert.Equal(t, 1, response.ID.Value()) 236 | assert.NotNil(t, response.Error) 237 | } 238 | -------------------------------------------------------------------------------- /testdata/claude_desktop_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "weather": { 4 | "command": "emcee", 5 | "args": ["https://api.weather.gov/openapi.json"] 6 | }, 7 | "x (formerly twitter)": { 8 | "command": "emcee", 9 | "args": ["https://api.x.com/2/openapi.json", "--bearer-auth=$X_API_KEY"] 10 | }, 11 | "openai": { 12 | "command": "emcee", 13 | "args": [ 14 | "https://raw.githubusercontent.com/openai/openai-openapi/refs/heads/master/openapi.yaml", 15 | "--bearer-auth=$OPEN_AI_API_KEY" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tools/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # This script should be run via curl: 4 | # sh -c "$(curl -fsSL https://raw.githubusercontent.com/loopwork-ai/emcee/main/tools/install.sh)" 5 | # or via wget: 6 | # sh -c "$(wget -qO- https://raw.githubusercontent.com/loopwork-ai/emcee/main/tools/install.sh)" 7 | # or via fetch: 8 | # sh -c "$(fetch -o - https://raw.githubusercontent.com/loopwork-ai/emcee/main/tools/install.sh)" 9 | # 10 | # As an alternative, you can first download the install script and run it afterwards: 11 | # wget https://raw.githubusercontent.com/loopwork-ai/emcee/main/tools/install.sh 12 | # sh install.sh 13 | # 14 | # You can tweak the install location by setting the INSTALL_DIR env var when running the script. 15 | # INSTALL_DIR=~/my/custom/install/location sh install.sh 16 | # 17 | # By default, emcee will be installed at /usr/local/bin/emcee 18 | # 19 | # This install script is based on that of ohmyzsh[1], which is licensed under the MIT License 20 | # [1] https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh 21 | 22 | set -e 23 | 24 | # Make sure important variables exist if not already defined 25 | DEFAULT_INSTALL_DIR="/usr/local/bin" 26 | INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR} 27 | 28 | command_exists() { 29 | command -v "$@" >/dev/null 2>&1 30 | } 31 | 32 | user_can_sudo() { 33 | # Check if sudo is installed 34 | command_exists sudo || return 1 35 | # Termux can't run sudo, so we can detect it and exit the function early. 36 | case "$PREFIX" in 37 | *com.termux*) return 1 ;; 38 | esac 39 | ! LANG= sudo -n -v 2>&1 | grep -q "may not run sudo" 40 | } 41 | 42 | setup_color() { 43 | # Only use colors if connected to a terminal 44 | if [ -t 1 ]; then 45 | FMT_RED=$(printf '\033[31m') 46 | FMT_GREEN=$(printf '\033[32m') 47 | FMT_YELLOW=$(printf '\033[33m') 48 | FMT_BLUE=$(printf '\033[34m') 49 | FMT_BOLD=$(printf '\033[1m') 50 | FMT_RESET=$(printf '\033[0m') 51 | else 52 | FMT_RED="" 53 | FMT_GREEN="" 54 | FMT_YELLOW="" 55 | FMT_BLUE="" 56 | FMT_BOLD="" 57 | FMT_RESET="" 58 | fi 59 | } 60 | 61 | get_platform() { 62 | platform="$(uname -s)_$(uname -m)" 63 | case "$platform" in 64 | "Darwin_arm64"|"Darwin_x86_64"|"Linux_arm64"|"Linux_i386"|"Linux_x86_64") 65 | echo "$platform" 66 | return 0 67 | ;; 68 | *) 69 | echo "Unsupported platform: $platform" 70 | return 1 71 | ;; 72 | esac 73 | } 74 | 75 | setup_emcee() { 76 | EMCEE_LOCATION="${INSTALL_DIR}/emcee" 77 | platform=$(get_platform) || exit 1 78 | 79 | BINARY_URI="https://github.com/loopwork-ai/emcee/releases/latest/download/emcee_${platform}.tar.gz" 80 | 81 | if [ -f "$EMCEE_LOCATION" ]; then 82 | echo "${FMT_YELLOW}A file already exists at $EMCEE_LOCATION${FMT_RESET}" 83 | printf "${FMT_YELLOW}Do you want to delete this file and continue with this installation anyway?${FMT_RESET}\n" 84 | read -p "Delete file? (y/N): " choice 85 | case "$choice" in 86 | y|Y ) echo "Deleting existing file and continuing with installation..."; sudo rm $EMCEE_LOCATION;; 87 | * ) echo "Exiting installation."; exit 1;; 88 | esac 89 | fi 90 | 91 | echo "${FMT_BLUE}Downloading emcee...${FMT_RESET}" 92 | 93 | TMP_DIR=$(mktemp -d) 94 | if command_exists curl; then 95 | curl -L "$BINARY_URI" | tar xz -C "$TMP_DIR" 96 | elif command_exists wget; then 97 | wget -O- "$BINARY_URI" | tar xz -C "$TMP_DIR" 98 | elif command_exists fetch; then 99 | fetch -o- "$BINARY_URI" | tar xz -C "$TMP_DIR" 100 | else 101 | echo "${FMT_RED}Error: One of curl, wget, or fetch must be present for this installer to work.${FMT_RESET}" 102 | exit 1 103 | fi 104 | 105 | sudo mv "$TMP_DIR/emcee" "$EMCEE_LOCATION" 106 | rm -rf "$TMP_DIR" 107 | sudo chmod +x "$EMCEE_LOCATION" 108 | 109 | SHELL_NAME=$(basename "$SHELL") 110 | if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then 111 | echo "Adding $INSTALL_DIR to PATH in .$SHELL_NAME"rc 112 | echo "" >> ~/.$SHELL_NAME"rc" 113 | echo "# Created by \`emcee\` install script on $(date)" >> ~/.$SHELL_NAME"rc" 114 | echo "export PATH=\$PATH:$INSTALL_DIR" >> ~/.$SHELL_NAME"rc" 115 | echo "You may need to open a new terminal window to run emcee for the first time." 116 | fi 117 | 118 | trap 'rm -rf "$TMP_DIR"' EXIT 119 | } 120 | 121 | print_success() { 122 | echo "${FMT_GREEN}Successfully installed emcee.${FMT_RESET}" 123 | echo "${FMT_BLUE}Run \`emcee --help\` to get started${FMT_RESET}" 124 | } 125 | 126 | main() { 127 | setup_color 128 | 129 | # Check if `emcee` command already exists 130 | if command_exists emcee; then 131 | echo "${FMT_YELLOW}An emcee command already exists on your system at the following location: $(which emcee)${FMT_RESET}" 132 | echo "The installations may interfere with one another." 133 | printf "${FMT_YELLOW}Do you want to continue with this installation anyway?${FMT_RESET}\n" 134 | read -p "Continue? (y/N): " choice 135 | case "$choice" in 136 | y|Y ) echo "Continuing with installation...";; 137 | * ) echo "Exiting installation."; exit 1;; 138 | esac 139 | fi 140 | 141 | # Check the users sudo privileges 142 | if [ ! "$(user_can_sudo)" ] && [ "${SUDO}" != "" ]; then 143 | echo "${FMT_RED}You need sudo permissions to run this install script. Please try again as a sudoer.${FMT_RESET}" 144 | exit 1 145 | fi 146 | 147 | setup_emcee 148 | 149 | if command_exists emcee; then 150 | print_success 151 | else 152 | echo "${FMT_RED}Error: emcee not installed.${FMT_RESET}" 153 | exit 1 154 | fi 155 | } 156 | 157 | main "$@" --------------------------------------------------------------------------------