From d5e0f01655c61c4d14fccc947eddf56d640b6834 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 4 Aug 2026 10:47:42 +0100 Subject: [PATCH 1/4] feat(mcp): add structured tool output behind mcp.output_schema flag Implements MCP structured tool output (spec revision 2025-06-18) in the router MCP server, opt-in via mcp.output_schema.enabled (default false, env MCP_OUTPUT_SCHEMA_ENABLED): - every tool generated from a GraphQL operation declares an outputSchema derived from the operation's selection set (aliases, fragments, @skip/@include/@defer, abstract types, custom scalars); schemas are deliberately permissive so a valid response is never rejected, and a build failure only degrades the tool to schema-less registration - successful tool results additionally carry the response as structuredContent mirroring the text content (also for execute_graphql, which declares no output schema) Behavioral change, applied unconditionally (not gated by the flag): a response body that cannot be a GraphQL response (non-JSON, empty, or literal null) now returns IsError: true instead of a success-looking text result. Spec-valid GraphQL error envelopes and partial results keep their existing IsError semantics. Opt-in because output schemas inflate tools/list payloads and structured content roughly doubles result sizes, both of which consume MCP client context budgets. --- router-tests/protocol/mcp_test.go | 216 ++++++++ router/core/router.go | 1 + router/pkg/config/config.go | 9 + router/pkg/config/config.schema.json | 12 + router/pkg/config/fixtures/full.yaml | 2 + .../pkg/config/testdata/config_defaults.json | 5 +- router/pkg/config/testdata/config_full.json | 5 +- router/pkg/mcpserver/response_schema.go | 411 ++++++++++++++++ router/pkg/mcpserver/response_schema_test.go | 465 ++++++++++++++++++ router/pkg/mcpserver/server.go | 61 ++- router/pkg/mcpserver/server_test.go | 186 +++++++ 11 files changed, 1363 insertions(+), 10 deletions(-) create mode 100644 router/pkg/mcpserver/response_schema.go create mode 100644 router/pkg/mcpserver/response_schema_test.go diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index fab165f048..5aa2eb09e0 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -25,6 +25,28 @@ import ( "go.uber.org/zap" ) +// requireStructuredContentMatchesText asserts that a successful tool result also +// exposes its text content as equivalent structured content. +func requireStructuredContentMatchesText(t *testing.T, resp *mcp.CallToolResult, text string) { + t.Helper() + require.NotNil(t, resp.StructuredContent) + var expectedStructured map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(text), &expectedStructured)) + assert.Equal(t, expectedStructured, resp.StructuredContent) +} + +// toolByName returns the tool with the given name from a tools/list response +func toolByName(t *testing.T, tools []mcp.Tool, name string) mcp.Tool { + t.Helper() + for _, tool := range tools { + if tool.Name == name { + return tool + } + } + t.Fatalf("tool %q not found", name) + return mcp.Tool{} +} + func TestMCP(t *testing.T) { t.Run("Discovery", func(t *testing.T) { @@ -502,6 +524,200 @@ Important Notes: }) }) + t.Run("Structured Tool Output", func(t *testing.T) { + outputSchemaEnabled := config.MCPConfiguration{ + Enabled: true, + OutputSchema: config.MCPOutputSchemaConfiguration{Enabled: true}, + } + + t.Run("Tools declare an output schema when enabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: outputSchemaEnabled, + }, func(t *testing.T, xEnv *testenv.Environment) { + + resp, err := xEnv.MCPClient.ListTools(xEnv.Context, mcp.ListToolsRequest{}) + require.NoError(t, err) + require.NotNil(t, resp) + + myEmployees := toolByName(t, resp.Tools, "execute_operation_my_employees") + assert.Equal(t, mcp.ToolOutputSchema{ + Type: "object", + Properties: map[string]interface{}{ + "data": map[string]interface{}{ + "type": []interface{}{"object", "null"}, + "properties": map[string]interface{}{ + "findEmployees": map[string]interface{}{ + "description": "This is a GraphQL query that retrieves a list of employees.", + "type": "array", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "currentMood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}, + "details": map[string]interface{}{ + "type": []interface{}{"object", "null"}, + "properties": map[string]interface{}{ + "forename": map[string]interface{}{"type": "string"}, + "nationality": map[string]interface{}{"enum": []interface{}{"AMERICAN", "DUTCH", "ENGLISH", "GERMAN", "INDIAN", "SPANISH", "UKRAINIAN"}, "type": "string"}, + }, + "required": []interface{}{"forename", "nationality"}, + }, + "id": map[string]interface{}{"type": "integer"}, + "isAvailable": map[string]interface{}{"type": []interface{}{"boolean", "null"}}, + "products": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"enum": []interface{}{"CONSULTANCY", "COSMO", "ENGINE", "FINANCE", "HUMAN_RESOURCES", "MARKETING", "SDK"}, "type": "string"}, + }, + }, + "required": []interface{}{"currentMood", "details", "id", "isAvailable", "products"}, + }, + }, + }, + "required": []interface{}{"findEmployees"}, + }, + }, + }, myEmployees.OutputSchema) + + updateMood := toolByName(t, resp.Tools, "execute_operation_update_mood") + assert.Equal(t, mcp.ToolOutputSchema{ + Type: "object", + Properties: map[string]interface{}{ + "data": map[string]interface{}{ + "type": []interface{}{"object", "null"}, + "properties": map[string]interface{}{ + "updateMood": map[string]interface{}{ + "description": "This mutation update the mood of an employee.", + "type": "object", + "properties": map[string]interface{}{ + "currentMood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}, + "details": map[string]interface{}{ + "type": []interface{}{"object", "null"}, + "properties": map[string]interface{}{"forename": map[string]interface{}{"type": "string"}}, + "required": []interface{}{"forename"}, + }, + "id": map[string]interface{}{"type": "integer"}, + }, + "required": []interface{}{"currentMood", "details", "id"}, + }, + }, + "required": []interface{}{"updateMood"}, + }, + }, + }, updateMood.OutputSchema) + }) + }) + + t.Run("Tools declare no output schema when disabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + + resp, err := xEnv.MCPClient.ListTools(xEnv.Context, mcp.ListToolsRequest{}) + require.NoError(t, err) + require.NotNil(t, resp) + + for _, tool := range resp.Tools { + assert.Equal(t, mcp.ToolOutputSchema{}, tool.OutputSchema, + "tool %q must not declare an output schema when the flag is disabled", tool.Name) + } + }) + }) + + t.Run("Successful results carry structured content matching the text content", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: outputSchemaEnabled, + }, func(t *testing.T, xEnv *testenv.Environment) { + + req := mcp.CallToolRequest{} + req.Params.Name = "execute_operation_my_employees" + req.Params.Arguments = map[string]interface{}{ + "criteria": map[string]interface{}{}, + } + + resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.IsError) + + content, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok) + + requireStructuredContentMatchesText(t, resp, content.Text) + }) + }) + + t.Run("Structured content is returned for execute_graphql without a declared output schema", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + EnableArbitraryOperations: true, + OutputSchema: config.MCPOutputSchemaConfiguration{Enabled: true}, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + + req := mcp.CallToolRequest{} + req.Params.Name = "execute_graphql" + req.Params.Arguments = map[string]interface{}{ + "query": `query { employees { id } }`, + } + + resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.IsError) + + content, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok) + + // The MCP specification permits structured content on tools + // that declare no output schema + requireStructuredContentMatchesText(t, resp, content.Text) + }) + }) + + t.Run("No structured content when disabled", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + + req := mcp.CallToolRequest{} + req.Params.Name = "execute_operation_my_employees" + req.Params.Arguments = map[string]interface{}{ + "criteria": map[string]interface{}{}, + } + + resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) + require.NoError(t, err) + require.NotNil(t, resp) + require.False(t, resp.IsError) + + assert.Nil(t, resp.StructuredContent) + }) + }) + + t.Run("Error results carry no structured content", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: outputSchemaEnabled, + }, func(t *testing.T, xEnv *testenv.Environment) { + + req := mcp.CallToolRequest{} + req.Params.Name = "execute_operation_my_employees" + req.Params.Arguments = map[string]interface{}{ + "criteria": nil, + } + + resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) + require.NoError(t, err) + require.True(t, resp.IsError) + + assert.Nil(t, resp.StructuredContent) + }) + }) + }) + t.Run("CORS", func(t *testing.T) { t.Run("Preflight OPTIONS request returns correct CORS headers", func(t *testing.T) { testenv.Run(t, &testenv.Config{ diff --git a/router/core/router.go b/router/core/router.go index 3491a7e516..35a0d7768b 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -1212,6 +1212,7 @@ func (r *Router) startMCPServer(ctx context.Context) error { mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), mcpserver.WithExposeSchema(r.mcp.ExposeSchema), mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), + mcpserver.WithOutputSchemaEnabled(r.mcp.OutputSchema.Enabled), mcpserver.WithStateless(r.mcp.Session.Stateless), mcpserver.WithInstructions(r.mcp.Server.Discover.Instructions), mcpserver.WithServerVersion(cmp.Or(r.mcp.Server.Version, Version)), diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 781709a25d..9d12adb457 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1358,6 +1358,15 @@ type MCPConfiguration struct { // ResourceDocumentation is a URL to a human-readable page describing this MCP resource, // its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set. ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` + // OutputSchema configures MCP structured tool output (outputSchema + structuredContent). + OutputSchema MCPOutputSchemaConfiguration `yaml:"output_schema,omitempty"` +} + +// MCPOutputSchemaConfiguration configures MCP structured tool output (spec revision 2025-06-18): +// an output schema declared on each operation tool and structured content on successful +// tool results. Disabled by default because it increases tools/list and result payload sizes. +type MCPOutputSchemaConfiguration struct { + Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_OUTPUT_SCHEMA_ENABLED"` } type MCPOAuthConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 8428a4a0e4..5af5298bc6 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2756,6 +2756,18 @@ "default": false, "description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'." }, + "output_schema": { + "type": "object", + "description": "Configuration for MCP structured tool output (MCP specification revision 2025-06-18).", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "When enabled, every tool generated from a GraphQL operation declares an output schema derived from the operation's selection set, and successful tool results additionally carry the response as structured content. This lets MCP clients know the response shape ahead of time and validate results, at the cost of larger tools/list payloads and roughly doubled tool result sizes, which consume more of the AI model's context window." + } + } + }, "resource_documentation": { "type": "string", "description": "A URL to a human-readable page describing this MCP resource, its access policies, and how to get started. Included in the RFC 9728 Protected Resource Metadata response if set.", diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 6e33f4f779..b953f28ae4 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -118,6 +118,8 @@ mcp: instructions: 'Use the operation tools to interact with the graph.' storage: provider_id: mcp + output_schema: + enabled: true watch_config: enabled: true diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 080b9f7a30..fb1ed6d4d7 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -226,7 +226,10 @@ "ScopeChallengeIncludeTokenScopes": false, "MaxScopeCombinations": 2048 }, - "ResourceDocumentation": "" + "ResourceDocumentation": "", + "OutputSchema": { + "Enabled": false + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 446dcc3958..9e04c4c2a2 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -295,7 +295,10 @@ "ScopeChallengeIncludeTokenScopes": false, "MaxScopeCombinations": 2048 }, - "ResourceDocumentation": "" + "ResourceDocumentation": "", + "OutputSchema": { + "Enabled": true + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/mcpserver/response_schema.go b/router/pkg/mcpserver/response_schema.go new file mode 100644 index 0000000000..54e9b9372d --- /dev/null +++ b/router/pkg/mcpserver/response_schema.go @@ -0,0 +1,411 @@ +package mcpserver + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "slices" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/jsonschema" + "github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/literal" +) + +// buildResponseSchema builds a JSON schema describing the GraphQL response +// envelope ({"data": ...}) produced by the operation's selection set. +// The schema is intentionally permissive: it must never reject a valid +// response for the operation. Response objects are therefore left open and +// fields that are only conditionally present are not marked as required. +func buildResponseSchema(operation, definition *ast.Document) (json.RawMessage, error) { + builder := &responseSchemaBuilder{ + operation: operation, + definition: definition, + pendingFragments: make(map[string]bool), + } + + schema, err := builder.build() + if err != nil { + return nil, err + } + + s, err := schema.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to marshal response schema: %w", err) + } + + return s, nil +} + +// responseSchemaBuilder builds a JSON schema for an operation's response from +// its selection set and the schema document +type responseSchemaBuilder struct { + operation *ast.Document + definition *ast.Document + // pendingFragments tracks the fragments on the current expansion path so + // that fragment spread cycles, which are invalid GraphQL and have no finite + // response shape, are rejected instead of recursing forever. With cycles + // rejected, the recursion is bounded by the operation document itself. + pendingFragments map[string]bool +} + +// build resolves the root operation type and wraps the selection set schema +// in the GraphQL response envelope +func (b *responseSchemaBuilder) build() (*jsonschema.JsonSchema, error) { + if len(b.operation.OperationDefinitions) == 0 { + return nil, fmt.Errorf("operation document contains no operation definition") + } + + operationDefinition := b.operation.OperationDefinitions[0] + + var rootTypeName []byte + switch operationDefinition.OperationType { + case ast.OperationTypeQuery: + rootTypeName = b.definition.Index.QueryTypeName + case ast.OperationTypeMutation: + rootTypeName = b.definition.Index.MutationTypeName + default: + return nil, fmt.Errorf("unsupported operation type %d", operationDefinition.OperationType) + } + + rootType, exists := b.definition.Index.FirstNodeByNameBytes(rootTypeName) + if !exists { + return nil, fmt.Errorf("root operation type %q is not defined in the schema", string(rootTypeName)) + } + + if !operationDefinition.HasSelections { + return nil, fmt.Errorf("operation has no selections") + } + + dataSchema, err := b.buildSelectionSetSchema(operationDefinition.SelectionSet, rootType) + if err != nil { + return nil, err + } + + // "data" is null when the response carries request-level errors, so it + // stays nullable and optional + dataSchema.Nullable = true + + // The envelope root is left open so that "errors" or "extensions" members + // never fail validation. The MCP specification requires the top-level type + // of a tool output schema to be "object", so it must not be nullable. + return &jsonschema.JsonSchema{ + Type: jsonschema.TypeObject, + Properties: map[string]*jsonschema.JsonSchema{"data": dataSchema}, + }, nil +} + +// buildSelectionSetSchema returns the object schema for one selection set +// evaluated against the given enclosing schema type. Response objects are left +// open: the schema describes what the router returns, it does not gate it. +func (b *responseSchemaBuilder) buildSelectionSetSchema(selectionSetRef int, parentType ast.Node) (*jsonschema.JsonSchema, error) { + properties := make(map[string]*jsonschema.JsonSchema) + required := make(map[string]bool) + + if err := b.collectSelections(selectionSetRef, parentType, false, properties, required); err != nil { + return nil, err + } + + return &jsonschema.JsonSchema{ + Type: jsonschema.TypeObject, + Properties: properties, + // Sort the required keys for deterministic output + Required: slices.Sorted(maps.Keys(required)), + Nullable: true, // response objects are nullable unless a non-null wrapper flips it off + }, nil +} + +// collectSelections flattens the fields, inline fragments and fragment spreads +// of one selection set into the properties and required maps. A key is required +// once it is selected unconditionally at least once; conditional marks +// selections that may be absent from the response because of a @skip, @include +// or @defer directive or a type condition that is not guaranteed to match. +func (b *responseSchemaBuilder) collectSelections(selectionSetRef int, parentType ast.Node, conditional bool, properties map[string]*jsonschema.JsonSchema, required map[string]bool) error { + for _, selectionRef := range b.operation.SelectionSets[selectionSetRef].SelectionRefs { + selection := b.operation.Selections[selectionRef] + + switch selection.Kind { + case ast.SelectionKindField: + fieldRef := selection.Ref + fieldConditional := conditional || b.hasConditionalDirective(b.operation.Fields[fieldRef].Directives) + + fieldSchema, err := b.buildFieldSchema(fieldRef, parentType) + if err != nil { + return err + } + + // The response key is the field alias when one is set. The same key + // selected through multiple fragments is merged into one schema. + key := b.operation.FieldAliasOrNameString(fieldRef) + if existing, ok := properties[key]; ok { + fieldSchema = mergeFieldSchemas(existing, fieldSchema) + } + properties[key] = fieldSchema + + if !fieldConditional { + required[key] = true + } + + case ast.SelectionKindInlineFragment: + inlineFragmentRef := selection.Ref + fragmentConditional := conditional || b.hasConditionalDirective(b.operation.InlineFragments[inlineFragmentRef].Directives) + + fragmentType := parentType + if b.operation.InlineFragmentHasTypeCondition(inlineFragmentRef) { + typeConditionName := b.operation.InlineFragmentTypeConditionNameString(inlineFragmentRef) + node, exists := b.definition.Index.FirstNodeByNameStr(typeConditionName) + if !exists { + return fmt.Errorf("type condition %q is not defined in the schema", typeConditionName) + } + fragmentType = node + fragmentConditional = fragmentConditional || !b.fragmentAlwaysMatches(parentType, typeConditionName) + } + + if fragmentSelectionSetRef, ok := b.operation.InlineFragmentSelectionSet(inlineFragmentRef); ok { + if err := b.collectSelections(fragmentSelectionSetRef, fragmentType, fragmentConditional, properties, required); err != nil { + return err + } + } + + case ast.SelectionKindFragmentSpread: + fragmentSpreadRef := selection.Ref + fragmentNameBytes := b.operation.FragmentSpreadNameBytes(fragmentSpreadRef) + fragmentName := string(fragmentNameBytes) + + // A fragment that spreads itself, directly or transitively, is + // invalid GraphQL and has no finite response shape + if b.pendingFragments[fragmentName] { + return fmt.Errorf("fragment %q forms a cycle", fragmentName) + } + + fragmentDefinitionRef, exists := b.operation.FragmentDefinitionRef(fragmentNameBytes) + if !exists { + return fmt.Errorf("fragment %q is not defined in the operation document", fragmentName) + } + + fragmentConditional := conditional || b.hasConditionalDirective(b.operation.FragmentSpreads[fragmentSpreadRef].Directives) + + typeConditionName := b.operation.FragmentDefinitionTypeNameString(fragmentDefinitionRef) + fragmentType, exists := b.definition.Index.FirstNodeByNameStr(typeConditionName) + if !exists { + return fmt.Errorf("type condition %q is not defined in the schema", typeConditionName) + } + fragmentConditional = fragmentConditional || !b.fragmentAlwaysMatches(parentType, typeConditionName) + + if b.operation.FragmentDefinitions[fragmentDefinitionRef].HasSelections { + b.pendingFragments[fragmentName] = true + if err := b.collectSelections(b.operation.FragmentDefinitions[fragmentDefinitionRef].SelectionSet, fragmentType, fragmentConditional, properties, required); err != nil { + return err + } + delete(b.pendingFragments, fragmentName) + } + } + } + + return nil +} + +// buildFieldSchema returns the schema for a single field selection +func (b *responseSchemaBuilder) buildFieldSchema(fieldRef int, parentType ast.Node) (*jsonschema.JsonSchema, error) { + fieldName := b.operation.FieldNameBytes(fieldRef) + + // __typename is valid on any composite type and is always a non-null string + if bytes.Equal(fieldName, literal.TYPENAME) { + return jsonschema.NewStringSchema().WithNullable(false), nil + } + + fieldDefinitionRef, exists := b.definition.NodeFieldDefinitionByName(parentType, fieldName) + if !exists { + return nil, fmt.Errorf("field %q is not defined on type %q", string(fieldName), parentType.NameString(b.definition)) + } + + schema, err := b.buildTypeRefSchema(b.definition.FieldDefinitionType(fieldDefinitionRef), fieldRef) + if err != nil { + return nil, err + } + + // Field descriptions take precedence over type descriptions + if b.definition.FieldDefinitions[fieldDefinitionRef].Description.IsDefined { + schema.Description = b.definition.FieldDefinitionDescriptionString(fieldDefinitionRef) + } + + return schema, nil +} + +// buildTypeRefSchema resolves the schema of a type reference from the schema +// document. The selection set of fieldRef provides the shape of composite types. +func (b *responseSchemaBuilder) buildTypeRefSchema(typeRef, fieldRef int) (*jsonschema.JsonSchema, error) { + switch b.definition.Types[typeRef].TypeKind { + case ast.TypeKindNonNull: + schema, err := b.buildTypeRefSchema(b.definition.Types[typeRef].OfType, fieldRef) + if err != nil { + return nil, err + } + // Non-null types are not nullable + schema.Nullable = false + return schema, nil + + case ast.TypeKindList: + itemSchema, err := b.buildTypeRefSchema(b.definition.Types[typeRef].OfType, fieldRef) + if err != nil { + return nil, err + } + // If we're not in a non-null context, the list is nullable + return jsonschema.NewArraySchema(itemSchema), nil + + case ast.TypeKindNamed: + return b.buildNamedTypeSchema(b.definition.TypeNameString(typeRef), fieldRef) + } + + return nil, fmt.Errorf("unknown type kind %d", b.definition.Types[typeRef].TypeKind) +} + +// buildNamedTypeSchema resolves the schema of a named type. Composite types +// take their shape from the selection set of fieldRef. +func (b *responseSchemaBuilder) buildNamedTypeSchema(typeName string, fieldRef int) (*jsonschema.JsonSchema, error) { + // Handle built-in scalars + switch typeName { + case "String", "ID": + return jsonschema.NewStringSchema(), nil + case "Int": + return jsonschema.NewIntegerSchema(), nil + case "Float": + return jsonschema.NewNumberSchema(), nil + case "Boolean": + return jsonschema.NewBooleanSchema(), nil + } + + // For custom types, look up in the definition document + node, exists := b.definition.Index.FirstNodeByNameStr(typeName) + if !exists { + return nil, fmt.Errorf("type %q is not defined", typeName) + } + + // Process the type based on its kind + switch node.Kind { + case ast.NodeKindEnumTypeDefinition: + enumDefinition := b.definition.EnumTypeDefinitions[node.Ref] + values := make([]string, 0, len(enumDefinition.EnumValuesDefinition.Refs)) + for _, valueRef := range enumDefinition.EnumValuesDefinition.Refs { + values = append(values, b.definition.EnumValueDefinitionNameString(valueRef)) + } + + schema := jsonschema.NewEnumSchema(values) + // Add description if available + if enumDefinition.Description.IsDefined { + schema.Description = b.definition.EnumTypeDefinitionDescriptionString(node.Ref) + } + return schema, nil + + case ast.NodeKindScalarTypeDefinition: + // Custom scalars accept any JSON value + schema := jsonschema.NewAnySchema() + // Add description if available + if b.definition.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + schema.Description = b.definition.ScalarTypeDefinitionDescriptionString(node.Ref) + } + return schema, nil + + case ast.NodeKindObjectTypeDefinition, ast.NodeKindInterfaceTypeDefinition, ast.NodeKindUnionTypeDefinition: + selectionSetRef, ok := b.operation.FieldSelectionSet(fieldRef) + if !ok { + return nil, fmt.Errorf("composite field %q has no selection set", b.operation.FieldNameString(fieldRef)) + } + return b.buildSelectionSetSchema(selectionSetRef, node) + + default: + // If we can't determine the type, default to any + return jsonschema.NewAnySchema(), nil + } +} + +// hasConditionalDirective reports whether the selection carries a directive +// that can make it absent from the response. The directive arguments are not +// inspected: a field behind @include(if: true) is still treated as optional, +// which can never reject a valid response. +func (b *responseSchemaBuilder) hasConditionalDirective(directives ast.DirectiveList) bool { + if _, exists := directives.HasDirectiveByNameBytes(b.operation, literal.SKIP); exists { + return true + } + if _, exists := directives.HasDirectiveByNameBytes(b.operation, literal.INCLUDE); exists { + return true + } + if _, exists := directives.HasDirectiveByNameBytes(b.operation, literal.DEFER); exists { + return true + } + return false +} + +// fragmentAlwaysMatches reports whether a fragment with the given type +// condition matches every possible runtime type of parentType, i.e. whether +// its fields are unconditionally present in the response +func (b *responseSchemaBuilder) fragmentAlwaysMatches(parentType ast.Node, typeConditionName string) bool { + if parentType.NameString(b.definition) == typeConditionName { + return true + } + + // The runtime type of an abstract parent may not match a narrower or + // sibling type condition + if parentType.Kind != ast.NodeKindObjectTypeDefinition { + return false + } + + conditionNode, exists := b.definition.Index.FirstNodeByNameStr(typeConditionName) + if !exists { + return false + } + + switch conditionNode.Kind { + case ast.NodeKindInterfaceTypeDefinition: + return b.definition.NodeImplementsInterface(parentType, []byte(typeConditionName)) + case ast.NodeKindUnionTypeDefinition: + memberTypeNames, ok := b.definition.UnionTypeDefinitionMemberTypeNames(conditionNode.Ref) + return ok && slices.Contains(memberTypeNames, parentType.NameString(b.definition)) + } + + return false +} + +// mergeFieldSchemas merges two schemas produced for the same response key, +// e.g. the same field reached through multiple fragments. Structurally equal +// schemas are kept, two object schemas are merged recursively, and anything +// else degrades to the empty (accept-anything) schema so that a valid +// response is never rejected. +func mergeFieldSchemas(a, b *jsonschema.JsonSchema) *jsonschema.JsonSchema { + aJSON, aErr := a.MarshalJSON() + bJSON, bErr := b.MarshalJSON() + if aErr == nil && bErr == nil && bytes.Equal(aJSON, bJSON) { + return a + } + + if a.Type == jsonschema.TypeObject && b.Type == jsonschema.TypeObject { + merged := &jsonschema.JsonSchema{ + Type: jsonschema.TypeObject, + Properties: make(map[string]*jsonschema.JsonSchema, len(a.Properties)+len(b.Properties)), + Nullable: a.Nullable || b.Nullable, + Description: a.Description, + } + + maps.Copy(merged.Properties, a.Properties) + for key, property := range b.Properties { + if existing, ok := merged.Properties[key]; ok { + merged.Properties[key] = mergeFieldSchemas(existing, property) + } else { + merged.Properties[key] = property + } + } + + // A key is only guaranteed to be present if every variant requires it + for _, key := range a.Required { + if slices.Contains(b.Required, key) { + merged.Required = append(merged.Required, key) + } + } + + return merged + } + + // The same response key can resolve to incompatible shapes, e.g. one alias + // bound to fields of different types on different union members + return jsonschema.NewAnySchema() +} diff --git a/router/pkg/mcpserver/response_schema_test.go b/router/pkg/mcpserver/response_schema_test.go new file mode 100644 index 0000000000..2464fc0bc2 --- /dev/null +++ b/router/pkg/mcpserver/response_schema_test.go @@ -0,0 +1,465 @@ +package mcpserver + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "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" +) + +// buildTestResponseSchema parses the schema and operation and builds the +// response schema, returning the builder error for error-path tests +func buildTestResponseSchema(t *testing.T, schemaStr, operationStr string) (json.RawMessage, error) { + t.Helper() + + schemaDoc, report := astparser.ParseGraphqlDocumentString(schemaStr) + require.False(t, report.HasErrors(), "failed to parse schema") + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&schemaDoc)) + + opDoc, report := astparser.ParseGraphqlDocumentString(operationStr) + require.False(t, report.HasErrors(), "failed to parse operation") + + return buildResponseSchema(&opDoc, &schemaDoc) +} + +// mustBuildTestResponseSchema builds the response schema and fails the test on error +func mustBuildTestResponseSchema(t *testing.T, schemaStr, operationStr string) json.RawMessage { + t.Helper() + + schema, err := buildTestResponseSchema(t, schemaStr, operationStr) + require.NoError(t, err) + + return schema +} + +// schemaAt unmarshals the schema and follows the given property path +func schemaAt(t *testing.T, rawSchema json.RawMessage, path ...string) map[string]any { + t.Helper() + + var schema map[string]any + require.NoError(t, json.Unmarshal(rawSchema, &schema)) + + for _, key := range path { + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok, "expected properties containing %q", key) + schema, ok = properties[key].(map[string]any) + require.True(t, ok, "expected property %q", key) + } + + return schema +} + +func TestBuildResponseSchema(t *testing.T) { + t.Run("envelope shape", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { hello: String } +`, `query Hello { hello }`) + + assert.JSONEq(t, `{ + "type": "object", + "properties": { + "data": { + "type": ["object", "null"], + "properties": { + "hello": {"type": ["string", "null"]} + }, + "required": ["hello"] + } + } + }`, string(schema)) + }) + + t.Run("scalar mapping", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { s: String! i: Int! f: Float b: Boolean! id: ID } +`, `query Scalars { s i f b id }`) + + assert.JSONEq(t, `{ + "type": "object", + "properties": { + "data": { + "type": ["object", "null"], + "properties": { + "s": {"type": "string"}, + "i": {"type": "integer"}, + "f": {"type": ["number", "null"]}, + "b": {"type": "boolean"}, + "id": {"type": ["string", "null"]} + }, + "required": ["b", "f", "i", "id", "s"] + } + } + }`, string(schema)) + }) + + t.Run("nested objects and lists", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employees: [Employee!]! } +type Employee { id: ID! tags: [[String!]!] } +`, `query Employees { employees { id tags } }`) + + assert.JSONEq(t, `{ + "type": "object", + "properties": { + "data": { + "type": ["object", "null"], + "properties": { + "employees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "tags": { + "type": ["array", "null"], + "items": { + "type": "array", + "items": {"type": "string"} + } + } + }, + "required": ["id", "tags"] + } + } + }, + "required": ["employees"] + } + } + }`, string(schema)) + }) + + t.Run("aliases become response keys", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! name: String! } +`, `query Aliases { a: employee(id: "1") { id } b: employee(id: "2") { name } }`) + + data := schemaAt(t, schema, "data") + properties := data["properties"].(map[string]any) + assert.Contains(t, properties, "a") + assert.Contains(t, properties, "b") + assert.NotContains(t, properties, "employee") + assert.Equal(t, []any{"a", "b"}, data["required"]) + + assert.Contains(t, schemaAt(t, schema, "data", "a")["properties"], "id") + assert.Contains(t, schemaAt(t, schema, "data", "b")["properties"], "name") + }) + + t.Run("typename is a non-null string", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! } +`, `query Typename { employee(id: "1") { __typename t: __typename id } }`) + + assert.Equal(t, map[string]any{"type": "string"}, schemaAt(t, schema, "data", "employee", "__typename")) + assert.Equal(t, map[string]any{"type": "string"}, schemaAt(t, schema, "data", "employee", "t")) + assert.Equal(t, []any{"__typename", "id", "t"}, schemaAt(t, schema, "data", "employee")["required"]) + }) + + t.Run("enum values and nullability", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { status: Status! mood: Mood } +enum Status { ACTIVE INACTIVE } +enum Mood { HAPPY SAD } +`, `query Enums { status mood }`) + + data := schemaAt(t, schema, "data") + properties := data["properties"].(map[string]any) + assert.Equal(t, map[string]any{"type": "string", "enum": []any{"ACTIVE", "INACTIVE"}}, properties["status"]) + assert.Equal(t, map[string]any{"type": []any{"string", "null"}, "enum": []any{"HAPPY", "SAD", nil}}, properties["mood"]) + }) + + t.Run("custom scalar accepts any value", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { meta: JSON! } +"Arbitrary JSON" +scalar JSON +`, `query Meta { meta }`) + + data := schemaAt(t, schema, "data") + assert.Equal(t, map[string]any{"description": "Arbitrary JSON"}, data["properties"].(map[string]any)["meta"]) + assert.Equal(t, []any{"meta"}, data["required"]) + }) + + t.Run("field descriptions from the schema", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { + "The employee ID" + id: ID! +} +`, `query Description { employee(id: "1") { id } }`) + + id := schemaAt(t, schema, "data", "employee", "id") + assert.Equal(t, "The employee ID", id["description"]) + }) + + t.Run("fragment spread on the same type is unconditional", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! name: String! } +`, `query Fragment { employee(id: "1") { ...Basics } } +fragment Basics on Employee { id name }`) + + employee := schemaAt(t, schema, "data", "employee") + assert.Contains(t, employee["properties"], "id") + assert.Contains(t, employee["properties"], "name") + assert.Equal(t, []any{"id", "name"}, employee["required"]) + }) + + t.Run("skip include and defer make fields optional", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! name: String! email: String phone: String } +`, `query Conditional($b: Boolean!) { + employee(id: "1") { + id @include(if: $b) + name + ... @skip(if: $b) { email } + ...Deferred @defer + } +} +fragment Deferred on Employee { phone }`) + + employee := schemaAt(t, schema, "data", "employee") + properties := employee["properties"].(map[string]any) + assert.Contains(t, properties, "id") + assert.Contains(t, properties, "email") + assert.Contains(t, properties, "phone") + assert.Equal(t, []any{"name"}, employee["required"]) + }) + + t.Run("fragment on an implemented interface is unconditional", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +interface Node { id: ID! } +type Employee implements Node { id: ID! name: String! } +`, `query Interface { employee(id: "1") { ... on Node { id } name } }`) + + employee := schemaAt(t, schema, "data", "employee") + assert.Equal(t, []any{"id", "name"}, employee["required"]) + }) + + t.Run("interface field with concrete fragments", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { node: Node } +interface Node { id: ID! } +type User implements Node { id: ID! name: String! } +type Bot implements Node { id: ID! model: String! } +`, `query Node { node { id __typename ... on User { name } ... on Bot { model } } }`) + + node := schemaAt(t, schema, "data", "node") + properties := node["properties"].(map[string]any) + assert.Contains(t, properties, "id") + assert.Contains(t, properties, "__typename") + assert.Contains(t, properties, "name") + assert.Contains(t, properties, "model") + assert.Equal(t, []any{"__typename", "id"}, node["required"]) + }) + + t.Run("union selections merge into one object", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { search: SearchResult } +union SearchResult = User | Bot +type User { name: String! } +type Bot { model: String! } +`, `query Search { search { __typename ... on User { name } ... on Bot { model } } }`) + + search := schemaAt(t, schema, "data", "search") + properties := search["properties"].(map[string]any) + assert.Contains(t, properties, "name") + assert.Contains(t, properties, "model") + assert.Equal(t, []any{"__typename"}, search["required"]) + }) + + t.Run("same field in multiple branches merges required-ness", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { node: Node } +interface Node { id: ID! } +type User implements Node { id: ID! } +type Bot implements Node { id: ID! } +`, `query Node { node { ... on User { id } ... on Bot { id } id } }`) + + node := schemaAt(t, schema, "data", "node") + assert.Equal(t, map[string]any{"type": "string"}, node["properties"].(map[string]any)["id"]) + assert.Equal(t, []any{"id"}, node["required"]) + }) + + t.Run("conflicting alias across union members degrades to any", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { search: SearchResult } +union SearchResult = User | Bot +type User { age: Int! } +type Bot { label: String! } +`, `query Search { search { ... on User { x: age } ... on Bot { x: label } } }`) + + search := schemaAt(t, schema, "data", "search") + assert.Equal(t, map[string]any{}, search["properties"].(map[string]any)["x"]) + assert.NotContains(t, search, "required") + }) + + t.Run("object schemas merge across branches", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { search: SearchResult } +union SearchResult = User | Bot +type User { profile: Profile } +type Bot { profile: Profile } +type Profile { name: String! age: Int! } +`, `query Search { search { ... on User { profile { name } } ... on Bot { profile { age } } } }`) + + profile := schemaAt(t, schema, "data", "search", "profile") + properties := profile["properties"].(map[string]any) + assert.Contains(t, properties, "name") + assert.Contains(t, properties, "age") + assert.NotContains(t, profile, "required") + }) + + t.Run("mutation root", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query mutation: Mutation } +type Query { employee(id: ID!): Employee } +type Mutation { updateMood(id: ID!): Employee } +type Employee { id: ID! } +`, `mutation UpdateMood { updateMood(id: "1") { id } }`) + + data := schemaAt(t, schema, "data") + assert.Contains(t, data["properties"], "updateMood") + }) + + t.Run("custom root type names", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: RootQ } +type RootQ { ping: String } +`, `query Ping { ping }`) + + data := schemaAt(t, schema, "data") + assert.Contains(t, data["properties"], "ping") + }) +} + +// TestBuildResponseSchemaFragmentCycle proves that fragment spread cycles in +// documents that were never validated against the schema are detected instead +// of recursing forever +func TestBuildResponseSchemaFragmentCycle(t *testing.T) { + t.Run("self cycle", func(t *testing.T) { + _, err := buildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! } +`, `query Cycle { employee(id: "1") { ...F } } +fragment F on Employee { id ...F }`) + + require.ErrorContains(t, err, `fragment "F" forms a cycle`) + }) + + t.Run("mutual cycle", func(t *testing.T) { + _, err := buildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! name: String! } +`, `query Cycle { employee(id: "1") { ...A } } +fragment A on Employee { id ...B } +fragment B on Employee { name ...A }`) + + require.ErrorContains(t, err, "forms a cycle") + }) + + t.Run("same fragment in sibling selections is not a cycle", func(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { employee(id: ID!): Employee } +type Employee { id: ID! name: String! } +`, `query Siblings { a: employee(id: "1") { ...Basics } b: employee(id: "2") { ...Basics } } +fragment Basics on Employee { id name }`) + + for _, key := range []string{"a", "b"} { + selection := schemaAt(t, schema, "data", key) + assert.Contains(t, selection["properties"], "id") + assert.Contains(t, selection["properties"], "name") + assert.Equal(t, []any{"id", "name"}, selection["required"]) + } + }) +} + +// TestBuildResponseSchemaUnknownField documents the degradation contract: the +// builder hard-errors so that the caller registers the tool without an output schema +func TestBuildResponseSchemaUnknownField(t *testing.T) { + _, err := buildTestResponseSchema(t, ` +schema { query: Query } +type Query { hello: String } +`, `query Unknown { bogus }`) + + require.ErrorContains(t, err, "not defined on type") +} + +// TestBuildResponseSchemaNeverRejectsValidResponse pins the guiding principle +// of the builder against a real JSON schema validator: no valid GraphQL +// response for the operation may fail validation against the generated schema +func TestBuildResponseSchemaNeverRejectsValidResponse(t *testing.T) { + schema := mustBuildTestResponseSchema(t, ` +schema { query: Query } +type Query { search: SearchResult employee(id: ID!): Employee } +union SearchResult = User | Bot +type User { name: String! profile: Profile } +type Bot { model: String! profile: Profile } +type Profile { name: String age: Int } +type Employee { id: ID! name: String! email: String } +`, `query Everything($b: Boolean!) { + search { + __typename + ... on User { name profile { name } } + ... on Bot { model profile { age } } + } + employee(id: "1") { + id @include(if: $b) + name + email @skip(if: $b) + } +}`) + + unmarshaled, err := jsonschema.UnmarshalJSON(bytes.NewReader(schema)) + require.NoError(t, err) + + compiler := jsonschema.NewCompiler() + require.NoError(t, compiler.AddResource("response.json", unmarshaled)) + compiled, err := compiler.Compile("response.json") + require.NoError(t, err) + + responses := map[string]string{ + "user branch": `{"data":{"search":{"__typename":"User","name":"Ada","profile":{"name":"p"}},"employee":{"id":"1","name":"Jens","email":null}}}`, + "bot branch, skipped fields": `{"data":{"search":{"__typename":"Bot","model":"m-1","profile":{"age":3}},"employee":{"name":"Jens"}}}`, + "null data": `{"data":null}`, + "errors and extensions": `{"data":{"search":null,"employee":null},"errors":[{"message":"boom"}],"extensions":{"traceId":"abc"}}`, + "null profile": `{"data":{"search":{"__typename":"User","name":"Ada","profile":null},"employee":{"id":"1","name":"Jens","email":"jens@wundergraph.com"}}}`, + } + + for name, response := range responses { + t.Run(name, func(t *testing.T) { + var v any + require.NoError(t, json.Unmarshal([]byte(response), &v)) + assert.NoError(t, compiled.Validate(v), "a valid GraphQL response must never fail validation") + }) + } +} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 1a118a6858..872ea08b70 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -84,6 +84,10 @@ type Options struct { ExposeSchema bool // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names OmitToolNamePrefix bool + // OutputSchemaEnabled declares an output schema on each operation tool and + // adds structured content to successful tool results (MCP structured tool + // output). Increases tools/list and result payload sizes. + OutputSchemaEnabled bool // Stateless determines whether the MCP server should be stateless Stateless bool // CorsConfig is the CORS configuration for the MCP server @@ -127,6 +131,7 @@ type GraphQLSchemaServer struct { enableArbitraryOperations bool exposeSchema bool omitToolNamePrefix bool + outputSchemaEnabled bool stateless bool operationsManager *OperationsManager schemaCompiler *SchemaCompiler @@ -337,6 +342,7 @@ func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, o enableArbitraryOperations: options.EnableArbitraryOperations, exposeSchema: options.ExposeSchema, omitToolNamePrefix: options.OmitToolNamePrefix, + outputSchemaEnabled: options.OutputSchemaEnabled, stateless: options.Stateless, corsConfig: options.CorsConfig, cancel: cancel, @@ -445,6 +451,14 @@ func WithOmitToolNamePrefix(omitToolNamePrefix bool) func(*Options) { } } +// WithOutputSchemaEnabled enables MCP structured tool output: an output schema +// on each operation tool and structured content on successful tool results +func WithOutputSchemaEnabled(outputSchemaEnabled bool) func(*Options) { + return func(o *Options) { + o.OutputSchemaEnabled = outputSchemaEnabled + } +} + func WithCORS(corsCfg cors.Config) func(*Options) { return func(o *Options) { // Force specific CORS settings for MCP server @@ -764,11 +778,26 @@ func (s *GraphQLSchemaServer) registerTools() error { inputSchema = map[string]any{"type": "object", "properties": map[string]any{}} } + // Declare the response envelope of the operation's selection set as the + // tool's output schema. A build failure only degrades the tool: it is + // registered without an output schema. + var outputSchema any + if s.outputSchemaEnabled { + if outputJSONSchema, err := buildResponseSchema(&op.Document, s.operationsManager.GetSchema()); err != nil { + s.logger.Warn("failed to build output schema for operation; registering tool without output schema", + zap.String("operation", op.Name), + zap.Error(err)) + } else { + outputSchema = outputJSONSchema + } + } + openWorld := true tool := &mcp.Tool{ - Name: toolName, - Description: toolDescription, - InputSchema: inputSchema, + Name: toolName, + Description: toolDescription, + InputSchema: inputSchema, + OutputSchema: outputSchema, Annotations: &mcp.ToolAnnotations{ IdempotentHint: op.OperationType != "mutation", Title: fmt.Sprintf("Execute operation %s", op.Name), @@ -987,10 +1016,20 @@ func (s *GraphQLSchemaServer) executeGraphQLQuery(ctx context.Context, query str return nil, fmt.Errorf("failed to read response body: %w", err) } - // Parse the GraphQL response - var graphqlResponse GraphQLResponse + // Per the GraphQL-over-HTTP specification the response body of a GraphQL + // endpoint must be a JSON object, so a body that cannot be parsed as one + // (a proxy error page, an empty body, or a literal JSON null, which leaves + // the pointer nil after unmarshaling) is transport-level breakage and is + // reported as a tool error. + var graphqlResponse *GraphQLResponse + if err := json.Unmarshal(body, &graphqlResponse); err != nil || graphqlResponse == nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Response error: unexpected response from GraphQL endpoint: %s", body)}}, + IsError: true, + }, nil + } - if err := json.Unmarshal(body, &graphqlResponse); err == nil && len(graphqlResponse.Errors) > 0 { + if len(graphqlResponse.Errors) > 0 { // Concatenate all error messages var errorMessages []string for _, gqlErr := range graphqlResponse.Errors { @@ -1016,9 +1055,15 @@ func (s *GraphQLSchemaServer) executeGraphQLQuery(ctx context.Context, query str }, nil } - return &mcp.CallToolResult{ + result := &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: string(body)}}, - }, nil + } + // Expose the response as structured content, per the MCP structured tool + // output specification + if s.outputSchemaEnabled { + result.StructuredContent = json.RawMessage(body) + } + return result, nil } // handleExecuteGraphQL returns a handler function that executes arbitrary GraphQL queries diff --git a/router/pkg/mcpserver/server_test.go b/router/pkg/mcpserver/server_test.go index 3a3c044609..5a09601453 100644 --- a/router/pkg/mcpserver/server_test.go +++ b/router/pkg/mcpserver/server_test.go @@ -1,6 +1,9 @@ package mcpserver import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -195,3 +198,186 @@ func TestReload_PrefixModeAvoidsReservedNameCollision(t *testing.T) { "get_operation_info", }, srv.registeredTools) } + +func TestRegisterTools_OutputSchemaFailureRegistersToolWithoutSchema(t *testing.T) { + core, logs := observer.New(zapcore.DebugLevel) + logger := zap.New(core) + + tempDir := t.TempDir() + writeOperationFiles(t, tempDir, map[string]string{ + "ListEmployees.graphql": listEmployeesOp, + }) + + schemaDoc, report := astparser.ParseGraphqlDocumentString(testSchema) + require.False(t, report.HasErrors()) + err := asttransform.MergeDefinitionWithBaseSchema(&schemaDoc) + require.NoError(t, err) + + srv, err := NewGraphQLSchemaServer( + t.Context(), + "http://localhost:4000/graphql", + WithLogger(logger), + WithOperationsDir(tempDir), + WithOmitToolNamePrefix(true), + WithOutputSchemaEnabled(true), + ) + require.NoError(t, err) + + err = srv.Reload(&schemaDoc, nil) + require.NoError(t, err) + require.Contains(t, srv.registeredTools, "list_employees") + require.Equal(t, 0, logs.FilterMessage("failed to build output schema for operation; registering tool without output schema").Len(), + "no output schema warning expected for a valid operation") + + // Replace the loaded operation with one selecting a field missing from the + // schema and re-register: the tool must still be registered, just without + // an output schema. + brokenDoc, report := astparser.ParseGraphqlDocumentString(`query ListEmployees { bogus }`) + require.False(t, report.HasErrors()) + + operations := srv.operationsManager.GetOperations() + require.Len(t, operations, 1) + operations[0].Document = brokenDoc + + srv.registeredTools = nil + require.NoError(t, srv.registerTools()) + + assert.Contains(t, srv.registeredTools, "list_employees") + assert.Equal(t, 1, logs.FilterMessage("failed to build output schema for operation; registering tool without output schema").Len(), + "expected a warning about the failed output schema") +} + +func TestRegisterTools_NoOutputSchemaBuildWhenDisabled(t *testing.T) { + core, logs := observer.New(zapcore.DebugLevel) + logger := zap.New(core) + + tempDir := t.TempDir() + writeOperationFiles(t, tempDir, map[string]string{ + "ListEmployees.graphql": listEmployeesOp, + }) + + schemaDoc, report := astparser.ParseGraphqlDocumentString(testSchema) + require.False(t, report.HasErrors()) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&schemaDoc)) + + srv, err := NewGraphQLSchemaServer( + t.Context(), + "http://localhost:4000/graphql", + WithLogger(logger), + WithOperationsDir(tempDir), + WithOmitToolNamePrefix(true), + ) + require.NoError(t, err) + + require.NoError(t, srv.Reload(&schemaDoc, nil)) + require.Contains(t, srv.registeredTools, "list_employees") + + // With the flag disabled (default), a broken operation must not produce an + // output schema warning because no schema is built at all. + brokenDoc, report := astparser.ParseGraphqlDocumentString(`query ListEmployees { bogus }`) + require.False(t, report.HasErrors()) + + operations := srv.operationsManager.GetOperations() + require.Len(t, operations, 1) + operations[0].Document = brokenDoc + + srv.registeredTools = nil + require.NoError(t, srv.registerTools()) + + assert.Contains(t, srv.registeredTools, "list_employees") + assert.Equal(t, 0, logs.FilterMessage("failed to build output schema for operation; registering tool without output schema").Len(), + "no output schema is built when the flag is disabled") +} + +// TestExecuteGraphQLQueryResultBoundary pins the result semantics of +// executeGraphQLQuery with structured output enabled: +// - transport-level breakage (non-JSON, empty, or literal null body) is a +// tool error, unconditionally +// - spec-valid GraphQL error envelopes keep their existing IsError semantics +// - successful responses carry structured content mirroring the text content +func TestExecuteGraphQLQueryResultBoundary(t *testing.T) { + testCases := []struct { + name string + responseBody string + wantIsError bool + wantStructuredContent bool + }{ + {"data object succeeds with structured content", `{"data":{"hello":"world"}}`, false, true}, + {"null data without errors succeeds with structured content", `{"data":null}`, false, true}, + {"empty object succeeds with structured content", `{}`, false, true}, + {"errors without data keep returning a tool error", `{"errors":[{"message":"boom"}],"data":null}`, true, false}, + {"errors with partial data keep returning a tool error", `{"errors":[{"message":"boom"}],"data":{"hello":null}}`, true, false}, + {"non-JSON body returns a tool error", `bad gateway`, true, false}, + {"empty body returns a tool error", ``, true, false}, + {"null body returns a tool error", `null`, true, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.responseBody)) + })) + defer upstream.Close() + + srv, err := NewGraphQLSchemaServer( + t.Context(), + upstream.URL, + WithLogger(zap.NewNop()), + WithOperationsDir(t.TempDir()), + WithOutputSchemaEnabled(true), + ) + require.NoError(t, err) + + result, err := srv.executeGraphQLQuery(t.Context(), "query { hello }", nil) + require.NoError(t, err) + + assert.Equal(t, tc.wantIsError, result.IsError) + if tc.wantStructuredContent { + // Structured content must accompany every success result and mirror the text content + assert.Equal(t, json.RawMessage(tc.responseBody), result.StructuredContent) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +// TestExecuteGraphQLQueryStructuredContentDisabled proves that the flag only +// gates structured content: the transport-level error boundary applies +// unconditionally, and successful results stay text-only. +func TestExecuteGraphQLQueryStructuredContentDisabled(t *testing.T) { + testCases := []struct { + name string + responseBody string + wantIsError bool + }{ + {"data object stays text-only", `{"data":{"hello":"world"}}`, false}, + {"non-JSON body is still a tool error", `bad gateway`, true}, + {"null body is still a tool error", `null`, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.responseBody)) + })) + defer upstream.Close() + + srv, err := NewGraphQLSchemaServer( + t.Context(), + upstream.URL, + WithLogger(zap.NewNop()), + WithOperationsDir(t.TempDir()), + ) + require.NoError(t, err) + + result, err := srv.executeGraphQLQuery(t.Context(), "query { hello }", nil) + require.NoError(t, err) + + assert.Equal(t, tc.wantIsError, result.IsError) + assert.Nil(t, result.StructuredContent) + }) + } +} From 741fe08200023a43247e1683efbba17a31f2eb1b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 4 Aug 2026 10:49:23 +0100 Subject: [PATCH 2/4] docs(mcp): document the output_schema structured tool output flag Adds a Structured Tool Output section to the MCP tools page and the output_schema.enabled key to the configuration reference (options table, environment variables, full example), including the tools/list and result payload-size tradeoff. --- docs-website/router/mcp/configuration.mdx | 4 ++++ docs-website/router/mcp/tools.mdx | 27 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index d257a4e5fa..1cd4dbbbd6 100644 --- a/docs-website/router/mcp/configuration.mdx +++ b/docs-website/router/mcp/configuration.mdx @@ -43,6 +43,7 @@ storage_providers: | `enable_arbitrary_operations` | Enables the `execute_graphql` built-in tool, allowing clients to run arbitrary GraphQL operations beyond the pre-defined operation set. | `false` | | `expose_schema` | Enables the `get_schema` built-in tool, exposing the full GraphQL schema to MCP clients. | `false` | | `omit_tool_name_prefix` | When enabled, MCP tool names omit the `execute_operation_` prefix. For example, `GetUser` becomes `get_user` instead of `execute_operation_get_user`. See [Tools - Omitting the Tool Name Prefix](/router/mcp/tools#omitting-the-tool-name-prefix). | `false` | +| `output_schema.enabled` | When enabled, every operation tool declares an output schema derived from its selection set, and successful tool results additionally carry the response as structured content. Increases `tools/list` and result payload sizes. See [Tools - Structured Tool Output](/router/mcp/tools#structured-tool-output). | `false` | For OAuth-specific configuration, see [OAuth 2.1 Authorization](/router/mcp/oauth/overview). @@ -67,6 +68,7 @@ All MCP options can also be set via environment variables: | `MCP_ENABLE_ARBITRARY_OPERATIONS` | `mcp.enable_arbitrary_operations` | | `MCP_EXPOSE_SCHEMA` | `mcp.expose_schema` | | `MCP_OMIT_TOOL_NAME_PREFIX` | `mcp.omit_tool_name_prefix` | +| `MCP_OUTPUT_SCHEMA_ENABLED` | `mcp.output_schema.enabled` | For OAuth-related environment variables, see [OAuth Configuration Reference](/router/mcp/oauth/configuration#environment-variables). @@ -140,6 +142,8 @@ mcp: enable_arbitrary_operations: false expose_schema: false omit_tool_name_prefix: false + output_schema: + enabled: false storage: provider_id: 'mcp' diff --git a/docs-website/router/mcp/tools.mdx b/docs-website/router/mcp/tools.mdx index 6523d97cf2..2630916216 100644 --- a/docs-website/router/mcp/tools.mdx +++ b/docs-website/router/mcp/tools.mdx @@ -293,6 +293,33 @@ Descriptions in the operation take priority over descriptions from the schema: variable description in the operation. +## Structured Tool Output + +The MCP specification (revision 2025-06-18) allows tools to declare an [output schema](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) describing the shape of their results, and to return results as machine-readable `structuredContent` alongside the human-readable text content. + +When you enable `output_schema.enabled`, the router: + +- **Declares an `outputSchema`** on every tool generated from a GraphQL operation. The schema is derived from the operation's selection set and describes the GraphQL response envelope (`{"data": ...}`), including field types, nullability, enum values, and field descriptions from your graph's schema. +- **Returns `structuredContent`** on every successful tool result, mirroring the text content. This also applies to the `execute_graphql` built-in tool when arbitrary operations are enabled. + +This lets MCP clients know a tool's response shape ahead of time, validate results against the declared schema, and bind results to typed code instead of re-parsing a text blob. + +```yaml +mcp: + enabled: true + output_schema: + enabled: true +``` + + + Output schemas are included in every `tools/list` response, which grows with the size of your operations' selection + sets, and structured content roughly doubles the size of each successful tool result because the response is carried + both as text and as structured content. Both consume the AI model's context window. Keep this feature disabled (the + default) unless your MCP clients consume output schemas or structured content. + + +The generated schemas are intentionally permissive: they describe what the router returns without over-constraining it, so a valid GraphQL response is never rejected by a client validating against the schema. Fields behind `@skip`, `@include`, or `@defer` directives and fragments on abstract types are marked optional, and custom scalars accept any JSON value. If a schema cannot be derived for an operation, the tool is still registered, just without an output schema. + ## Best Practices ### Write Effective Descriptions From 1ebc6093904af264f0354158c2e756f166ec2179 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 23:18:06 +0100 Subject: [PATCH 3/4] docs(mcp): qualify output_schema docs with the schema-less fallback --- docs-website/router/mcp/configuration.mdx | 2 +- router/pkg/config/config.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index 1cd4dbbbd6..9e60834e1b 100644 --- a/docs-website/router/mcp/configuration.mdx +++ b/docs-website/router/mcp/configuration.mdx @@ -43,7 +43,7 @@ storage_providers: | `enable_arbitrary_operations` | Enables the `execute_graphql` built-in tool, allowing clients to run arbitrary GraphQL operations beyond the pre-defined operation set. | `false` | | `expose_schema` | Enables the `get_schema` built-in tool, exposing the full GraphQL schema to MCP clients. | `false` | | `omit_tool_name_prefix` | When enabled, MCP tool names omit the `execute_operation_` prefix. For example, `GetUser` becomes `get_user` instead of `execute_operation_get_user`. See [Tools - Omitting the Tool Name Prefix](/router/mcp/tools#omitting-the-tool-name-prefix). | `false` | -| `output_schema.enabled` | When enabled, every operation tool declares an output schema derived from its selection set, and successful tool results additionally carry the response as structured content. Increases `tools/list` and result payload sizes. See [Tools - Structured Tool Output](/router/mcp/tools#structured-tool-output). | `false` | +| `output_schema.enabled` | When enabled, operation tools declare an output schema derived from their selection set, and successful tool results additionally carry the response as structured content. A tool whose schema cannot be derived stays registered without an output schema. Increases `tools/list` and result payload sizes. See [Tools - Structured Tool Output](/router/mcp/tools#structured-tool-output). | `false` | For OAuth-specific configuration, see [OAuth 2.1 Authorization](/router/mcp/oauth/overview). diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 9d12adb457..68be7325fc 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1363,8 +1363,9 @@ type MCPConfiguration struct { } // MCPOutputSchemaConfiguration configures MCP structured tool output (spec revision 2025-06-18): -// an output schema declared on each operation tool and structured content on successful -// tool results. Disabled by default because it increases tools/list and result payload sizes. +// an output schema declared on operation tools and structured content on successful tool +// results. A tool whose schema cannot be derived stays registered without an output schema. +// Disabled by default because it increases tools/list and result payload sizes. type MCPOutputSchemaConfiguration struct { Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_OUTPUT_SCHEMA_ENABLED"` } From d0bdaf552302ed85a3893bcb5fb39fa8b21dc886 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sun, 9 Aug 2026 08:06:03 +0100 Subject: [PATCH 4/4] chore(router-tests): use any instead of interface{} in new MCP test code --- router-tests/protocol/mcp_test.go | 82 +++++++++++++++---------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index 5aa2eb09e0..afaae15a0b 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -30,7 +30,7 @@ import ( func requireStructuredContentMatchesText(t *testing.T, resp *mcp.CallToolResult, text string) { t.Helper() require.NotNil(t, resp.StructuredContent) - var expectedStructured map[string]interface{} + var expectedStructured map[string]any require.NoError(t, json.Unmarshal([]byte(text), &expectedStructured)) assert.Equal(t, expectedStructured, resp.StructuredContent) } @@ -542,37 +542,37 @@ Important Notes: myEmployees := toolByName(t, resp.Tools, "execute_operation_my_employees") assert.Equal(t, mcp.ToolOutputSchema{ Type: "object", - Properties: map[string]interface{}{ - "data": map[string]interface{}{ - "type": []interface{}{"object", "null"}, - "properties": map[string]interface{}{ - "findEmployees": map[string]interface{}{ + Properties: map[string]any{ + "data": map[string]any{ + "type": []any{"object", "null"}, + "properties": map[string]any{ + "findEmployees": map[string]any{ "description": "This is a GraphQL query that retrieves a list of employees.", "type": "array", - "items": map[string]interface{}{ + "items": map[string]any{ "type": "object", - "properties": map[string]interface{}{ - "currentMood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}, - "details": map[string]interface{}{ - "type": []interface{}{"object", "null"}, - "properties": map[string]interface{}{ - "forename": map[string]interface{}{"type": "string"}, - "nationality": map[string]interface{}{"enum": []interface{}{"AMERICAN", "DUTCH", "ENGLISH", "GERMAN", "INDIAN", "SPANISH", "UKRAINIAN"}, "type": "string"}, + "properties": map[string]any{ + "currentMood": map[string]any{"enum": []any{"HAPPY", "SAD"}, "type": "string"}, + "details": map[string]any{ + "type": []any{"object", "null"}, + "properties": map[string]any{ + "forename": map[string]any{"type": "string"}, + "nationality": map[string]any{"enum": []any{"AMERICAN", "DUTCH", "ENGLISH", "GERMAN", "INDIAN", "SPANISH", "UKRAINIAN"}, "type": "string"}, }, - "required": []interface{}{"forename", "nationality"}, + "required": []any{"forename", "nationality"}, }, - "id": map[string]interface{}{"type": "integer"}, - "isAvailable": map[string]interface{}{"type": []interface{}{"boolean", "null"}}, - "products": map[string]interface{}{ + "id": map[string]any{"type": "integer"}, + "isAvailable": map[string]any{"type": []any{"boolean", "null"}}, + "products": map[string]any{ "type": "array", - "items": map[string]interface{}{"enum": []interface{}{"CONSULTANCY", "COSMO", "ENGINE", "FINANCE", "HUMAN_RESOURCES", "MARKETING", "SDK"}, "type": "string"}, + "items": map[string]any{"enum": []any{"CONSULTANCY", "COSMO", "ENGINE", "FINANCE", "HUMAN_RESOURCES", "MARKETING", "SDK"}, "type": "string"}, }, }, - "required": []interface{}{"currentMood", "details", "id", "isAvailable", "products"}, + "required": []any{"currentMood", "details", "id", "isAvailable", "products"}, }, }, }, - "required": []interface{}{"findEmployees"}, + "required": []any{"findEmployees"}, }, }, }, myEmployees.OutputSchema) @@ -580,26 +580,26 @@ Important Notes: updateMood := toolByName(t, resp.Tools, "execute_operation_update_mood") assert.Equal(t, mcp.ToolOutputSchema{ Type: "object", - Properties: map[string]interface{}{ - "data": map[string]interface{}{ - "type": []interface{}{"object", "null"}, - "properties": map[string]interface{}{ - "updateMood": map[string]interface{}{ + Properties: map[string]any{ + "data": map[string]any{ + "type": []any{"object", "null"}, + "properties": map[string]any{ + "updateMood": map[string]any{ "description": "This mutation update the mood of an employee.", "type": "object", - "properties": map[string]interface{}{ - "currentMood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}, - "details": map[string]interface{}{ - "type": []interface{}{"object", "null"}, - "properties": map[string]interface{}{"forename": map[string]interface{}{"type": "string"}}, - "required": []interface{}{"forename"}, + "properties": map[string]any{ + "currentMood": map[string]any{"enum": []any{"HAPPY", "SAD"}, "type": "string"}, + "details": map[string]any{ + "type": []any{"object", "null"}, + "properties": map[string]any{"forename": map[string]any{"type": "string"}}, + "required": []any{"forename"}, }, - "id": map[string]interface{}{"type": "integer"}, + "id": map[string]any{"type": "integer"}, }, - "required": []interface{}{"currentMood", "details", "id"}, + "required": []any{"currentMood", "details", "id"}, }, }, - "required": []interface{}{"updateMood"}, + "required": []any{"updateMood"}, }, }, }, updateMood.OutputSchema) @@ -631,8 +631,8 @@ Important Notes: req := mcp.CallToolRequest{} req.Params.Name = "execute_operation_my_employees" - req.Params.Arguments = map[string]interface{}{ - "criteria": map[string]interface{}{}, + req.Params.Arguments = map[string]any{ + "criteria": map[string]any{}, } resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) @@ -658,7 +658,7 @@ Important Notes: req := mcp.CallToolRequest{} req.Params.Name = "execute_graphql" - req.Params.Arguments = map[string]interface{}{ + req.Params.Arguments = map[string]any{ "query": `query { employees { id } }`, } @@ -685,8 +685,8 @@ Important Notes: req := mcp.CallToolRequest{} req.Params.Name = "execute_operation_my_employees" - req.Params.Arguments = map[string]interface{}{ - "criteria": map[string]interface{}{}, + req.Params.Arguments = map[string]any{ + "criteria": map[string]any{}, } resp, err := xEnv.MCPClient.CallTool(xEnv.Context, req) @@ -705,7 +705,7 @@ Important Notes: req := mcp.CallToolRequest{} req.Params.Name = "execute_operation_my_employees" - req.Params.Arguments = map[string]interface{}{ + req.Params.Arguments = map[string]any{ "criteria": nil, }