├── .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 | 
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 | 
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 |
66 |
67 | If you allow, Claude will communicate with the MCP
68 | and use the result to inform its response.
69 |
70 | 
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 |
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 "$@"
--------------------------------------------------------------------------------