diff --git a/router-tests/go.mod b/router-tests/go.mod index c6e0cb99e7..418a321fa8 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -36,7 +36,7 @@ require ( go.uber.org/goleak v1.3.0 go.uber.org/zap v1.27.0 golang.org/x/net v0.46.0 - google.golang.org/grpc v1.68.1 + google.golang.org/grpc v1.71.0 google.golang.org/protobuf v1.36.9 gopkg.in/yaml.v3 v3.0.1 ) @@ -170,15 +170,15 @@ require ( go.uber.org/ratelimit v0.3.1 // indirect go.withmatt.com/connect-brotli v0.4.0 // indirect golang.org/x/crypto v0.43.0 // indirect - golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect ) diff --git a/router-tests/go.sum b/router-tests/go.sum index 2cebcf85bc..ee029ab67c 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -416,8 +416,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= @@ -464,12 +464,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= -google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= diff --git a/router-tests/mcp_test.go b/router-tests/mcp_test.go index ffe2ddf082..5f77b89366 100644 --- a/router-tests/mcp_test.go +++ b/router-tests/mcp_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "strings" "sync" "testing" @@ -14,6 +16,10 @@ import ( "github.com/wundergraph/cosmo/router-tests/testenv" "github.com/wundergraph/cosmo/router/core" "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) { @@ -126,7 +132,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"}}, @@ -143,7 +149,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", @@ -555,6 +561,201 @@ 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") + } + }) + } + }) + t.Run("Header Forwarding", func(t *testing.T) { t.Run("All request headers are forwarded from MCP client through to subgraphs", func(t *testing.T) { // This test validates that ALL headers sent by MCP clients are forwarded diff --git a/router/go.mod b/router/go.mod index c73632d5a6..b0337fdf7f 100644 --- a/router/go.mod +++ b/router/go.mod @@ -52,7 +52,7 @@ require ( go.withmatt.com/connect-brotli v0.4.0 golang.org/x/sync v0.17.0 golang.org/x/sys v0.37.0 // indirect - google.golang.org/grpc v1.68.1 + google.golang.org/grpc v1.71.0 google.golang.org/protobuf v1.36.9 ) @@ -82,7 +82,7 @@ require ( github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 go.uber.org/goleak v1.3.0 go.uber.org/ratelimit v0.3.1 - golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 golang.org/x/text v0.30.0 golang.org/x/time v0.9.0 ) @@ -166,8 +166,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/net v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/router/go.sum b/router/go.sum index 0605695dad..06324c9c54 100644 --- a/router/go.sum +++ b/router/go.sum @@ -382,8 +382,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -426,12 +426,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= -google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 2140966de8..fc5812869e 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -510,10 +510,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) } diff --git a/router/pkg/schemaloader/loader.go b/router/pkg/schemaloader/loader.go index 5a4cb928eb..cd3f53dad8 100644 --- a/router/pkg/schemaloader/loader.go +++ b/router/pkg/schemaloader/loader.go @@ -116,6 +116,9 @@ 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, @@ -123,6 +126,7 @@ func (l *OperationLoader) LoadOperationsFromDirectory(dirPath string) ([]Operati Document: opDoc, OperationString: operationString, OperationType: opType, + Description: opDescription, }) return nil @@ -174,10 +178,25 @@ func getOperationNameAndType(doc *ast.Document) (string, string, error) { } if opDef.Name.Length() > 0 { - return doc.Input.ByteSliceString(opDef.Name), opType, nil + return string(doc.Input.ByteSlice(opDef.Name)), opType, nil } return "", opType, nil } } 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 := string(doc.Input.ByteSlice(opDef.Description.Content)) + return strings.TrimSpace(description) + } + return "" + } + } + return "" +} diff --git a/router/pkg/schemaloader/loader_test.go b/router/pkg/schemaloader/loader_test.go new file mode 100644 index 0000000000..0108cef6b7 --- /dev/null +++ b/router/pkg/schemaloader/loader_test.go @@ -0,0 +1,167 @@ +package schemaloader + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" + "go.uber.org/zap" +) + +// TestLoadOperationsWithDescriptions tests that the OperationLoader properly loads +// operations from files and extracts their descriptions +func TestLoadOperationsWithDescriptions(t *testing.T) { + // Create a temporary directory for test operations + tempDir := t.TempDir() + + // Create test operation files + testFiles := map[string]string{ + "WithDescription.graphql": `""" +This operation finds employees by their ID. +It returns detailed employee information. +""" +query FindEmployee($id: ID!) { + employee(id: $id) { + id + name + email + } +}`, + "WithoutDescription.graphql": `query ListEmployees { + employees { + id + name + } +}`, + "SingleLineDescription.graphql": `"""Gets the current user""" +query GetCurrentUser { + me { + id + name + } +}`, + } + + // Write test files + for filename, content := range testFiles { + err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644) + require.NoError(t, err, "Failed to write test file %s", filename) + } + + // Create a schema that matches all test operations + schemaStr := ` +schema { + query: Query +} + +type Query { + employee(id: ID!): Employee + employees: [Employee!]! + me: User +} + +type Employee { + id: ID! + name: String! + email: String! +} + +type User { + id: ID! + name: 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 with a development logger to see errors + logger, _ := zap.NewDevelopment() + loader := NewOperationLoader(logger, &schemaDoc) + operations, err := loader.LoadOperationsFromDirectory(tempDir) + require.NoError(t, err, "Failed to load operations") + + // Debug: print what we got + t.Logf("Loaded %d operations", len(operations)) + for _, op := range operations { + t.Logf("Operation: %s (type: %s, desc: %q)", op.Name, op.OperationType, op.Description) + } + + require.Len(t, operations, 3, "Expected 3 operations to be loaded") + + // Verify operations + opMap := make(map[string]Operation) + for _, op := range operations { + opMap[filepath.Base(op.FilePath)] = op + } + + // Test operation with multi-line description + op1 := opMap["WithDescription.graphql"] + assert.Equal(t, "FindEmployee", op1.Name) + assert.Contains(t, op1.Description, "This operation finds employees by their ID") + assert.Contains(t, op1.Description, "It returns detailed employee information") + + // Test operation without description + op2 := opMap["WithoutDescription.graphql"] + assert.Equal(t, "ListEmployees", op2.Name) + assert.Empty(t, op2.Description, "Operation without description should have empty description") + + // Test operation with single-line description + op3 := opMap["SingleLineDescription.graphql"] + assert.Equal(t, "GetCurrentUser", op3.Name) + assert.Equal(t, "Gets the current user", op3.Description) +} + +// TestLoadOperationsValidation tests that invalid operations are properly rejected +func TestLoadOperationsValidation(t *testing.T) { + tempDir := t.TempDir() + + // Create an invalid operation (references non-existent type) + invalidOp := `"""This operation is invalid""" +query InvalidQuery { + nonExistentField { + id + } +} +` + err := os.WriteFile(filepath.Join(tempDir, "Invalid.graphql"), []byte(invalidOp), 0644) + require.NoError(t, err) + + // Create a simple schema + schemaStr := ` +type Query { + validField: String +} +` + schemaDoc, report := astparser.ParseGraphqlDocumentString(schemaStr) + require.False(t, report.HasErrors()) + + // Load operations - invalid operation should be skipped + logger := zap.NewNop() + loader := NewOperationLoader(logger, &schemaDoc) + operations, err := loader.LoadOperationsFromDirectory(tempDir) + require.NoError(t, err, "LoadOperationsFromDirectory should not return error for invalid operations") + assert.Len(t, operations, 0, "Invalid operations should be skipped") +} + +// TestLoadOperationsFromEmptyDirectory tests loading from an empty directory +func TestLoadOperationsFromEmptyDirectory(t *testing.T) { + tempDir := t.TempDir() + + schemaStr := `type Query { test: String }` + schemaDoc, report := astparser.ParseGraphqlDocumentString(schemaStr) + require.False(t, report.HasErrors()) + + logger := zap.NewNop() + loader := NewOperationLoader(logger, &schemaDoc) + operations, err := loader.LoadOperationsFromDirectory(tempDir) + require.NoError(t, err) + assert.Len(t, operations, 0, "Empty directory should return no operations") +} \ No newline at end of file diff --git a/router/pkg/schemaloader/schema_builder.go b/router/pkg/schemaloader/schema_builder.go index 1e304a950b..a6bfa7a141 100644 --- a/router/pkg/schemaloader/schema_builder.go +++ b/router/pkg/schemaloader/schema_builder.go @@ -46,7 +46,13 @@ func (b *SchemaBuilder) buildSchemaForOperation(operation *Operation) error { return fmt.Errorf("failed to marshal schema: %w", err) } operation.JSONSchema = s - operation.Description = schema.Description + + // Use operation description if provided, otherwise fall back to schema description + // This ensures user-provided descriptions take absolute priority + if operation.Description == "" { + operation.Description = schema.Description + } + // If operation.Description is not empty, keep it as-is (don't merge with schema description) } return nil