diff --git a/docs-website/router/mcp/configuration.mdx b/docs-website/router/mcp/configuration.mdx index d257a4e5f..9e60834e1 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, 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). @@ -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 6523d97cf..d6e6c2f82 100644 --- a/docs-website/router/mcp/tools.mdx +++ b/docs-website/router/mcp/tools.mdx @@ -293,6 +293,137 @@ 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 without an output schema. + +### Results carry the response twice + +When the flag is on, a successful tool result contains the response in two fields: + +- The `content` field carries the response as serialized JSON in a text block. +- The `structuredContent` field carries the response as a JSON object. + +The MCP specification requires this shape. A tool that declares an output schema must return structured results that conform to the schema. For backwards compatibility, the specification also recommends that the tool returns the serialized JSON in a text block. Clients that do not read `structuredContent` still receive the full response as text. + +### Example: the same requests with the flag off and on + +The example below uses a mutation operation `UpdateMood`. The router exposes it as the tool `execute_operation_update_mood`. + +List the tools: + +```sh +curl -s -X POST http://localhost:5025/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +When `output_schema.enabled` is `false` (the default), the tool declares only an input schema: + +```json +{ + "name": "execute_operation_update_mood", + "description": "This mutation update the mood of an employee.", + "inputSchema": { "...": "..." } +} +``` + +When `output_schema.enabled` is `true`, the same tool also declares an output schema for the response envelope: + +```json +{ + "name": "execute_operation_update_mood", + "description": "This mutation update the mood of an employee.", + "inputSchema": { "...": "..." }, + "outputSchema": { + "type": "object", + "properties": { + "data": { + "type": ["object", "null"], + "properties": { + "updateMood": { + "type": "object", + "properties": { + "currentMood": { "enum": ["HAPPY", "SAD"], "type": "string" }, + "details": { + "type": ["object", "null"], + "properties": { "forename": { "type": "string" } }, + "required": ["forename"] + }, + "id": { "type": "integer" } + }, + "required": ["currentMood", "details", "id"] + } + }, + "required": ["updateMood"] + } + } + } +} +``` + +Call the tool: + +```sh +curl -s -X POST http://localhost:5025/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute_operation_update_mood","arguments":{"employeeID":2,"mood":"HAPPY"}}}' +``` + +When `output_schema.enabled` is `false`, the result carries the response only as text: + +```json +{ + "content": [ + { "type": "text", "text": "{\"data\":{\"updateMood\":{\"id\":2,\"details\":{\"forename\":\"Dustin\"},\"currentMood\":\"HAPPY\"}}}" } + ] +} +``` + +When `output_schema.enabled` is `true`, the result also carries the response as structured content: + +```json +{ + "content": [ + { "type": "text", "text": "{\"data\":{\"updateMood\":{\"id\":2,\"details\":{\"forename\":\"Dustin\"},\"currentMood\":\"HAPPY\"}}}" } + ], + "structuredContent": { + "data": { + "updateMood": { + "id": 2, + "details": { "forename": "Dustin" }, + "currentMood": "HAPPY" + } + } + } +} +``` + ## Best Practices ### Write Effective Descriptions diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index fab165f04..e23d6ee9a 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]any + 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,204 @@ 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") + myEmployeesSchema, err := json.Marshal(myEmployees.OutputSchema) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "object", + "properties": { + "data": { + "type": ["object", "null"], + "properties": { + "findEmployees": { + "description": "This is a GraphQL query that retrieves a list of employees.", + "type": "array", + "items": { + "type": "object", + "properties": { + "currentMood": {"enum": ["HAPPY", "SAD"], "type": "string"}, + "details": { + "type": ["object", "null"], + "properties": { + "forename": {"type": "string"}, + "nationality": {"enum": ["AMERICAN", "DUTCH", "ENGLISH", "GERMAN", "INDIAN", "SPANISH", "UKRAINIAN"], "type": "string"} + }, + "required": ["forename", "nationality"] + }, + "id": {"type": "integer"}, + "isAvailable": {"type": ["boolean", "null"]}, + "products": { + "type": "array", + "items": {"enum": ["CONSULTANCY", "COSMO", "ENGINE", "FINANCE", "HUMAN_RESOURCES", "MARKETING", "SDK"], "type": "string"} + } + }, + "required": ["currentMood", "details", "id", "isAvailable", "products"] + } + } + }, + "required": ["findEmployees"] + } + } + }`, string(myEmployeesSchema)) + + updateMood := toolByName(t, resp.Tools, "execute_operation_update_mood") + updateMoodSchema, err := json.Marshal(updateMood.OutputSchema) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "object", + "properties": { + "data": { + "type": ["object", "null"], + "properties": { + "updateMood": { + "description": "This mutation update the mood of an employee.", + "type": "object", + "properties": { + "currentMood": {"enum": ["HAPPY", "SAD"], "type": "string"}, + "details": { + "type": ["object", "null"], + "properties": {"forename": {"type": "string"}}, + "required": ["forename"] + }, + "id": {"type": "integer"} + }, + "required": ["currentMood", "details", "id"] + } + }, + "required": ["updateMood"] + } + } + }`, string(updateMoodSchema)) + }) + }) + + 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]any{ + "criteria": map[string]any{}, + } + + 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]any{ + "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]any{ + "criteria": map[string]any{}, + } + + 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]any{ + "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 3491a7e51..35a0d7768 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/go.mod b/router/go.mod index 09fc818ae..76cbeaa0f 100644 --- a/router/go.mod +++ b/router/go.mod @@ -69,6 +69,7 @@ require ( github.com/expr-lang/expr v1.17.7 github.com/goccy/go-json v0.10.3 github.com/google/go-containerregistry v0.20.3 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/grafana/pyroscope-go v1.4.0 github.com/hashicorp/go-hclog v1.6.3 @@ -118,7 +119,6 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 781709a25..68be7325f 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1358,6 +1358,16 @@ 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 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"` } type MCPOAuthConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 8428a4a0e..5af5298bc 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 6e33f4f77..b953f28ae 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 080b9f7a3..fb1ed6d4d 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 446dcc395..9e04c4c2a 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 000000000..09691dcd9 --- /dev/null +++ b/router/pkg/mcpserver/response_schema.go @@ -0,0 +1,429 @@ +package mcpserver + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "slices" + + "github.com/google/jsonschema-go/jsonschema" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "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. +// +// Schemas are emitted as github.com/google/jsonschema-go nodes, the schema +// model of the MCP Go SDK. Nullability is decided before a node is built and +// baked in at construction; no node is modified after its subtree is complete. +func buildResponseSchema(operation, definition *ast.Document) (*jsonschema.Schema, error) { + builder := &responseSchemaBuilder{ + operation: operation, + definition: definition, + pendingFragments: make(map[string]bool), + } + + return builder.build() +} + +// typedSchema returns a schema for one JSON type, in nullable or non-null +// form. Nullability uses the JSON Schema 2020-12 type-union form. +func typedSchema(jsonType string, nullable bool) *jsonschema.Schema { + if nullable { + return &jsonschema.Schema{Types: []string{jsonType, "null"}} + } + return &jsonschema.Schema{Type: jsonType} +} + +// anySchema returns a schema that accepts any JSON value. It marshals as the +// boolean schema "true", the JSON Schema 2020-12 accept-everything form. +func anySchema() *jsonschema.Schema { + return &jsonschema.Schema{} +} + +func isObjectSchema(s *jsonschema.Schema) bool { + return s.Type == "object" || slices.Contains(s.Types, "object") +} + +func isNullableSchema(s *jsonschema.Schema) bool { + return slices.Contains(s.Types, "null") +} + +// 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.Schema, 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") + } + + // "data" is null when the response carries request-level errors, so it + // stays nullable and optional + dataSchema, err := b.buildSelectionSetSchema(operationDefinition.SelectionSet, rootType, true) + if err != nil { + return nil, err + } + + // 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.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"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, nullable bool) (*jsonschema.Schema, error) { + properties := make(map[string]*jsonschema.Schema) + required := make(map[string]bool) + + if err := b.collectSelections(selectionSetRef, parentType, false, properties, required); err != nil { + return nil, err + } + + schema := typedSchema("object", nullable) + if len(properties) > 0 { + schema.Properties = properties + } + // Sort the required keys for deterministic output + schema.Required = slices.Sorted(maps.Keys(required)) + return schema, 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.Schema, 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.Schema, 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 typedSchema("string", 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, true) + 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. nullable is the nullability of the current wrapping position: a +// non-null wrapper builds its inner type with nullable false. +func (b *responseSchemaBuilder) buildTypeRefSchema(typeRef, fieldRef int, nullable bool) (*jsonschema.Schema, error) { + switch b.definition.Types[typeRef].TypeKind { + case ast.TypeKindNonNull: + return b.buildTypeRefSchema(b.definition.Types[typeRef].OfType, fieldRef, false) + + case ast.TypeKindList: + itemSchema, err := b.buildTypeRefSchema(b.definition.Types[typeRef].OfType, fieldRef, true) + if err != nil { + return nil, err + } + schema := typedSchema("array", nullable) + schema.Items = itemSchema + return schema, nil + + case ast.TypeKindNamed: + return b.buildNamedTypeSchema(b.definition.TypeNameString(typeRef), fieldRef, nullable) + } + + 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, nullable bool) (*jsonschema.Schema, error) { + // Handle built-in scalars + switch typeName { + case "String", "ID": + return typedSchema("string", nullable), nil + case "Int": + return typedSchema("integer", nullable), nil + case "Float": + return typedSchema("number", nullable), nil + case "Boolean": + return typedSchema("boolean", nullable), 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([]any, 0, len(enumDefinition.EnumValuesDefinition.Refs)+1) + for _, valueRef := range enumDefinition.EnumValuesDefinition.Refs { + values = append(values, b.definition.EnumValueDefinitionNameString(valueRef)) + } + // A nullable enum admits null as a value + if nullable { + values = append(values, nil) + } + + schema := typedSchema("string", nullable) + schema.Enum = 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 := anySchema() + // 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, nullable) + + default: + // If we can't determine the type, default to any + return anySchema(), 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 accept-anything schema so that a valid response is +// never rejected. +func mergeFieldSchemas(a, b *jsonschema.Schema) *jsonschema.Schema { + aJSON, aErr := json.Marshal(a) + bJSON, bErr := json.Marshal(b) + if aErr == nil && bErr == nil && bytes.Equal(aJSON, bJSON) { + return a + } + + if isObjectSchema(a) && isObjectSchema(b) { + merged := typedSchema("object", isNullableSchema(a) || isNullableSchema(b)) + merged.Description = a.Description + + properties := make(map[string]*jsonschema.Schema, len(a.Properties)+len(b.Properties)) + maps.Copy(properties, a.Properties) + for key, property := range b.Properties { + if existing, ok := properties[key]; ok { + properties[key] = mergeFieldSchemas(existing, property) + } else { + properties[key] = property + } + } + if len(properties) > 0 { + merged.Properties = properties + } + + // 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 anySchema() +} diff --git a/router/pkg/mcpserver/response_schema_test.go b/router/pkg/mcpserver/response_schema_test.go new file mode 100644 index 000000000..c1c9050e1 --- /dev/null +++ b/router/pkg/mcpserver/response_schema_test.go @@ -0,0 +1,473 @@ +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") + + schema, err := buildResponseSchema(&opDoc, &schemaDoc) + if err != nil { + return nil, err + } + + raw, err := json.Marshal(schema) + require.NoError(t, err) + return raw, nil +} + +// 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") + // The accept-anything schema marshals as the boolean schema "true" + assert.Equal(t, true, 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 1a118a685..872ea08b7 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 3a3c04460..5a0960145 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) + }) + } +}