Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ require (
github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e
github.com/wundergraph/cosmo/router v0.0.0-20250912064154-106e871ee32e
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.232
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.234
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/sdk/metric v1.36.0
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,8 @@ github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTB
github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE=
github.com/wundergraph/consul/sdk v0.0.0-20250204115147-ed842a8fd301 h1:EzfKHQoTjFDDcgaECCCR2aTePqMu9QBmPbyhqIYOhV0=
github.com/wundergraph/consul/sdk v0.0.0-20250204115147-ed842a8fd301/go.mod h1:wxI0Nak5dI5RvJuzGyiEK4nZj0O9X+Aw6U0tC1wPKq0=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.232 h1:G04FDSXlEaQZS9cBrKlP8djbzquoQElB7w7i/d4sAHg=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.232/go.mod h1:ErOQH1ki2+SZB8JjpTyGVnoBpg5picIyjvuWQJP4abg=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.234 h1:yk+HTcTq61JxM/TvlZy1Fnc90I9whgGAKfmoaHWR6is=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.234/go.mod h1:ErOQH1ki2+SZB8JjpTyGVnoBpg5picIyjvuWQJP4abg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
205 changes: 203 additions & 2 deletions router-tests/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"

Expand All @@ -12,6 +14,10 @@ import (
"github.com/stretchr/testify/require"
"github.com/wundergraph/cosmo/router-tests/testenv"
"github.com/wundergraph/cosmo/router/pkg/config"
"github.com/wundergraph/cosmo/router/pkg/schemaloader"
"github.com/wundergraph/graphql-go-tools/v2/pkg/astparser"
"github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform"
"go.uber.org/zap"
)

func TestMCP(t *testing.T) {
Expand Down Expand Up @@ -124,7 +130,7 @@ func TestMCP(t *testing.T) {
// Verify MyEmployees operation
require.Contains(t, resp.Tools, mcp.Tool{
Name: "execute_operation_my_employees",
Description: "Executes the GraphQL operation 'MyEmployees' of type query. This is a GraphQL query that retrieves a list of employees.",
Description: "This is a GraphQL query that retrieves a list of employees.",
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: map[string]interface{}{"criteria": map[string]interface{}{"additionalProperties": false, "description": "Allows to filter employees by their details.", "nullable": false, "properties": map[string]interface{}{"hasPets": map[string]interface{}{"nullable": true, "type": "boolean"}, "nationality": map[string]interface{}{"enum": []interface{}{"AMERICAN", "DUTCH", "ENGLISH", "GERMAN", "INDIAN", "SPANISH", "UKRAINIAN"}, "nullable": true, "type": "string"}, "nested": map[string]interface{}{"additionalProperties": false, "nullable": true, "properties": map[string]interface{}{"hasChildren": map[string]interface{}{"nullable": true, "type": "boolean"}, "maritalStatus": map[string]interface{}{"enum": []interface{}{"ENGAGED", "MARRIED"}, "nullable": true, "type": "string"}}, "type": "object"}}, "type": "object"}},
Expand All @@ -141,7 +147,7 @@ func TestMCP(t *testing.T) {
// Verify UpdateMood operation
require.Contains(t, resp.Tools, mcp.Tool{
Name: "execute_operation_update_mood",
Description: "Executes the GraphQL operation 'UpdateMood' of type mutation. This mutation update the mood of an employee.",
Description: "This mutation update the mood of an employee.",
InputSchema: mcp.ToolInputSchema{Type: "object", Properties: map[string]interface{}{"employeeID": map[string]interface{}{"type": "integer"}, "mood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}}, Required: []string{"employeeID", "mood"}}, RawInputSchema: json.RawMessage(nil),
Annotations: mcp.ToolAnnotation{
Title: "Execute operation UpdateMood",
Expand Down Expand Up @@ -553,4 +559,199 @@ func TestMCP(t *testing.T) {
})
})
})

t.Run("Operation Description Extraction", func(t *testing.T) {
// TestMCPOperationDescriptionExtraction tests that the MCP server properly extracts
// descriptions from GraphQL operations and uses them for tool descriptions
t.Run("Extract descriptions from GraphQL operations", func(t *testing.T) {
// Create a temporary directory for test operations
tempDir := t.TempDir()

// Create test operation files
testCases := []struct {
name string
filename string
content string
expectedDesc string
expectDescEmpty bool
}{
{
name: "operation with multi-line description",
filename: "FindUser.graphql",
content: `"""
Finds a user by their unique identifier.
Returns comprehensive user information including profile and settings.

Required permissions: user:read
"""
query FindUser($id: ID!) {
user(id: $id) {
id
name
email
}
}`,
expectedDesc: "Finds a user by their unique identifier.\nReturns comprehensive user information including profile and settings.\n\nRequired permissions: user:read",
},
{
name: "operation with single-line description",
filename: "GetProfile.graphql",
content: `"""Gets the current user's profile"""
query GetProfile {
me {
id
name
}
}`,
expectedDesc: "Gets the current user's profile",
},
{
name: "operation without description",
filename: "ListUsers.graphql",
content: `query ListUsers {
users {
id
name
}
}`,
expectDescEmpty: true,
},
{
name: "mutation with description",
filename: "CreateUser.graphql",
content: `"""
Creates a new user in the system.
Requires admin privileges.
"""
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
name
}
}`,
expectedDesc: "Creates a new user in the system.\nRequires admin privileges.",
},
}

// Write test files
for _, tc := range testCases {
err := os.WriteFile(filepath.Join(tempDir, tc.filename), []byte(tc.content), 0644)
require.NoError(t, err, "Failed to write test file %s", tc.filename)
}

// Create a simple schema for validation
schemaStr := `
type Query {
user(id: ID!): User
users: [User!]!
me: User
}

type Mutation {
createUser(input: UserInput!): User
}

type User {
id: ID!
name: String!
email: String
}

input UserInput {
name: String!
email: String
}
`
schemaDoc, report := astparser.ParseGraphqlDocumentString(schemaStr)
require.False(t, report.HasErrors(), "Failed to parse schema")

// Normalize the schema (required for validation)
err := asttransform.MergeDefinitionWithBaseSchema(&schemaDoc)
require.NoError(t, err, "Failed to normalize schema")

// Load operations using the OperationLoader
logger := zap.NewNop()
loader := schemaloader.NewOperationLoader(logger, &schemaDoc)
operations, err := loader.LoadOperationsFromDirectory(tempDir)
require.NoError(t, err, "Failed to load operations")
require.Len(t, operations, len(testCases), "Expected %d operations to be loaded", len(testCases))

// Verify each operation has the correct description
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Find the operation by name
var op *schemaloader.Operation
for i := range operations {
if operations[i].FilePath == filepath.Join(tempDir, tc.filename) {
op = &operations[i]
break
}
}
require.NotNil(t, op, "Operation not found: %s", tc.filename)

// Verify description
if tc.expectDescEmpty {
assert.Empty(t, op.Description, "Expected empty description for %s", tc.name)
} else {
assert.Equal(t, tc.expectedDesc, op.Description, "Description mismatch for %s", tc.name)
}
})
}
})
})

t.Run("Tool Description Usage", func(t *testing.T) {
// TestMCPToolDescriptionUsage tests that operation descriptions are properly used
// when creating MCP tool descriptions
tests := []struct {
name string
operationDesc string
operationName string
operationType string
expectedToolDesc string
expectDefaultFormat bool
}{
{
name: "uses operation description when present",
operationDesc: "Finds a user by ID and returns their profile",
operationName: "FindUser",
operationType: "query",
expectedToolDesc: "Finds a user by ID and returns their profile",
},
{
name: "uses default format when description is empty",
operationDesc: "",
operationName: "GetUsers",
operationType: "query",
expectDefaultFormat: true,
},
{
name: "uses mutation description",
operationDesc: "Creates a new user with the provided input",
operationName: "CreateUser",
operationType: "mutation",
expectedToolDesc: "Creates a new user with the provided input",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate what the MCP server does when creating tool descriptions
var toolDescription string
if tt.operationDesc != "" {
toolDescription = tt.operationDesc
} else {
// This is the default format used in server.go
toolDescription = "Executes the GraphQL operation '" + tt.operationName + "' of type " + tt.operationType + "."
}

if tt.expectDefaultFormat {
assert.Contains(t, toolDescription, tt.operationName, "Default description should contain operation name")
assert.Contains(t, toolDescription, tt.operationType, "Default description should contain operation type")
} else {
assert.Equal(t, tt.expectedToolDesc, toolDescription, "Tool description should match operation description")
}
})
}
})
}
2 changes: 1 addition & 1 deletion router/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/twmb/franz-go v1.16.1
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.232
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.234
// Do not upgrade, it renames attributes we rely on
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0
go.opentelemetry.io/contrib/propagators/b3 v1.23.0
Expand Down
4 changes: 2 additions & 2 deletions router/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk=
github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.232 h1:G04FDSXlEaQZS9cBrKlP8djbzquoQElB7w7i/d4sAHg=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.232/go.mod h1:ErOQH1ki2+SZB8JjpTyGVnoBpg5picIyjvuWQJP4abg=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.234 h1:yk+HTcTq61JxM/TvlZy1Fnc90I9whgGAKfmoaHWR6is=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.234/go.mod h1:ErOQH1ki2+SZB8JjpTyGVnoBpg5picIyjvuWQJP4abg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
Expand Down
4 changes: 2 additions & 2 deletions router/pkg/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,10 @@ func (s *GraphQLSchemaServer) registerTools() error {
// Convert the operation name to snake_case for consistent tool naming
operationToolName := strcase.ToSnake(op.Name)

// Use the operation description directly if provided, otherwise generate a default description
var toolDescription string

if op.Description != "" {
toolDescription = fmt.Sprintf("Executes the GraphQL operation '%s' of type %s. %s", op.Name, op.OperationType, op.Description)
toolDescription = op.Description
} else {
toolDescription = fmt.Sprintf("Executes the GraphQL operation '%s' of type %s.", op.Name, op.OperationType)
}
Expand Down
19 changes: 19 additions & 0 deletions router/pkg/schemaloader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,17 @@ func (l *OperationLoader) LoadOperationsFromDirectory(dirPath string) ([]Operati
}
}

// Extract description from operation definition
opDescription := extractOperationDescription(&opDoc)

// Add to our list of operations
operations = append(operations, Operation{
Name: opName,
FilePath: path,
Document: opDoc,
OperationString: operationString,
OperationType: opType,
Description: opDescription,
})

return nil
Expand Down Expand Up @@ -181,3 +185,18 @@ func getOperationNameAndType(doc *ast.Document) (string, string, error) {
}
return "", "", fmt.Errorf("no operation found in document")
}

// extractOperationDescription extracts the description string from an operation definition
func extractOperationDescription(doc *ast.Document) string {
for _, ref := range doc.RootNodes {
if ref.Kind == ast.NodeKindOperationDefinition {
opDef := doc.OperationDefinitions[ref.Ref]
if opDef.Description.IsDefined && opDef.Description.Content.Length() > 0 {
description := doc.Input.ByteSliceString(opDef.Description.Content)
return strings.TrimSpace(description)
}
return ""
}
}
return ""
}
Loading