Skip to content
Merged
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
131 changes: 131 additions & 0 deletions docs-website/router/mcp/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,137 @@ 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 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.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"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
Expand Down
220 changes: 220 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]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) {
Expand Down Expand Up @@ -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{
Expand Down
Loading
Loading