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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ VERSION := $(shell git describe --tags || echo "v0.0.0")
VER_CUT := $(shell echo $(VERSION) | cut -c2-)

# Dependency versions
GOLANGCI_VERSION = latest
GOLANGCI_VERSION = v2.12.1
CLICKHOUSE_INFRA_VERSION = $(shell go list -m -f '{{.Version}}' github.com/DIMO-Network/clickhouse-infra)

help:
Expand Down Expand Up @@ -53,4 +53,4 @@ tools: tools-golangci-lint ## Install all tools

tools-golangci-lint: ## Install golangci-lint
@mkdir -p $(PATHINSTBIN)
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | BINARY=golangci-lint bash -s -- ${GOLANGCI_VERSION}
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_VERSION)/install.sh | sh -s -- -b $(PATHINSTBIN) $(GOLANGCI_VERSION)
15 changes: 13 additions & 2 deletions pkg/mcpserver/gqlgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@ type gqlgenExecutor struct {
exec *executor.Executor
}

// ExecutorOption customizes the gqlgen executor backing a GraphQLExecutor
// before it serves MCP queries — e.g. registering handler extensions
// (complexity limits, billing middleware) or setting an error presenter.
// Services that configure their HTTP GraphQL server with extensions should
// pass the same set here so both paths behave identically.
type ExecutorOption func(*executor.Executor)

// NewGQLGenExecutor returns a GraphQLExecutor backed by a gqlgen ExecutableSchema.
func NewGQLGenExecutor(es graphql.ExecutableSchema) GraphQLExecutor {
func NewGQLGenExecutor(es graphql.ExecutableSchema, opts ...ExecutorOption) GraphQLExecutor {
e := executor.New(es)
for _, opt := range opts {
opt(e)
}
return &gqlgenExecutor{
exec: executor.New(es),
exec: e,
}
}

Expand Down
37 changes: 37 additions & 0 deletions pkg/mcpserver/gqlgen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/executor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vektah/gqlparser/v2"
Expand Down Expand Up @@ -76,3 +77,39 @@ func TestGQLGenExecutorNilResponse(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "nil response")
}

// testExtension is a minimal gqlgen handler extension that records whether it
// intercepted a response.
type testExtension struct {
called *bool
}

func (testExtension) ExtensionName() string { return "TestExtension" }
func (testExtension) Validate(schema graphql.ExecutableSchema) error { return nil }
func (e testExtension) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
*e.called = true
return next(ctx)
}

func TestGQLGenExecutorAppliesOptions(t *testing.T) {
es := &graphql.ExecutableSchemaMock{
SchemaFunc: testSchema,
ComplexityFunc: func(ctx context.Context, typeName, fieldName string, childComplexity int, args map[string]any) (int, bool) {
return 0, false
},
ExecFunc: func(ctx context.Context) graphql.ResponseHandler {
return func(ctx context.Context) *graphql.Response {
return &graphql.Response{Data: []byte(`{"hello":"world"}`)}
}
},
}

var intercepted bool
exec := NewGQLGenExecutor(es, func(e *executor.Executor) {
e.Use(testExtension{called: &intercepted})
})

_, err := exec.Execute(context.Background(), `{ hello }`, nil)
require.NoError(t, err)
assert.True(t, intercepted, "extension registered via ExecutorOption must run")
}
49 changes: 49 additions & 0 deletions pkg/mcpserver/mcpserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -925,3 +925,52 @@ func TestCoerceArgTypesPreservesExistingInts(t *testing.T) {
coerceArgTypes(args, defs)
assert.Equal(t, int64(7), args["tokenId"])
}

func TestSelectionTemplateMissingKeyReturnsToolError(t *testing.T) {
exec := &mockExecutor{
fn: func(ctx context.Context, query string, variables map[string]any) ([]byte, error) {
t.Fatal("executor must not be called when template rendering fails")
return nil, nil
},
}

tool := ToolDefinition{
Name: "get_signals",
Description: "Aggregated signals with dynamic selection",
Args: []ArgDefinition{
{Name: "signalRequests", Type: "array", ItemsType: "object", Required: true, ToolOnly: true},
},
Query: `query { signals { __MCPGEN_SELECTION__ } }`,
SelectionTemplate: "timestamp{{range .signalRequests}} {{.name}}(agg: {{.agg}}){{end}}",
}

mcpServer := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
require.NoError(t, registerShortcutTools(mcpServer, exec, []ToolDefinition{tool}, nil))

serverTransport, clientTransport := mcp.NewInMemoryTransports()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = mcpServer.Run(ctx, serverTransport) }()

client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.1.0"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
require.NoError(t, err)

result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_signals",
Arguments: map[string]any{
"signalRequests": []any{
map[string]any{"name": "speed"}, // "agg" intentionally missing
},
},
})
require.NoError(t, err)
require.True(t, result.IsError, "missing template key must surface as a tool error")

contentJSON, err := json.Marshal(result.Content[0])
require.NoError(t, err)
var tc struct{ Text string }
require.NoError(t, json.Unmarshal(contentJSON, &tc))
assert.Contains(t, tc.Text, "failed to render selection template")
assert.NotContains(t, tc.Text, "<no value>")
}
5 changes: 4 additions & 1 deletion pkg/mcpserver/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ func registerShortcutTools(server *mcp.Server, exec GraphQLExecutor, tools []Too

var selTmpl *template.Template
if tool.SelectionTemplate != "" {
tmpl, err := template.New(tool.Name).Parse(tool.SelectionTemplate)
// missingkey=error: a caller omitting a key the template references
// (e.g. a signalRequests entry without "agg") gets a clear render
// error instead of "<no value>" spliced into the GraphQL query.
tmpl, err := template.New(tool.Name).Option("missingkey=error").Parse(tool.SelectionTemplate)
if err != nil {
return fmt.Errorf("mcpserver: parse SelectionTemplate for tool %q: %w", tool.Name, err)
}
Expand Down
Loading