Skip to content

Commit 47f55d7

Browse files
authored
Fix tool calling (#13)
* Reorganize ServerOption and methods Remove unused Start and Shutdown methods * Remove default server info * Define constant for MCP version * Clean up and comment code * Inline applyAuthHeaders * Define jsonrpc.Result type * Fixup server info * Rename Handle to HandleRequest * Create protocol.go * Refactor server with formalized protocol * Use url.PathEscape to properly escape path parameters according to RFC 3986 * Move logging to top-level handler * Add test case for NWS point tool and fix implementation * Only escape characters that are invalid in URL path segments
1 parent 737ada3 commit 47f55d7

File tree

8 files changed

+1099
-579
lines changed

8 files changed

+1099
-579
lines changed

cmd/emcee/main.go

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import (
2121

2222
var rootCmd = &cobra.Command{
2323
Use: "emcee [spec-path-or-url]",
24-
Short: "An MCP server for a given OpenAPI specification",
25-
Long: `emcee is a CLI tool that provides an MCP stdio transport for a given OpenAPI specification.
24+
Short: "Creates an MCP server for an OpenAPI specification",
25+
Long: `emcee is a CLI tool that provides an Model Context Protocol (MCP) stdio transport for a given OpenAPI specification.
2626
It takes an OpenAPI specification path or URL as input and processes JSON-RPC requests
2727
from stdin, making corresponding API calls and returning JSON-RPC responses to stdout.
2828
@@ -32,11 +32,14 @@ The spec-path-or-url argument can be:
3232
- "-" to read from stdin`,
3333
Args: cobra.ExactArgs(1),
3434
RunE: func(cmd *cobra.Command, args []string) error {
35+
// Set up context and signal handling
3536
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
3637
defer cancel()
3738

39+
// Set up error group
3840
g, ctx := errgroup.WithContext(ctx)
3941

42+
// Set up logger
4043
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
4144
Level: slog.LevelDebug,
4245
}))
@@ -47,15 +50,19 @@ The spec-path-or-url argument can be:
4750
g.Go(func() error {
4851
var opts []mcp.ServerOption
4952

53+
// Set server info
54+
opts = append(opts, mcp.WithServerInfo(cmd.Name(), version))
55+
56+
// Set logger
5057
opts = append(opts, mcp.WithLogger(logger))
5158

59+
// Configure HTTP client
5260
retryClient := retryablehttp.NewClient()
5361
retryClient.RetryMax = retries
5462
retryClient.RetryWaitMin = 1 * time.Second
5563
retryClient.RetryWaitMax = 30 * time.Second
5664
retryClient.HTTPClient.Timeout = timeout
5765
retryClient.Logger = logger
58-
5966
if rps > 0 {
6067
retryClient.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
6168
// Ensure we wait at least 1/rps between requests
@@ -66,19 +73,24 @@ The spec-path-or-url argument can be:
6673
return retryablehttp.DefaultBackoff(min, max, attemptNum, resp)
6774
}
6875
}
69-
7076
client := retryClient.StandardClient()
7177
opts = append(opts, mcp.WithClient(client))
7278

79+
// Set Authentication header if provided
80+
if auth != "" {
81+
opts = append(opts, mcp.WithAuth(auth))
82+
}
83+
84+
// Read OpenAPI specification data
85+
var rpcInput io.Reader = os.Stdin
7386
var specData []byte
7487
var err error
75-
var rpcInput io.Reader = os.Stdin
7688

7789
if args[0] == "-" {
7890
logger.Info("reading spec from stdin")
7991

80-
// When reading spec from stdin, we need to use /dev/tty for RPC input
81-
// because stdin isn't a TTY when reading from a pipe
92+
// When reading the OpenAPI spec from stdin, we need to read RPC input from /dev/tty
93+
// since stdin is being used for the spec data and isn't available for interactive I/O
8294
tty, err := os.Open("/dev/tty")
8395
if err != nil {
8496
return fmt.Errorf("error opening /dev/tty: %w", err)
@@ -94,6 +106,7 @@ The spec-path-or-url argument can be:
94106
} else if strings.HasPrefix(args[0], "http://") || strings.HasPrefix(args[0], "https://") {
95107
logger.Info("reading spec from URL", "url", args[0])
96108

109+
// Create HTTP request
97110
req, err := http.NewRequest(http.MethodGet, args[0], nil)
98111
if err != nil {
99112
return fmt.Errorf("error creating request: %w", err)
@@ -104,6 +117,7 @@ The spec-path-or-url argument can be:
104117
req.Header.Set("Authorization", auth)
105118
}
106119

120+
// Make HTTP request
107121
resp, err := client.Do(req)
108122
if err != nil {
109123
return fmt.Errorf("error downloading spec: %w", err)
@@ -113,6 +127,7 @@ The spec-path-or-url argument can be:
113127
}
114128
defer resp.Body.Close()
115129

130+
// Read spec from response body
116131
specData, err = io.ReadAll(resp.Body)
117132
if err != nil {
118133
return fmt.Errorf("error reading spec from %s: %w", args[0], err)
@@ -142,30 +157,25 @@ The spec-path-or-url argument can be:
142157
return fmt.Errorf("spec file too large (max 100MB): %s", cleanPath)
143158
}
144159

160+
// Read spec from file
145161
specData, err = os.ReadFile(cleanPath)
146162
if err != nil {
147163
return fmt.Errorf("error reading spec file %s: %w", cleanPath, err)
148164
}
149165
}
150166

151-
if len(specData) == 0 {
152-
return fmt.Errorf("no OpenAPI spec data provided")
153-
}
167+
// Set spec data
154168
opts = append(opts, mcp.WithSpecData(specData))
155169

156-
if auth != "" {
157-
opts = append(opts, mcp.WithAuth(auth))
158-
}
159-
160-
opts = append(opts, mcp.WithLogger(logger))
161-
170+
// Create server
162171
server, err := mcp.NewServer(opts...)
163172
if err != nil {
164173
return fmt.Errorf("error creating server: %w", err)
165174
}
166175

176+
// Create and run transport
167177
transport := mcp.NewStdioTransport(rpcInput, os.Stdout, os.Stderr)
168-
return transport.Run(ctx, server.Handle)
178+
return transport.Run(ctx, server.HandleRequest)
169179
})
170180

171181
return g.Wait()

jsonrpc/response.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package jsonrpc
22

3+
// Result represents a map of string keys to arbitrary values
4+
type Result interface{}
5+
36
// Response represents a JSON-RPC response object
47
type Response struct {
5-
Version string `json:"jsonrpc"`
6-
Result interface{} `json:"result,omitempty"`
7-
Error *Error `json:"error,omitempty"`
8-
ID ID `json:"id"`
8+
Version string `json:"jsonrpc"`
9+
Result Result `json:"result,omitempty"`
10+
Error *Error `json:"error,omitempty"`
11+
ID ID `json:"id"`
912
}
1013

1114
// NewResponse creates a new Response object
12-
func NewResponse(id interface{}, result interface{}, err *Error) Response {
15+
func NewResponse(id interface{}, result Result, err *Error) Response {
1316
respID, _ := NewID(id)
1417

1518
return Response{

main_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,40 @@ func TestIntegration(t *testing.T) {
7878
assert.Equal(t, "2.0", response.JSONRPC)
7979
assert.Equal(t, 1, response.ID)
8080
assert.NotEmpty(t, response.Result.Tools, "Expected at least one tool in response")
81+
82+
// Find and verify the point tool
83+
var pointTool struct {
84+
Name string
85+
Description string
86+
InputSchema struct {
87+
Type string `json:"type"`
88+
Properties map[string]interface{} `json:"properties"`
89+
Required []string `json:"required"`
90+
}
91+
}
92+
93+
foundPointTool := false
94+
for _, tool := range response.Result.Tools {
95+
if tool.Name == "point" {
96+
foundPointTool = true
97+
err := json.Unmarshal(tool.InputSchema, &pointTool.InputSchema)
98+
require.NoError(t, err)
99+
pointTool.Name = tool.Name
100+
pointTool.Description = tool.Description
101+
break
102+
}
103+
}
104+
105+
require.True(t, foundPointTool, "Expected to find point tool")
106+
assert.Equal(t, "point", pointTool.Name)
107+
assert.Contains(t, pointTool.Description, "Returns metadata about a given latitude/longitude point")
108+
109+
// Verify point tool has proper parameter schema
110+
assert.Equal(t, "object", pointTool.InputSchema.Type)
111+
assert.Contains(t, pointTool.InputSchema.Properties, "point", "Point tool should have 'point' parameter")
112+
113+
pointParam := pointTool.InputSchema.Properties["point"].(map[string]interface{})
114+
assert.Equal(t, "string", pointParam["type"])
115+
assert.Contains(t, pointParam["description"].(string), "Point (latitude, longitude)")
116+
assert.Contains(t, pointTool.InputSchema.Required, "point", "Point parameter should be required")
81117
}

0 commit comments

Comments
 (0)