Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -342,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/
Expand All @@ -354,5 +381,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
55 changes: 54 additions & 1 deletion internal/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -349,6 +356,52 @@ 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]
}
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]
}
}
return nil
}
Comment thread
Copilot marked this conversation as resolved.

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
Expand Down
117 changes: 117 additions & 0 deletions internal/openapi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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) {
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) {
obs := observedRequest{method: r.Method}
body, err := io.ReadAll(r.Body)
if err == nil {
err = json.Unmarshal(body, &obs.body)
}
obs.err = err
observed <- obs

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
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)

obs := <-observed
require.NoError(t, obs.err)
assert.Equal(t, "QUERY", obs.method)
assert.Equal(t, map[string]any{"q": "emcee"}, obs.body)
}
Loading