├── .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 | ![emcee flow diagram](https://github.com/user-attachments/assets/bcac98a5-497f-4b34-9e8d-d4bc08852ea1) 2 | 3 | # emcee 4 | 5 | **emcee** is a tool that provides a [Model Context Protocol (MCP)][mcp] server 6 | for any web application with an [OpenAPI][openapi] specification. 7 | You can use emcee to connect [Claude Desktop][claude] and [other apps][mcp-clients] 8 | to external tools and data services, 9 | similar to [ChatGPT plugins][chatgpt-plugins]. 10 | 11 | ## Quickstart 12 | 13 | If you're on macOS and have [Homebrew][homebrew] installed, 14 | you can get up-and-running quickly. 15 | 16 | ```bash 17 | # Install emcee 18 | brew install 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 | ![Claude Desktop settings Edit Config button](https://github.com/user-attachments/assets/761c6de5-62c2-4c53-83e6-54362040acd5) 30 | 31 | The configuration file should be located in the Application Support directory. 32 | You can also open it directly in VSCode using: 33 | 34 | ```console 35 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 36 | ``` 37 | 38 | Add the following configuration to add the weather.gov MCP server: 39 | 40 | ```json 41 | { 42 | "mcpServers": { 43 | "weather": { 44 | "command": "emcee", 45 | "args": ["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 | Allow tool from weather MCP dialog 64 | 65 | If you allow, Claude will communicate with the MCP 66 | and use the result to inform its response. 67 | 68 | ![Claude response with MCP tool use](https://github.com/user-attachments/assets/d5b63002-1ed3-4b03-bc71-8357427bb06b) 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 | 1Password Access Requested 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 | --------------------------------------------------------------------------------