From 1b3c1691a28af60d8ce7d39bff4cb50738105bfe Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 29 Jun 2026 04:47:13 -0700 Subject: [PATCH 1/3] Add support for the HTTP QUERY method --- README.md | 29 +++++++++++ internal/openapi.go | 52 ++++++++++++++++++- internal/openapi_test.go | 109 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 internal/openapi_test.go diff --git a/README.md b/README.md index 6112acf..0021346 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,34 @@ cat path/to/openapi.json | \ emcee ``` +### HTTP QUERY + +emcee supports the HTTP `QUERY` method defined by [RFC 10008][rfc-query]. +OpenAPI 3.2 specifications can use the native `query` Path Item operation; +older OpenAPI 3.x specifications can use `x-query` as a compatibility extension. +The value is parsed as a standard OpenAPI Operation Object, +registered as an MCP tool, +and annotated as read-only and idempotent. + +```yaml +paths: + /search: + query: + operationId: search + summary: Search records + requestBody: + content: + application/json: + schema: + type: object + properties: + q: + type: string + responses: + "200": + description: OK +``` + ### JSON-RPC You can interact directly with the provided MCP server @@ -354,5 +382,6 @@ See the LICENSE file for more info. [openapi-overlays]: https://www.openapis.org/blog/2024/10/22/announcing-overlay-specification [redocly-cli]: https://redocly.com/docs/cli/commands [releases]: https://github.com/mattt/emcee/releases +[rfc-query]: https://datatracker.ietf.org/doc/rfc10008/ [secret-reference-syntax]: https://developer.1password.com/docs/cli/secret-reference-syntax/ [yq]: https://github.com/mikefarah/yq diff --git a/internal/openapi.go b/internal/openapi.go index 7771efc..8167cc8 100644 --- a/internal/openapi.go +++ b/internal/openapi.go @@ -18,6 +18,8 @@ import ( "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel/high/base" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/datamodel/low" + lowV3 "github.com/pb33f/libopenapi/datamodel/low/v3" "gopkg.in/yaml.v3" ) @@ -77,11 +79,16 @@ func RegisterTools(server *mcp.Server, specData []byte, client *http.Client, opt for pair := model.Model.Paths.PathItems.First(); pair != nil; pair = pair.Next() { p := pair.Key() item := pair.Value() + queryOp, err := queryOperation(item) + if err != nil { + return fmt.Errorf("error parsing QUERY operation for %s: %w", p, err) + } ops := []struct { method string op *v3.Operation }{ {"GET", item.Get}, + {"QUERY", queryOp}, {"POST", item.Post}, {"PUT", item.Put}, {"DELETE", item.Delete}, @@ -185,7 +192,7 @@ func RegisterTools(server *mcp.Server, specData []byte, client *http.Client, opt OpenWorldHint: &openWorld, } switch op.method { - case "GET": + case "GET", "QUERY": ann.ReadOnlyHint = true ann.IdempotentHint = true case "POST": @@ -349,6 +356,49 @@ func RegisterTools(server *mcp.Server, specData []byte, client *http.Client, opt return nil } +func queryOperation(item *v3.PathItem) (*v3.Operation, error) { + if item == nil || item.GoLow() == nil { + return nil, nil + } + if node := pathItemOperationNode(item, "query"); node != nil { + return operationFromNode(item, node) + } + ext := item.GoLow().FindExtension("x-query") + if ext == nil || ext.Value == nil { + return nil, nil + } + return operationFromNode(item, ext.Value) +} + +func pathItemOperationNode(item *v3.PathItem, method string) *yaml.Node { + root := item.GoLow().GetRootNode() + if root == nil { + return nil + } + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + root = root.Content[0] + } + for i := 0; i+1 < len(root.Content); i += 2 { + if strings.EqualFold(root.Content[i].Value, method) { + return root.Content[i+1] + } + } + return nil +} + +func operationFromNode(item *v3.PathItem, node *yaml.Node) (*v3.Operation, error) { + op, err, _, _ := low.ExtractObjectRaw[*lowV3.Operation, lowV3.Operation]( + item.GoLow().GetContext(), + nil, + node, + item.GoLow().GetIndex(), + ) + if err != nil { + return nil, err + } + return v3.NewOperation(op), nil +} + func addParamToSchema(schema *jsonschema.Schema, param *v3.Parameter) { if param == nil || param.Schema == nil { return diff --git a/internal/openapi_test.go b/internal/openapi_test.go new file mode 100644 index 0000000..151d8b7 --- /dev/null +++ b/internal/openapi_test.go @@ -0,0 +1,109 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegisterToolsSupportsNativeQueryOperation(t *testing.T) { + testRegisterToolsSupportsQuery(t, "3.2.0", "query") +} + +func TestRegisterToolsSupportsQueryExtension(t *testing.T) { + testRegisterToolsSupportsQuery(t, "3.1.0", "x-query") +} + +func testRegisterToolsSupportsQuery(t *testing.T, openAPIVersion, operationKey string) { + var gotMethod string + var gotBody map[string]any + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &gotBody)) + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(`{"ok":true}`)) + require.NoError(t, err) + })) + defer api.Close() + + spec := fmt.Sprintf(`{ + "openapi": %q, + "info": {"title": "QUERY API", "version": "1.0.0"}, + "servers": [{"url": %q}], + "paths": { + "/search": { + %q: { + "operationId": "searchRecords", + "summary": "Search records", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "q": {"type": "string", "description": "Search query"} + }, + "required": ["q"] + } + } + } + }, + "responses": {"200": {"description": "OK"}} + } + } + } +}`, openAPIVersion, api.URL, operationKey) + + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "dev"}, nil) + require.NoError(t, RegisterTools(server, []byte(spec), api.Client())) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "dev"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer clientSession.Close() + + tools, err := clientSession.ListTools(ctx, nil) + require.NoError(t, err) + + var queryTool *mcp.Tool + for i := range tools.Tools { + if tools.Tools[i].Name == "searchRecords" { + queryTool = tools.Tools[i] + break + } + } + require.NotNil(t, queryTool) + require.NotNil(t, queryTool.Annotations) + assert.True(t, queryTool.Annotations.ReadOnlyHint) + assert.True(t, queryTool.Annotations.IdempotentHint) + + result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: "searchRecords", + Arguments: map[string]any{"q": "emcee"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Equal(t, "QUERY", gotMethod) + assert.Equal(t, map[string]any{"q": "emcee"}, gotBody) +} From c8399ce83a6e80af07b467ac400909019c6e3667 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 29 Jun 2026 04:47:13 -0700 Subject: [PATCH 2/3] Remove unused homebrew-tap link reference --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 0021346..c095e7b 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,6 @@ See the LICENSE file for more info. [docker-images]: https://github.com/mattt/emcee/pkgs/container/emcee [golang]: https://go.dev [homebrew]: https://brew.sh -[homebrew-tap]: https://github.com/mattt/homebrew-tap [installer]: https://github.com/mattt/emcee/blob/main/tools/install.sh [jq]: https://github.com/jqlang/jq [mcp]: https://modelcontextprotocol.io/ From 6d58b67c95e309d53e10c3c0cc380488de2a3deb Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Mon, 29 Jun 2026 05:22:38 -0700 Subject: [PATCH 3/3] Address review feedback on QUERY support Guard against non-mapping Path Item nodes and avoid a data race in the QUERY test by passing the observed request over a channel. --- internal/openapi.go | 3 +++ internal/openapi_test.go | 26 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/openapi.go b/internal/openapi.go index 8167cc8..a0b3346 100644 --- a/internal/openapi.go +++ b/internal/openapi.go @@ -378,6 +378,9 @@ func pathItemOperationNode(item *v3.PathItem, method string) *yaml.Node { if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { root = root.Content[0] } + if root.Kind != yaml.MappingNode { + return nil + } for i := 0; i+1 < len(root.Content); i += 2 { if strings.EqualFold(root.Content[i].Value, method) { return root.Content[i+1] diff --git a/internal/openapi_test.go b/internal/openapi_test.go index 151d8b7..68276e3 100644 --- a/internal/openapi_test.go +++ b/internal/openapi_test.go @@ -24,17 +24,23 @@ func TestRegisterToolsSupportsQueryExtension(t *testing.T) { } func testRegisterToolsSupportsQuery(t *testing.T, openAPIVersion, operationKey string) { - var gotMethod string - var gotBody map[string]any + type observedRequest struct { + method string + body map[string]any + err error + } + observed := make(chan observedRequest, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method + obs := observedRequest{method: r.Method} body, err := io.ReadAll(r.Body) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(body, &gotBody)) + if err == nil { + err = json.Unmarshal(body, &obs.body) + } + obs.err = err + observed <- obs w.Header().Set("Content-Type", "application/json") - _, err = w.Write([]byte(`{"ok":true}`)) - require.NoError(t, err) + _, _ = w.Write([]byte(`{"ok":true}`)) })) defer api.Close() @@ -104,6 +110,8 @@ func testRegisterToolsSupportsQuery(t *testing.T, openAPIVersion, operationKey s require.NoError(t, err) require.False(t, result.IsError) - assert.Equal(t, "QUERY", gotMethod) - assert.Equal(t, map[string]any{"q": "emcee"}, gotBody) + obs := <-observed + require.NoError(t, obs.err) + assert.Equal(t, "QUERY", obs.method) + assert.Equal(t, map[string]any{"q": "emcee"}, obs.body) }