Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs-website/router/mcp/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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).

Expand Down Expand Up @@ -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'

Expand Down
27 changes: 27 additions & 0 deletions docs-website/router/mcp/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,33 @@ Descriptions in the operation take priority over descriptions from the schema:
variable description in the operation.
</Info>

## 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
```

<Warning>
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.
</Warning>

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
Expand Down
216 changes: 216 additions & 0 deletions router-tests/protocol/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions router/core/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
10 changes: 10 additions & 0 deletions router/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions router/pkg/config/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions router/pkg/config/fixtures/full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion router/pkg/config/testdata/config_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@
"ScopeChallengeIncludeTokenScopes": false,
"MaxScopeCombinations": 2048
},
"ResourceDocumentation": ""
"ResourceDocumentation": "",
"OutputSchema": {
"Enabled": false
}
},
"ConnectRPC": {
"Enabled": false,
Expand Down
5 changes: 4 additions & 1 deletion router/pkg/config/testdata/config_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,10 @@
"ScopeChallengeIncludeTokenScopes": false,
"MaxScopeCombinations": 2048
},
"ResourceDocumentation": ""
"ResourceDocumentation": "",
"OutputSchema": {
"Enabled": true
}
},
"ConnectRPC": {
"Enabled": false,
Expand Down
Loading
Loading