├── .gitignore
├── jsonrpc
├── version.go
├── request.go
├── response.go
├── error.go
└── id.go
├── testdata
└── claude_desktop_config.json
├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .goreleaser.yaml
├── LICENSE.md
├── go.mod
├── internal
├── secret.go
├── http.go
├── secret_test.go
└── openapi.go
├── tools
└── install.sh
├── go.sum
├── main_test.go
├── cmd
└── emcee
│ └── main.go
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | ~/
2 | /emcee
3 | dist/
4 |
5 | .DS_Store
--------------------------------------------------------------------------------
/jsonrpc/version.go:
--------------------------------------------------------------------------------
1 | package jsonrpc
2 |
3 | const (
4 | Version = "2.0" // JSON-RPC protocol version
5 | )
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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: mattt
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}}
49 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2025 Mattt (https://mat.tt)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a
4 | copy of this software and associated documentation files (the "Software"),
5 | to deal in the Software without restriction, including without limitation
6 | the rights to use, copy, modify, merge, publish, distribute, sublicense,
7 | and/or sell copies of the Software, and to permit persons to whom the
8 | Software is furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
14 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19 | DEALINGS IN THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/mattt/emcee
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/google/jsonschema-go v0.2.0
7 | github.com/hashicorp/go-retryablehttp v0.7.7
8 | github.com/modelcontextprotocol/go-sdk v0.2.1-0.20250814153251-bb6dadecca24
9 | github.com/pb33f/libopenapi v0.21.2
10 | github.com/spf13/cobra v1.8.1
11 | github.com/stretchr/testify v1.10.0
12 | golang.org/x/sync v0.11.0
13 | gopkg.in/yaml.v3 v3.0.1
14 | )
15 |
16 | require (
17 | github.com/bahlo/generic-list-go v0.2.0 // indirect
18 | github.com/buger/jsonparser v1.1.1 // indirect
19 | github.com/davecgh/go-spew v1.1.1 // indirect
20 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
21 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
22 | github.com/mailru/easyjson v0.9.0 // indirect
23 | github.com/pmezard/go-difflib v1.0.0 // indirect
24 | github.com/speakeasy-api/jsonpath v0.6.1 // indirect
25 | github.com/spf13/pflag v1.0.6 // indirect
26 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect
27 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
28 | golang.org/x/sys v0.30.0 // indirect
29 | )
30 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/internal/http.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "net/http"
7 | "time"
8 |
9 | "github.com/hashicorp/go-retryablehttp"
10 | )
11 |
12 | // HeaderTransport is a custom RoundTripper that adds default headers to requests
13 | type HeaderTransport struct {
14 | Base http.RoundTripper
15 | Headers http.Header
16 | }
17 |
18 | // RoundTrip adds the default headers to the request
19 | func (t *HeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
20 | for key, values := range t.Headers {
21 | for _, value := range values {
22 | req.Header.Add(key, value)
23 | }
24 | }
25 | base := t.Base
26 | if base == nil {
27 | base = http.DefaultTransport
28 | }
29 | return base.RoundTrip(req)
30 | }
31 |
32 | // RetryableClientOptions configures the retryable HTTP client.
33 | type RetryableClientOptions struct {
34 | Retries int
35 | Timeout time.Duration
36 | RPS int
37 | Logger interface{}
38 | Insecure bool
39 | }
40 |
41 | // RetryableClient returns a new http.Client with a retryablehttp.Client configured per opts.
42 | func RetryableClient(opts RetryableClientOptions) (*http.Client, error) {
43 | if opts.Retries < 0 {
44 | return nil, fmt.Errorf("retries must be greater than 0")
45 | }
46 | if opts.Timeout < 0 {
47 | return nil, fmt.Errorf("timeout must be greater than 0")
48 | }
49 | if opts.RPS < 0 {
50 | return nil, fmt.Errorf("rps must be greater than 0")
51 | }
52 |
53 | retryClient := retryablehttp.NewClient()
54 | retryClient.RetryMax = opts.Retries
55 | retryClient.RetryWaitMin = 1 * time.Second
56 | retryClient.RetryWaitMax = 30 * time.Second
57 | retryClient.HTTPClient.Timeout = opts.Timeout
58 | retryClient.Logger = opts.Logger
59 | if opts.Insecure {
60 | // Clone the default transport to preserve defaults (pooling, timeouts, proxies), then override TLS.
61 | if base, ok := http.DefaultTransport.(*http.Transport); ok && base != nil {
62 | transport := base.Clone()
63 | if transport.TLSClientConfig == nil {
64 | transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
65 | } else {
66 | transport.TLSClientConfig = transport.TLSClientConfig.Clone()
67 | transport.TLSClientConfig.InsecureSkipVerify = true
68 | }
69 | retryClient.HTTPClient.Transport = transport
70 | } else {
71 | // Fallback: construct a new transport if default transport type is unexpected.
72 | retryClient.HTTPClient.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
73 | }
74 | }
75 | if opts.RPS > 0 {
76 | retryClient.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
77 | // Ensure we wait at least 1/rps between requests
78 | minWait := time.Second / time.Duration(opts.RPS)
79 | if min < minWait {
80 | min = minWait
81 | }
82 | return retryablehttp.DefaultBackoff(min, max, attemptNum, resp)
83 | }
84 | }
85 |
86 | return retryClient.StandardClient(), nil
87 | }
88 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tools/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # This script should be run via curl:
4 | # sh -c "$(curl -fsSL https://raw.githubusercontent.com/mattt/emcee/main/tools/install.sh)"
5 | # or via wget:
6 | # sh -c "$(wget -qO- https://raw.githubusercontent.com/mattt/emcee/main/tools/install.sh)"
7 | # or via fetch:
8 | # sh -c "$(fetch -o - https://raw.githubusercontent.com/mattt/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/mattt/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/mattt/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 "$@"
--------------------------------------------------------------------------------
/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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
11 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
12 | github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ=
13 | github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
14 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
15 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
16 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
17 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
18 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
19 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
20 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
21 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
22 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
23 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
26 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
27 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
28 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
29 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
30 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
31 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
32 | github.com/modelcontextprotocol/go-sdk v0.2.1-0.20250814153251-bb6dadecca24 h1:4ZvCNG2xcIVyC3QT3S+vpWkVp7YAgWg006jMMhwdFXw=
33 | github.com/modelcontextprotocol/go-sdk v0.2.1-0.20250814153251-bb6dadecca24/go.mod h1:71VUZVa8LL6WARvSgLJ7DMpDWSeomT4uBv8g97mGBvo=
34 | github.com/pb33f/libopenapi v0.21.2 h1:L99NhyXtcRIawo8aVmWPIfA6k8v+8t6zJrTZkv7ggMI=
35 | github.com/pb33f/libopenapi v0.21.2/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU=
36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
38 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
39 | github.com/speakeasy-api/jsonpath v0.6.1 h1:FWbuCEPGaJTVB60NZg2orcYHGZlelbNJAcIk/JGnZvo=
40 | github.com/speakeasy-api/jsonpath v0.6.1/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
41 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
42 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
43 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
44 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
45 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
46 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
47 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
48 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd h1:dLuIF2kX9c+KknGJUdJi1Il1SDiTSK158/BB9kdgAew=
49 | github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo=
50 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
51 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
52 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
53 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
54 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
55 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
56 | golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
57 | golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
59 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
60 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
61 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
62 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
63 |
--------------------------------------------------------------------------------
/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 | // Perform MCP handshake (initialize + initialized), then list tools
44 | scanner := bufio.NewScanner(stdout)
45 |
46 | // initialize
47 | initReq := map[string]any{
48 | "jsonrpc": "2.0",
49 | "id": 1,
50 | "method": "initialize",
51 | "params": map[string]any{
52 | "protocolVersion": "2025-06-18",
53 | "capabilities": map[string]any{},
54 | "clientInfo": map[string]any{
55 | "name": "emcee-test",
56 | "version": "dev",
57 | },
58 | },
59 | }
60 | initJSON, err := json.Marshal(initReq)
61 | require.NoError(t, err)
62 | initJSON = append(initJSON, '\n')
63 | _, err = stdin.Write(initJSON)
64 | require.NoError(t, err)
65 | require.True(t, scanner.Scan(), "Expected initialize response")
66 |
67 | // notifications/initialized
68 | initialized := map[string]any{
69 | "jsonrpc": "2.0",
70 | "method": "notifications/initialized",
71 | "params": map[string]any{},
72 | }
73 | initdJSON, err := json.Marshal(initialized)
74 | require.NoError(t, err)
75 | initdJSON = append(initdJSON, '\n')
76 | _, err = stdin.Write(initdJSON)
77 | require.NoError(t, err)
78 |
79 | // tools/list
80 | listReqID := 2
81 | listReq := map[string]any{
82 | "jsonrpc": "2.0",
83 | "id": listReqID,
84 | "method": "tools/list",
85 | "params": map[string]any{},
86 | }
87 | listJSON, err := json.Marshal(listReq)
88 | require.NoError(t, err)
89 | listJSON = append(listJSON, '\n')
90 | _, err = stdin.Write(listJSON)
91 | require.NoError(t, err)
92 | require.True(t, scanner.Scan(), "Expected tools/list response")
93 |
94 | var response struct {
95 | JSONRPC string `json:"jsonrpc"`
96 | Result struct {
97 | Tools []struct {
98 | Name string `json:"name"`
99 | Description string `json:"description"`
100 | InputSchema json.RawMessage `json:"inputSchema"`
101 | } `json:"tools"`
102 | } `json:"result"`
103 | ID int `json:"id"`
104 | }
105 |
106 | err = json.Unmarshal(scanner.Bytes(), &response)
107 | require.NoError(t, err, "Failed to parse JSON response")
108 |
109 | // Verify response
110 | assert.Equal(t, "2.0", response.JSONRPC)
111 | assert.Equal(t, listReqID, response.ID)
112 | assert.NotEmpty(t, response.Result.Tools, "Expected at least one tool in response")
113 |
114 | // Find and verify the point tool
115 | var pointTool struct {
116 | Name string
117 | Description string
118 | InputSchema struct {
119 | Type string `json:"type"`
120 | Properties map[string]interface{} `json:"properties"`
121 | Required []string `json:"required"`
122 | }
123 | }
124 |
125 | foundPointTool := false
126 | for _, tool := range response.Result.Tools {
127 | if tool.Name == "point" {
128 | foundPointTool = true
129 | err := json.Unmarshal(tool.InputSchema, &pointTool.InputSchema)
130 | require.NoError(t, err)
131 | pointTool.Name = tool.Name
132 | pointTool.Description = tool.Description
133 | break
134 | }
135 | }
136 |
137 | require.True(t, foundPointTool, "Expected to find point tool")
138 | assert.Equal(t, "point", pointTool.Name)
139 | assert.Contains(t, pointTool.Description, "Returns metadata about a given latitude/longitude point")
140 |
141 | // Verify point tool has proper parameter schema
142 | assert.Equal(t, "object", pointTool.InputSchema.Type)
143 | assert.Contains(t, pointTool.InputSchema.Properties, "point", "Point tool should have 'point' parameter")
144 |
145 | pointParam := pointTool.InputSchema.Properties["point"].(map[string]interface{})
146 | assert.Equal(t, "string", pointParam["type"])
147 | assert.Contains(t, pointParam["description"].(string), "Point (latitude, longitude)")
148 | assert.Contains(t, pointTool.InputSchema.Required, "point", "Point parameter should be required")
149 |
150 | var zoneTool struct {
151 | Name string
152 | Description string
153 | InputSchema struct {
154 | Type string `json:"type"`
155 | Properties map[string]interface{} `json:"properties"`
156 | Required []string `json:"required"`
157 | }
158 | }
159 |
160 | foundZoneTool := false
161 | for _, tool := range response.Result.Tools {
162 | if tool.Name == "zone" {
163 | foundZoneTool = true
164 | err := json.Unmarshal(tool.InputSchema, &zoneTool.InputSchema)
165 | require.NoError(t, err)
166 | zoneTool.Name = tool.Name
167 | zoneTool.Description = tool.Description
168 | break
169 | }
170 | }
171 |
172 | require.True(t, foundZoneTool, "Expected to find zone tool")
173 | assert.Equal(t, "zone", zoneTool.Name)
174 | assert.Contains(t, zoneTool.Description, "Returns metadata about a given zone")
175 |
176 | // Verify zone tool has proper parameter schema
177 | assert.Equal(t, "object", zoneTool.InputSchema.Type)
178 | assert.Contains(t, zoneTool.InputSchema.Properties, "zoneId", "Zone tool should have 'zoneId' parameter")
179 |
180 | typeParam := zoneTool.InputSchema.Properties["type"].(map[string]interface{})
181 | assert.Equal(t, "string", typeParam["type"])
182 | assert.Contains(t, typeParam["description"].(string), "Zone type")
183 | assert.Contains(t, typeParam["description"].(string), "Allowed values: land, marine, ")
184 | assert.Contains(t, zoneTool.InputSchema.Required, "type", "type parameter should be required")
185 |
186 | zoneIdParam := zoneTool.InputSchema.Properties["zoneId"].(map[string]interface{})
187 | assert.Equal(t, "string", zoneIdParam["type"])
188 | assert.Contains(t, zoneIdParam["description"].(string), "NWS public zone/county identifier")
189 | assert.Contains(t, zoneIdParam["description"].(string), "UGC identifier for a NWS")
190 | assert.Contains(t, zoneTool.InputSchema.Required, "zoneId", "zoneId parameter should be required")
191 | }
192 |
--------------------------------------------------------------------------------
/cmd/emcee/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/tls"
5 | "encoding/base64"
6 | "fmt"
7 | "io"
8 | "log/slog"
9 | "net/http"
10 | "os"
11 | "os/signal"
12 | "path/filepath"
13 | "strings"
14 | "syscall"
15 | "time"
16 |
17 | "github.com/spf13/cobra"
18 | "golang.org/x/sync/errgroup"
19 |
20 | "github.com/mattt/emcee/internal"
21 | "github.com/modelcontextprotocol/go-sdk/mcp"
22 | )
23 |
24 | var rootCmd = &cobra.Command{
25 | Use: "emcee [spec-path-or-url]",
26 | Short: "Creates an MCP server for an OpenAPI specification",
27 | Long: `emcee is a CLI tool that provides an Model Context Protocol (MCP) stdio transport for a given OpenAPI specification.
28 | 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.
29 |
30 | The spec-path-or-url argument can be:
31 | - A local file path (e.g. ./openapi.json)
32 | - An HTTP(S) URL (e.g. https://api.example.com/openapi.json)
33 | - "-" to read from stdin
34 |
35 | By default, a GET request with no additional headers is made to the spec URL to download the OpenAPI specification.
36 |
37 | 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.
38 |
39 | Authentication values can be provided directly or as 1Password secret references (e.g. op://vault/item/field). When using 1Password references:
40 | - The 1Password CLI (op) must be installed and available in your PATH
41 | - You must be signed in to 1Password
42 | - The reference must be in the format op://vault/item/field
43 | - The secret will be securely retrieved at runtime using the 1Password CLI
44 | `,
45 | Args: cobra.ExactArgs(1),
46 | RunE: func(cmd *cobra.Command, args []string) error {
47 | // Set up context and signal handling
48 | ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
49 | defer cancel()
50 |
51 | // Set up error group
52 | g, ctx := errgroup.WithContext(ctx)
53 |
54 | // Set up logger
55 | var logger *slog.Logger
56 | switch {
57 | case silent:
58 | logger = slog.New(slog.NewTextHandler(io.Discard, nil))
59 | case verbose:
60 | logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
61 | Level: slog.LevelDebug,
62 | }))
63 | default:
64 | logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
65 | Level: slog.LevelInfo,
66 | }))
67 | }
68 |
69 | g.Go(func() error {
70 | // Read OpenAPI specification data
71 | var specData []byte
72 | if args[0] == "-" {
73 | logger.Info("reading spec from stdin")
74 |
75 | // When reading the OpenAPI spec from stdin, we need to read RPC input from /dev/tty
76 | // since stdin is being used for the spec data and isn't available for interactive I/O
77 | origStdin := os.Stdin
78 | tty, err := os.Open("/dev/tty")
79 | if err != nil {
80 | return fmt.Errorf("error opening /dev/tty: %w", err)
81 | }
82 | defer tty.Close()
83 |
84 | // Read spec from original stdin
85 | specData, err = io.ReadAll(origStdin)
86 | if err != nil {
87 | return fmt.Errorf("error reading OpenAPI spec from stdin: %w", err)
88 | }
89 | // Redirect SDK stdio transport to use /dev/tty for input
90 | os.Stdin = tty
91 | } else if strings.HasPrefix(args[0], "http://") || strings.HasPrefix(args[0], "https://") {
92 | logger.Info("reading spec from URL", "url", args[0])
93 |
94 | // Create HTTP request
95 | req, err := http.NewRequest(http.MethodGet, args[0], nil)
96 | if err != nil {
97 | return fmt.Errorf("error creating request: %w", err)
98 | }
99 |
100 | // Make HTTP request
101 | client := http.DefaultClient
102 | if insecure {
103 | if base, ok := http.DefaultTransport.(*http.Transport); ok && base != nil {
104 | transport := base.Clone()
105 | if transport.TLSClientConfig == nil {
106 | transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
107 | } else {
108 | transport.TLSClientConfig = transport.TLSClientConfig.Clone()
109 | transport.TLSClientConfig.InsecureSkipVerify = true
110 | }
111 | client = &http.Client{Transport: transport}
112 | } else {
113 | client = &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
114 | }
115 | }
116 | resp, err := client.Do(req)
117 | if err != nil {
118 | return fmt.Errorf("error downloading spec: %w", err)
119 | }
120 | if resp.Body == nil {
121 | return fmt.Errorf("no response body from %s", args[0])
122 | }
123 | defer resp.Body.Close()
124 |
125 | // Read spec from response body
126 | specData, err = io.ReadAll(resp.Body)
127 | if err != nil {
128 | return fmt.Errorf("error reading spec from %s: %w", args[0], err)
129 | }
130 | } else {
131 | logger.Info("reading spec from file", "file", args[0])
132 |
133 | // Clean the file path to remove any . or .. segments and ensure consistent separators
134 | cleanPath := filepath.Clean(args[0])
135 |
136 | // Check if file exists and is readable before attempting to read
137 | info, err := os.Stat(cleanPath)
138 | if err != nil {
139 | if os.IsNotExist(err) {
140 | return fmt.Errorf("spec file does not exist: %s", cleanPath)
141 | }
142 | return fmt.Errorf("error accessing spec file %s: %w", cleanPath, err)
143 | }
144 |
145 | // Ensure it's a regular file, not a directory
146 | if info.IsDir() {
147 | return fmt.Errorf("specified path is a directory, not a file: %s", cleanPath)
148 | }
149 |
150 | // Check file size to prevent loading extremely large files
151 | if info.Size() > 100*1024*1024 { // 100MB limit
152 | return fmt.Errorf("spec file too large (max 100MB): %s", cleanPath)
153 | }
154 |
155 | // Read spec from file
156 | specData, err = os.ReadFile(cleanPath)
157 | if err != nil {
158 | return fmt.Errorf("error reading spec file %s: %w", cleanPath, err)
159 | }
160 | }
161 |
162 | // Build HTTP client with optional auth header
163 | client, err := internal.RetryableClient(internal.RetryableClientOptions{
164 | Retries: retries,
165 | Timeout: timeout,
166 | RPS: rps,
167 | Logger: logger,
168 | Insecure: insecure,
169 | })
170 | if err != nil {
171 | return fmt.Errorf("error creating client: %w", err)
172 | }
173 | if bearerAuth != "" {
174 | resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, bearerAuth)
175 | if err != nil {
176 | return fmt.Errorf("error resolving bearer auth: %w", err)
177 | }
178 | if wasSecret {
179 | logger.Debug("resolved bearer auth from 1Password")
180 | }
181 | headers := http.Header{}
182 | headers.Add("Authorization", "Bearer "+resolvedAuth)
183 | client.Transport = &internal.HeaderTransport{Base: client.Transport, Headers: headers}
184 | } else if basicAuth != "" {
185 | resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, basicAuth)
186 | if err != nil {
187 | return fmt.Errorf("error resolving basic auth: %w", err)
188 | }
189 | if wasSecret {
190 | logger.Debug("resolved basic auth from 1Password")
191 | }
192 | var value string
193 | if strings.Contains(resolvedAuth, ":") {
194 | value = base64.StdEncoding.EncodeToString([]byte(resolvedAuth))
195 | } else {
196 | value = resolvedAuth
197 | }
198 | headers := http.Header{}
199 | headers.Add("Authorization", "Basic "+value)
200 | client.Transport = &internal.HeaderTransport{Base: client.Transport, Headers: headers}
201 | } else if rawAuth != "" {
202 | resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, rawAuth)
203 | if err != nil {
204 | return fmt.Errorf("error resolving raw auth: %w", err)
205 | }
206 | if wasSecret {
207 | logger.Debug("resolved raw auth from 1Password")
208 | }
209 | headers := http.Header{}
210 | headers.Add("Authorization", resolvedAuth)
211 | client.Transport = &internal.HeaderTransport{Base: client.Transport, Headers: headers}
212 | }
213 |
214 | // Create SDK server and register tools from OpenAPI
215 | impl := &mcp.Implementation{Name: cmd.Name(), Version: version}
216 | server := mcp.NewServer(impl, nil)
217 | var opts []internal.RegisterToolsOption
218 | if noAnnotations {
219 | opts = append(opts, internal.WithoutAnnotations())
220 | }
221 | if err := internal.RegisterTools(server, specData, client, opts...); err != nil {
222 | return fmt.Errorf("error registering tools: %w", err)
223 | }
224 |
225 | // Run over stdio; when spec was from stdin, we redirected os.Stdin to /dev/tty above.
226 | return server.Run(ctx, &mcp.StdioTransport{})
227 | })
228 |
229 | return g.Wait()
230 | },
231 | }
232 |
233 | var (
234 | bearerAuth string
235 | basicAuth string
236 | rawAuth string
237 |
238 | retries int
239 | timeout time.Duration
240 | rps int
241 | insecure bool
242 |
243 | verbose bool
244 | silent bool
245 | noAnnotations bool
246 |
247 | version = "dev"
248 | commit = "none"
249 | date = "unknown"
250 | )
251 |
252 | func init() {
253 | rootCmd.Flags().StringVar(&bearerAuth, "bearer-auth", "", "Bearer token value (will be prefixed with 'Bearer ')")
254 | rootCmd.Flags().StringVar(&basicAuth, "basic-auth", "", "Basic auth value (either user:pass or base64 encoded, will be prefixed with 'Basic ')")
255 | rootCmd.Flags().StringVar(&rawAuth, "raw-auth", "", "Raw value for Authorization header")
256 | rootCmd.MarkFlagsMutuallyExclusive("bearer-auth", "basic-auth", "raw-auth")
257 |
258 | rootCmd.Flags().IntVar(&retries, "retries", 3, "Maximum number of retries for failed requests")
259 | rootCmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "HTTP request timeout")
260 | rootCmd.Flags().IntVarP(&rps, "rps", "r", 0, "Maximum requests per second (0 for no limit)")
261 | rootCmd.Flags().BoolVar(&insecure, "insecure", false, "Allow insecure TLS connections (skip certificate verification)")
262 |
263 | rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug level logging to stderr")
264 | rootCmd.Flags().BoolVarP(&silent, "silent", "s", false, "Disable all logging")
265 | rootCmd.MarkFlagsMutuallyExclusive("verbose", "silent")
266 |
267 | rootCmd.Flags().BoolVar(&noAnnotations, "no-annotations", false, "Disable generated tool annotations")
268 |
269 | rootCmd.Version = fmt.Sprintf("%s (commit: %s, built at: %s)", version, commit, date)
270 | }
271 |
272 | func main() {
273 | if err := rootCmd.Execute(); err != nil {
274 | fmt.Fprintln(os.Stderr, err)
275 | os.Exit(1)
276 | }
277 | }
278 |
--------------------------------------------------------------------------------
/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 mattt/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": ["https://api.weather.gov/openapi.json"]
46 | }
47 | }
48 | }
49 | ```
50 |
51 | After saving the file, quit and re-open Claude.
52 | You should now see 🔨57 in the bottom right corner of your chat box.
53 | Click on that to see a list of all the tools made available to Claude through MCP.
54 |
55 | Start a new chat and ask it about the weather where you are.
56 |
57 | > What's the weather in Portland, OR?
58 |
59 | Claude will consult the tools made available to it through MCP
60 | and request to use one if deemed to be suitable for answering your question.
61 | You can review this request and either approve or deny it.
62 |
63 |
64 |
65 | If you allow, Claude will communicate with the MCP
66 | and use the result to inform its response.
67 |
68 | 
69 |
70 | ## Why use emcee?
71 |
72 | MCP provides a standardized way to connect AI models to tools and data sources.
73 | It's still early days, but there are already a variety of [available servers][mcp-servers]
74 | for connecting to browsers, developer tools, and other systems.
75 |
76 | We think emcee is a convenient way to connect to services
77 | that don't have an existing MCP server implementation —
78 | _especially for services you're building yourself_.
79 | Got a web app with an OpenAPI spec?
80 | You might be surprised how far you can get
81 | without a dashboard or client library.
82 |
83 | ## Installation
84 |
85 | ### Installer Script
86 |
87 | Use the [installer script][installer] to download and install a
88 | [pre-built release][releases] of emcee for your platform
89 | (Linux x86-64/i386/arm64 and macOS Intel/Apple Silicon).
90 |
91 | ```console
92 | # fish
93 | sh (curl -fsSL https://get.emcee.sh | psub)
94 |
95 | # bash, zsh
96 | sh <(curl -fsSL https://get.emcee.sh)
97 | ```
98 |
99 | ### Homebrew
100 |
101 | Install emcee using [Homebrew][homebrew].
102 |
103 | ```console
104 | brew install mattt/tap/emcee
105 | ```
106 |
107 | ### Docker
108 |
109 | Prebuilt [Docker images][docker-images] with emcee are available.
110 |
111 | ```console
112 | docker run -it ghcr.io/mattt/emcee
113 | ```
114 |
115 | ### Build From Source
116 |
117 | Requires [go 1.24][golang] or later.
118 |
119 | ```console
120 | git clone https://github.com/mattt/emcee.git
121 | cd emcee
122 | go build -o emcee cmd/emcee/main.go
123 | ```
124 |
125 | Once built, you can run in place (`./emcee`)
126 | or move it somewhere in your `PATH`, like `/usr/local/bin`.
127 |
128 | ## Usage
129 |
130 | ```console
131 | Usage:
132 | emcee [spec-path-or-url] [flags]
133 |
134 | Flags:
135 | --basic-auth string Basic auth value (either user:pass or base64 encoded, will be prefixed with 'Basic ')
136 | --bearer-auth string Bearer token value (will be prefixed with 'Bearer ')
137 | -h, --help help for emcee
138 | --raw-auth string Raw value for Authorization header
139 | --retries int Maximum number of retries for failed requests (default 3)
140 | -r, --rps int Maximum requests per second (0 for no limit)
141 | -s, --silent Disable all logging
142 | --timeout duration HTTP request timeout (default 1m0s)
143 | -v, --verbose Enable debug level logging to stderr
144 | --version version for emcee
145 | ```
146 |
147 | emcee implements [Standard Input/Output (stdio)](https://modelcontextprotocol.io/docs/concepts/transports#standard-input-output-stdio) transport for MCP,
148 | which uses [JSON-RPC 2.0](https://www.jsonrpc.org/) as its wire format.
149 |
150 | When you run emcee from the command-line,
151 | it starts a program that listens on stdin,
152 | outputs to stdout,
153 | and logs to stderr.
154 |
155 | ### Authentication
156 |
157 | For APIs that require authentication,
158 | emcee supports several authentication methods:
159 |
160 | | Authentication Type | Example Usage | Resulting Header |
161 | | ------------------- | ---------------------------- | ----------------------------------- |
162 | | **Bearer Token** | `--bearer-auth="abc123"` | `Authorization: Bearer abc123` |
163 | | **Basic Auth** | `--basic-auth="user:pass"` | `Authorization: Basic dXNlcjpwYXNz` |
164 | | **Raw Value** | `--raw-auth="Custom xyz789"` | `Authorization: Custom xyz789` |
165 |
166 | These authentication values can be provided directly
167 | or as [1Password secret references][secret-reference-syntax].
168 |
169 | When using 1Password references:
170 |
171 | - Use the format `op://vault/item/field`
172 | (e.g. `--bearer-auth="op://Shared/X/credential"`)
173 | - Ensure the 1Password CLI ([op][op]) is installed and available in your `PATH`
174 | - Sign in to 1Password before running emcee or launching Claude Desktop
175 |
176 | ```console
177 | # Install op
178 | brew install 1password-cli
179 |
180 | # Sign in 1Password CLI
181 | op signin
182 | ```
183 |
184 | ```json
185 | {
186 | "mcpServers": {
187 | "twitter": {
188 | "command": "emcee",
189 | "args": [
190 | "--bearer-auth=op://shared/x/credential",
191 | "https://api.twitter.com/2/openapi.json"
192 | ]
193 | }
194 | }
195 | }
196 | ```
197 |
198 |
199 |
200 | > [!IMPORTANT]
201 | > emcee doesn't use auth credentials when downloading
202 | > OpenAPI specifications from URLs provided as command arguments.
203 | > If your OpenAPI specification requires authentication to access,
204 | > first download it to a local file using your preferred HTTP client,
205 | > then provide the local file path to emcee.
206 |
207 | ### Transforming OpenAPI Specifications
208 |
209 | You can transform OpenAPI specifications before passing them to emcee using standard Unix utilities. This is useful for:
210 |
211 | - Selecting specific endpoints to expose as tools
212 | with [jq][jq] or [yq][yq]
213 | - Modifying descriptions or parameters
214 | with [OpenAPI Overlays][openapi-overlays]
215 | - Combining multiple specifications
216 | with [Redocly][redocly-cli]
217 |
218 | For example,
219 | you can use `jq` to include only the `point` tool from `weather.gov`.
220 |
221 | ```console
222 | cat path/to/openapi.json | \
223 | jq 'if .paths then .paths |= with_entries(select(.key == "/points/{point}")) else . end' | \
224 | emcee
225 | ```
226 |
227 | ### JSON-RPC
228 |
229 | You can interact directly with the provided MCP server
230 | by sending JSON-RPC requests.
231 |
232 | > [!NOTE]
233 | > emcee provides only MCP tool capabilities.
234 | > Other features like resources, prompts, and sampling aren't yet supported.
235 |
236 | #### List Tools
237 |
238 |
239 |
240 | Request
241 |
242 | ```json
243 | { "jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 1 }
244 | ```
245 |
246 |
247 |
248 |
249 |
250 | Response
251 |
252 | ```jsonc
253 | {
254 | "jsonrpc": "2.0",
255 | "result": {
256 | "tools": [
257 | // ...
258 | {
259 | "name": "tafs",
260 | "description": "Returns Terminal Aerodrome Forecasts for the specified airport station.",
261 | "inputSchema": {
262 | "type": "object",
263 | "properties": {
264 | "stationId": {
265 | "description": "Observation station ID",
266 | "type": "string"
267 | }
268 | },
269 | "required": ["stationId"]
270 | }
271 | }
272 | // ...
273 | ]
274 | },
275 | "id": 1
276 | }
277 | ```
278 |
279 |
280 |
281 | #### Call Tool
282 |
283 |
284 |
285 | Request
286 |
287 | ```json
288 | {
289 | "jsonrpc": "2.0",
290 | "method": "tools/call",
291 | "params": { "name": "taf", "arguments": { "stationId": "KPDX" } },
292 | "id": 1
293 | }
294 | ```
295 |
296 |
297 |
298 |
299 |
300 | Response
301 |
302 | ```jsonc
303 | {
304 | "jsonrpc":"2.0",
305 | "content": [
306 | {
307 | "type": "text",
308 | "text": /* Weather forecast in GeoJSON format */,
309 | "annotations": {
310 | "audience": ["assistant"]
311 | }
312 | }
313 | ]
314 | "id": 1
315 | }
316 | ```
317 |
318 |
319 |
320 | ## Debugging
321 |
322 | The [MCP Inspector][mcp-inspector] is a tool for testing and debugging MCP servers.
323 | If Claude and/or emcee aren't working as expected,
324 | the inspector can help you understand what's happening.
325 |
326 | ```console
327 | npx @modelcontextprotocol/inspector emcee https://api.weather.gov/openapi.json
328 | # 🔍 MCP Inspector is up and running at http://localhost:5173 🚀
329 | ```
330 |
331 | ```console
332 | open http://localhost:5173
333 | ```
334 |
335 | ## License
336 |
337 | This project is available under the MIT license.
338 | See the LICENSE file for more info.
339 |
340 | [chatgpt-plugins]: https://openai.com/index/chatgpt-plugins/
341 | [claude]: https://claude.ai/download
342 | [docker-images]: https://github.com/mattt/emcee/pkgs/container/emcee
343 | [golang]: https://go.dev
344 | [homebrew]: https://brew.sh
345 | [homebrew-tap]: https://github.com/mattt/homebrew-tap
346 | [installer]: https://github.com/mattt/emcee/blob/main/tools/install.sh
347 | [jq]: https://github.com/jqlang/jq
348 | [mcp]: https://modelcontextprotocol.io/
349 | [mcp-clients]: https://modelcontextprotocol.info/docs/clients/
350 | [mcp-inspector]: https://github.com/modelcontextprotocol/inspector
351 | [mcp-servers]: https://modelcontextprotocol.io/examples
352 | [op]: https://developer.1password.com/docs/cli/get-started/
353 | [openapi]: https://openapi.org
354 | [openapi-overlays]: https://www.openapis.org/blog/2024/10/22/announcing-overlay-specification
355 | [redocly-cli]: https://redocly.com/docs/cli/commands
356 | [releases]: https://github.com/mattt/emcee/releases
357 | [secret-reference-syntax]: https://developer.1password.com/docs/cli/secret-reference-syntax/
358 | [yq]: https://github.com/mikefarah/yq
359 |
--------------------------------------------------------------------------------
/internal/openapi.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "crypto/sha256"
7 | "encoding/base64"
8 | "encoding/json"
9 | "fmt"
10 | "io"
11 | "net/http"
12 | "net/url"
13 | "path"
14 | "strings"
15 |
16 | "github.com/google/jsonschema-go/jsonschema"
17 | "github.com/modelcontextprotocol/go-sdk/mcp"
18 | "github.com/pb33f/libopenapi"
19 | "github.com/pb33f/libopenapi/datamodel/high/base"
20 | v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
21 | "gopkg.in/yaml.v3"
22 | )
23 |
24 | // RegisterToolsOption configures RegisterTools behavior.
25 | type RegisterToolsOption func(*registerToolsConfig)
26 |
27 | type registerToolsConfig struct {
28 | enableAnnotations bool
29 | }
30 |
31 | // WithoutAnnotations disables attaching REST-aware MCP ToolAnnotations for generated tools.
32 | func WithoutAnnotations() RegisterToolsOption {
33 | return func(cfg *registerToolsConfig) { cfg.enableAnnotations = false }
34 | }
35 |
36 | // RegisterTools parses the given OpenAPI specification and registers tools on the provided MCP server.
37 | // All HTTP calls are executed using the provided http.Client. If the client is nil, http.DefaultClient is used.
38 | // By default, REST-aware MCP ToolAnnotations are attached to each tool. Pass options to change behavior.
39 | func RegisterTools(server *mcp.Server, specData []byte, client *http.Client, opts ...RegisterToolsOption) error {
40 | if len(specData) == 0 {
41 | return fmt.Errorf("no OpenAPI spec data provided")
42 | }
43 | if server == nil {
44 | return fmt.Errorf("server is nil")
45 | }
46 | if client == nil {
47 | client = http.DefaultClient
48 | }
49 |
50 | // Defaults
51 | cfg := ®isterToolsConfig{enableAnnotations: true}
52 | for _, opt := range opts {
53 | if opt != nil {
54 | opt(cfg)
55 | }
56 | }
57 |
58 | doc, err := libopenapi.NewDocument(specData)
59 | if err != nil {
60 | return fmt.Errorf("error parsing OpenAPI spec: %w", err)
61 | }
62 | model, errs := doc.BuildV3Model()
63 | if len(errs) > 0 {
64 | return fmt.Errorf("error building OpenAPI model: %v", errs[0])
65 | }
66 |
67 | if len(model.Model.Servers) == 0 || model.Model.Servers[0].URL == "" {
68 | return fmt.Errorf("OpenAPI spec must include at least one server URL")
69 | }
70 | baseURL := strings.TrimSuffix(model.Model.Servers[0].URL, "/")
71 |
72 | // Iterate operations and register tools.
73 | if model.Model.Paths == nil || model.Model.Paths.PathItems == nil {
74 | return nil
75 | }
76 |
77 | for pair := model.Model.Paths.PathItems.First(); pair != nil; pair = pair.Next() {
78 | p := pair.Key()
79 | item := pair.Value()
80 | ops := []struct {
81 | method string
82 | op *v3.Operation
83 | }{
84 | {"GET", item.Get},
85 | {"POST", item.Post},
86 | {"PUT", item.Put},
87 | {"DELETE", item.Delete},
88 | {"PATCH", item.Patch},
89 | }
90 | for _, op := range ops {
91 | if op.op == nil || op.op.OperationId == "" {
92 | continue
93 | }
94 | toolName := getToolName(op.op.OperationId)
95 | desc := op.op.Description
96 | if desc == "" {
97 | desc = op.op.Summary
98 | }
99 |
100 | // Build input schema
101 | schema := &jsonschema.Schema{Type: "object"}
102 | schema.Properties = make(map[string]*jsonschema.Schema)
103 | // Track names used by path/query/header parameters to avoid collisions
104 | paramNames := make(map[string]struct{})
105 |
106 | // Path item parameters
107 | if item.Parameters != nil {
108 | for _, param := range item.Parameters {
109 | addParamToSchema(schema, param)
110 | if param != nil {
111 | paramNames[param.Name] = struct{}{}
112 | }
113 | }
114 | }
115 |
116 | // Operation parameters
117 | if op.op.Parameters != nil {
118 | for _, param := range op.op.Parameters {
119 | addParamToSchema(schema, param)
120 | if param != nil {
121 | paramNames[param.Name] = struct{}{}
122 | }
123 | }
124 | }
125 |
126 | // Request body (application/json)
127 | if op.op.RequestBody != nil && op.op.RequestBody.Content != nil {
128 | if mediaType, ok := op.op.RequestBody.Content.Get("application/json"); ok && mediaType != nil {
129 | if mediaType.Schema != nil && mediaType.Schema.Schema() != nil {
130 | if s := mediaType.Schema.Schema(); s.Properties != nil {
131 | for prop := s.Properties.First(); prop != nil; prop = prop.Next() {
132 | propName := prop.Key()
133 | // Skip body properties that collide with parameter names
134 | if _, exists := paramNames[propName]; exists {
135 | continue
136 | }
137 | propSchema := prop.Value().Schema()
138 | if propSchema == nil {
139 | continue
140 | }
141 | // Skip readOnly properties
142 | if propSchema.ReadOnly != nil && *propSchema.ReadOnly {
143 | continue
144 | }
145 | sch := &jsonschema.Schema{Type: typeOfSchema(propSchema)}
146 | sch.Description = buildSchemaDescription("", propSchema)
147 | schema.Properties[propName] = sch
148 | }
149 | if s.Required != nil {
150 | for _, r := range s.Required {
151 | // Skip required fields that collide with parameter names
152 | if _, exists := paramNames[r]; exists {
153 | continue
154 | }
155 | // Skip required fields that are readOnly
156 | if prop, exists := s.Properties.Get(r); exists && prop != nil && prop.Schema() != nil {
157 | if prop.Schema().ReadOnly != nil && *prop.Schema().ReadOnly {
158 | continue
159 | }
160 | }
161 | schema.Required = append(schema.Required, r)
162 | }
163 | }
164 | }
165 | }
166 | }
167 | }
168 |
169 | tool := &mcp.Tool{
170 | Name: toolName,
171 | Description: desc,
172 | InputSchema: schema,
173 | }
174 |
175 | if cfg.enableAnnotations {
176 | // Derive MCP ToolAnnotations from REST conventions
177 | title := op.op.Summary
178 | if title == "" {
179 | title = fmt.Sprintf("%s %s", op.method, p)
180 | }
181 | openWorld := true
182 | destructiveTrue := true
183 | ann := &mcp.ToolAnnotations{
184 | Title: title,
185 | OpenWorldHint: &openWorld,
186 | }
187 | switch op.method {
188 | case "GET":
189 | ann.ReadOnlyHint = true
190 | ann.IdempotentHint = true
191 | case "POST":
192 | ann.ReadOnlyHint = false
193 | ann.IdempotentHint = false
194 | ann.DestructiveHint = &destructiveTrue
195 | case "PUT":
196 | ann.ReadOnlyHint = false
197 | ann.IdempotentHint = true
198 | ann.DestructiveHint = &destructiveTrue
199 | case "PATCH":
200 | ann.ReadOnlyHint = false
201 | ann.IdempotentHint = false
202 | ann.DestructiveHint = &destructiveTrue
203 | case "DELETE":
204 | ann.ReadOnlyHint = false
205 | ann.IdempotentHint = true
206 | ann.DestructiveHint = &destructiveTrue
207 | }
208 | tool.Annotations = ann
209 | }
210 |
211 | // Capture for handler
212 | method := op.method
213 | operation := op.op
214 | pathItem := item
215 | pathTemplate := p
216 |
217 | mcp.AddTool(server, tool, func(ctx context.Context, req *mcp.ServerRequest[*mcp.CallToolParamsFor[map[string]any]]) (*mcp.CallToolResultFor[any], error) {
218 | // Build URL
219 | base, err := url.Parse(baseURL)
220 | if err != nil {
221 | return nil, fmt.Errorf("invalid base URL: %w", err)
222 | }
223 | p := pathTemplate
224 | if !strings.HasPrefix(p, "/") {
225 | p = "/" + p
226 | }
227 | p = path.Clean(p)
228 | u := &url.URL{Scheme: base.Scheme, Host: base.Host}
229 | if base.Path != "" {
230 | basePath := path.Clean(base.Path)
231 | u.Path = "/" + strings.TrimPrefix(path.Join(basePath, p), "/")
232 | } else {
233 | u.Path = p
234 | }
235 | if u.Scheme == "" {
236 | u.Scheme = "http"
237 | }
238 |
239 | q := url.Values{}
240 | headers := make(http.Header)
241 | var bodyParams map[string]any
242 | // Track parameter names applied to URL/query/headers
243 | usedParamNames := make(map[string]struct{})
244 |
245 | // Path item parameters
246 | if pathItem.Parameters != nil {
247 | for _, param := range pathItem.Parameters {
248 | applyParam(param, req.Params.Arguments, u, q, headers)
249 | if param != nil {
250 | usedParamNames[param.Name] = struct{}{}
251 | }
252 | }
253 | }
254 | // Operation parameters
255 | if operation.Parameters != nil {
256 | for _, param := range operation.Parameters {
257 | applyParam(param, req.Params.Arguments, u, q, headers)
258 | if param != nil {
259 | usedParamNames[param.Name] = struct{}{}
260 | }
261 | }
262 | }
263 |
264 | // Request body
265 | if operation.RequestBody != nil && operation.RequestBody.Content != nil {
266 | if mediaType, ok := operation.RequestBody.Content.Get("application/json"); ok && mediaType != nil {
267 | if mediaType.Schema != nil && mediaType.Schema.Schema() != nil {
268 | if s := mediaType.Schema.Schema(); s.Properties != nil {
269 | bodyParams = make(map[string]any)
270 | for prop := s.Properties.First(); prop != nil; prop = prop.Next() {
271 | name := prop.Key()
272 | // Skip colliding names so path/query/header take precedence
273 | if _, exists := usedParamNames[name]; exists {
274 | continue
275 | }
276 | propSchema := prop.Value().Schema()
277 | // Skip readOnly properties in request body
278 | if propSchema != nil && propSchema.ReadOnly != nil && *propSchema.ReadOnly {
279 | continue
280 | }
281 | if v, ok := req.Params.Arguments[name]; ok {
282 | bodyParams[name] = v
283 | }
284 | }
285 | }
286 | }
287 | }
288 | }
289 |
290 | if len(q) > 0 {
291 | u.RawQuery = q.Encode()
292 | }
293 |
294 | var reqBody io.Reader
295 | if len(bodyParams) > 0 {
296 | b, err := json.Marshal(bodyParams)
297 | if err != nil {
298 | return nil, fmt.Errorf("marshal body: %w", err)
299 | }
300 | reqBody = bytes.NewReader(b)
301 | }
302 |
303 | hreq, err := http.NewRequest(method, u.String(), reqBody)
304 | if err != nil {
305 | return nil, err
306 | }
307 | for k, vs := range headers {
308 | for _, v := range vs {
309 | hreq.Header.Add(k, v)
310 | }
311 | }
312 | if reqBody != nil {
313 | hreq.Header.Set("Content-Type", "application/json")
314 | }
315 |
316 | resp, err := client.Do(hreq)
317 | if err != nil {
318 | return nil, err
319 | }
320 | defer resp.Body.Close()
321 | body, err := io.ReadAll(resp.Body)
322 | if err != nil {
323 | return nil, err
324 | }
325 | if resp.StatusCode >= 400 {
326 | return &mcp.CallToolResultFor[any]{
327 | Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Request failed with status %d: %s", resp.StatusCode, string(body))}},
328 | IsError: true,
329 | }, nil
330 | }
331 | ct := resp.Header.Get("Content-Type")
332 | var content mcp.Content
333 | switch {
334 | case strings.HasPrefix(ct, "image/"):
335 | content = &mcp.ImageContent{Data: body, MIMEType: ct}
336 | case strings.Contains(ct, "application/json"):
337 | var pretty bytes.Buffer
338 | if json.Indent(&pretty, body, "", " ") == nil {
339 | body = pretty.Bytes()
340 | }
341 | content = &mcp.TextContent{Text: string(body)}
342 | default:
343 | content = &mcp.TextContent{Text: string(body)}
344 | }
345 | return &mcp.CallToolResultFor[any]{Content: []mcp.Content{content}}, nil
346 | })
347 | }
348 | }
349 | return nil
350 | }
351 |
352 | func addParamToSchema(schema *jsonschema.Schema, param *v3.Parameter) {
353 | if param == nil || param.Schema == nil {
354 | return
355 | }
356 | ps := &jsonschema.Schema{Type: typeOfSchema(param.Schema.Schema())}
357 | if s := param.Schema.Schema(); s != nil {
358 | ps.Description = buildSchemaDescription(param.Description, s)
359 | if s.Pattern != "" {
360 | ps.Pattern = s.Pattern
361 | }
362 | }
363 | schema.Properties[param.Name] = ps
364 | if param.Required != nil && *param.Required {
365 | schema.Required = append(schema.Required, param.Name)
366 | }
367 | }
368 |
369 | func typeOfSchema(s *base.Schema) string {
370 | if s == nil || len(s.Type) == 0 {
371 | return "string"
372 | }
373 | return s.Type[0]
374 | }
375 |
376 | func buildSchemaDescription(paramDesc string, paramSchema *base.Schema) string {
377 | description := paramDesc
378 | if paramSchema.Description != "" {
379 | if description != "" && description != paramSchema.Description {
380 | description = fmt.Sprintf("%s. %s", description, paramSchema.Description)
381 | } else {
382 | description = paramSchema.Description
383 | }
384 | }
385 | var enumValues []string
386 | if len(paramSchema.Enum) > 0 {
387 | enumValues = getEnumValues(paramSchema.Enum)
388 | }
389 | if len(enumValues) > 0 {
390 | if description != "" {
391 | description = fmt.Sprintf("%s (Allowed values: %s)", description, strings.Join(enumValues, ", "))
392 | } else {
393 | description = fmt.Sprintf("Allowed values: %s", strings.Join(enumValues, ", "))
394 | }
395 | }
396 | return description
397 | }
398 |
399 | func getEnumValues(enum []*yaml.Node) []string {
400 | if len(enum) == 0 {
401 | return nil
402 | }
403 | values := make([]string, len(enum))
404 | for i, v := range enum {
405 | values[i] = v.Value
406 | }
407 | return values
408 | }
409 |
410 | func getToolName(operationId string) string {
411 | if len(operationId) <= 64 {
412 | return operationId
413 | }
414 | hash := sha256.Sum256([]byte(operationId))
415 | shortHash := base64.RawURLEncoding.EncodeToString(hash[:])[:8]
416 | return operationId[:55] + "_" + shortHash
417 | }
418 |
419 | func applyParam(param *v3.Parameter, args map[string]any, u *url.URL, q url.Values, headers http.Header) {
420 | if param == nil {
421 | return
422 | }
423 | value, ok := args[param.Name]
424 | if !ok {
425 | return
426 | }
427 | switch param.In {
428 | case "path":
429 | val := fmt.Sprint(value)
430 | u.Path = strings.ReplaceAll(u.Path, "{"+param.Name+"}", pathSegmentEscape(val))
431 | case "query":
432 | switch v := value.(type) {
433 | case []any:
434 | strs := make([]string, len(v))
435 | for i, it := range v {
436 | strs[i] = fmt.Sprint(it)
437 | }
438 | q.Set(param.Name, strings.Join(strs, ","))
439 | default:
440 | q.Set(param.Name, fmt.Sprint(value))
441 | }
442 | case "header":
443 | headers.Add(param.Name, fmt.Sprint(value))
444 | }
445 | }
446 |
447 | // pathSegmentEscape preserves valid URL segment characters per RFC 3986.
448 | func pathSegmentEscape(s string) string {
449 | hexCount := 0
450 | for i := 0; i < len(s); i++ {
451 | if shouldEscape(s[i]) {
452 | hexCount++
453 | }
454 | }
455 | if hexCount == 0 {
456 | return s
457 | }
458 | var buf [3]byte
459 | t := make([]byte, len(s)+2*hexCount)
460 | j := 0
461 | for i := 0; i < len(s); i++ {
462 | c := s[i]
463 | if shouldEscape(c) {
464 | buf[0] = '%'
465 | buf[1] = "0123456789ABCDEF"[c>>4]
466 | buf[2] = "0123456789ABCDEF"[c&15]
467 | t[j] = buf[0]
468 | t[j+1] = buf[1]
469 | t[j+2] = buf[2]
470 | j += 3
471 | } else {
472 | t[j] = c
473 | j++
474 | }
475 | }
476 | return string(t)
477 | }
478 |
479 | func shouldEscape(c byte) bool {
480 | if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' {
481 | return false
482 | }
483 | switch c {
484 | case '-', '.', '_', '~':
485 | return false
486 | case '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '@':
487 | return false
488 | }
489 | return true
490 | }
491 |
--------------------------------------------------------------------------------