Skip to content

Commit cbf1cfc

Browse files
Aias00johnlanni
andauthored
feat: add output schema support and structured data handling (#21)
Co-authored-by: 澄潭 <zty98751@alibaba-inc.com>
1 parent b573359 commit cbf1cfc

File tree

8 files changed

+157
-19
lines changed

8 files changed

+157
-19
lines changed

examples/request-block/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ require (
1515
github.com/tidwall/match v1.1.1 // indirect
1616
github.com/tidwall/pretty v1.2.1 // indirect
1717
github.com/tidwall/resp v0.1.1 // indirect
18+
github.com/tidwall/sjson v1.2.5 // indirect
1819
)

examples/request-block/go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
88
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
99
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
1010
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
11+
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
1112
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
1213
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
1314
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -17,5 +18,7 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
1718
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
1819
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
1920
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
21+
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
22+
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
2023
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2124
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
github.com/pmezard/go-difflib v1.0.0 // indirect
3131
github.com/shopspring/decimal v1.4.0 // indirect
3232
github.com/spf13/cast v1.7.0 // indirect
33+
github.com/tetratelabs/wazero v1.7.2 // indirect
3334
github.com/tidwall/match v1.1.1 // indirect
3435
github.com/tidwall/pretty v1.2.1 // indirect
3536
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
4747
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
4848
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
4949
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
50+
github.com/tetratelabs/wazero v1.7.2 h1:1+z5nXJNwMLPAWaTePFi49SSTL0IMx/i3Fg8Yc25GDc=
51+
github.com/tetratelabs/wazero v1.7.2/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
5052
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
5153
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
5254
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

pkg/mcp/server/composed_server.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ func (cs *ComposedMCPServer) GetMCPTools() map[string]Tool {
5151

5252
composedToolName := fmt.Sprintf("%s%s%s", originalServerName, consts.ToolSetNameSplitter, originalToolName)
5353
composedTools[composedToolName] = &DescriptiveTool{
54-
description: toolInfo.Description,
55-
inputSchema: toolInfo.InputSchema,
54+
description: toolInfo.Description,
55+
inputSchema: toolInfo.InputSchema,
56+
outputSchema: toolInfo.OutputSchema, // New field for MCP Protocol Version 2025-06-18
5657
}
5758
}
5859
}
@@ -88,17 +89,19 @@ func (cs *ComposedMCPServer) Clone() Server {
8889
// DescriptiveTool is a placeholder Tool implementation for ComposedMCPServer.
8990
// Its Call and Create methods should never be invoked.
9091
type DescriptiveTool struct {
91-
description string
92-
inputSchema map[string]any
92+
description string
93+
inputSchema map[string]any
94+
outputSchema map[string]any // New field for MCP Protocol Version 2025-06-18
9395
}
9496

9597
// Create for DescriptiveTool should not be called.
9698
func (dt *DescriptiveTool) Create(params []byte) Tool {
9799
log.Errorf("DescriptiveTool.Create called for tool used in ComposedMCPServer. This should not happen.")
98100
// Return a new instance to fulfill the interface, though it's an error state.
99101
return &DescriptiveTool{
100-
description: dt.description,
101-
inputSchema: dt.inputSchema,
102+
description: dt.description,
103+
inputSchema: dt.inputSchema,
104+
outputSchema: dt.outputSchema,
102105
}
103106
}
104107

@@ -117,3 +120,8 @@ func (dt *DescriptiveTool) Description() string {
117120
func (dt *DescriptiveTool) InputSchema() map[string]any {
118121
return dt.inputSchema
119122
}
123+
124+
// OutputSchema returns the tool's output schema (MCP Protocol Version 2025-06-18).
125+
func (dt *DescriptiveTool) OutputSchema() map[string]any {
126+
return dt.outputSchema
127+
}

pkg/mcp/server/plugin.go

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"errors"
1919
"fmt"
2020
"reflect"
21+
"slices"
2122
"strings"
2223

2324
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -35,6 +36,9 @@ const (
3536
GlobalToolRegistryKey = "GlobalToolRegistry"
3637
)
3738

39+
// SupportedMCPVersions contains all supported MCP protocol versions
40+
var SupportedMCPVersions = []string{"2024-11-05", "2025-03-26", "2025-06-18"}
41+
3842
type HttpContext wrapper.HttpContext
3943

4044
type Context struct {
@@ -49,11 +53,12 @@ var globalContext Context
4953

5054
// ToolInfo stores information about a tool for the global registry.
5155
type ToolInfo struct {
52-
Name string
53-
Description string
54-
InputSchema map[string]any
55-
ServerName string // Original server name
56-
Tool Tool // The actual tool instance for cloning
56+
Name string
57+
Description string
58+
InputSchema map[string]any
59+
OutputSchema map[string]any // New field for MCP Protocol Version 2025-06-18
60+
ServerName string // Original server name
61+
Tool Tool // The actual tool instance for cloning
5762
}
5863

5964
// GlobalToolRegistry holds all tools from all servers.
@@ -72,13 +77,18 @@ func (r *GlobalToolRegistry) RegisterTool(serverName string, toolName string, to
7277
if _, ok := r.serverTools[serverName]; !ok {
7378
r.serverTools[serverName] = make(map[string]ToolInfo)
7479
}
75-
r.serverTools[serverName][toolName] = ToolInfo{
80+
toolInfo := ToolInfo{
7681
Name: toolName,
7782
Description: tool.Description(),
7883
InputSchema: tool.InputSchema(),
7984
ServerName: serverName,
8085
Tool: tool,
8186
}
87+
// Check if tool implements OutputSchema (MCP Protocol Version 2025-06-18)
88+
if toolWithSchema, ok := tool.(ToolWithOutputSchema); ok {
89+
toolInfo.OutputSchema = toolWithSchema.OutputSchema()
90+
}
91+
r.serverTools[serverName][toolName] = toolInfo
8292
log.Debugf("Registered tool %s/%s", serverName, toolName)
8393
}
8494

@@ -122,6 +132,14 @@ type Tool interface {
122132
InputSchema() map[string]any
123133
}
124134

135+
// ToolWithOutputSchema is an optional interface for tools that support output schema
136+
// (MCP Protocol Version 2025-06-18). Tools can optionally implement this interface
137+
// to provide output schema information.
138+
type ToolWithOutputSchema interface {
139+
Tool
140+
OutputSchema() map[string]any
141+
}
142+
125143
// ToolSetConfig defines the configuration for a toolset.
126144
type ToolSetConfig struct {
127145
Name string `json:"name"`
@@ -276,7 +294,15 @@ func parseConfigCore(configJson gjson.Result, config *McpServerConfig, opts *Con
276294
version := params.Get("protocolVersion").String()
277295
if version == "" {
278296
utils.OnMCPResponseError(ctx, errors.New("Unsupported protocol version"), utils.ErrInvalidParams, fmt.Sprintf("mcp:%s:initialize:error", currentServerNameForHandlers))
297+
return nil
279298
}
299+
300+
// Support for multiple protocol versions including 2025-06-18
301+
if !slices.Contains(SupportedMCPVersions, version) {
302+
utils.OnMCPResponseError(ctx, fmt.Errorf("Unsupported protocol version: %s", version), utils.ErrInvalidParams, fmt.Sprintf("mcp:%s:initialize:error", currentServerNameForHandlers))
303+
return nil
304+
}
305+
280306
utils.OnMCPResponseSuccess(ctx, map[string]any{
281307
"protocolVersion": version,
282308
"capabilities": map[string]any{
@@ -318,11 +344,18 @@ func parseConfigCore(configJson gjson.Result, config *McpServerConfig, opts *Con
318344
continue
319345
}
320346
}
321-
listedTools = append(listedTools, map[string]any{
347+
toolDef := map[string]any{
322348
"name": toolFullName,
323349
"description": tool.Description(),
324350
"inputSchema": tool.InputSchema(),
325-
})
351+
}
352+
// Add outputSchema if tool implements ToolWithOutputSchema (MCP Protocol Version 2025-06-18)
353+
if toolWithSchema, ok := tool.(ToolWithOutputSchema); ok {
354+
if outputSchema := toolWithSchema.OutputSchema(); outputSchema != nil && len(outputSchema) > 0 {
355+
toolDef["outputSchema"] = outputSchema
356+
}
357+
}
358+
listedTools = append(listedTools, toolDef)
326359
}
327360
utils.OnMCPResponseSuccess(ctx, map[string]any{
328361
"tools": listedTools,
@@ -471,6 +504,22 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config McpServerConfig) types
471504
ctx.SetRequestBodyBufferLimit(DefaultMaxBodyBytes)
472505
ctx.SetResponseBodyBufferLimit(DefaultMaxBodyBytes)
473506

507+
// Parse MCP-Protocol-Version header and store in context
508+
// This allows clients to specify the MCP protocol version via HTTP header
509+
// instead of only through the JSON-RPC initialize method
510+
protocolVersion, _ := proxywasm.GetHttpRequestHeader("MCP-Protocol-Version")
511+
if protocolVersion != "" {
512+
// Validate the protocol version against supported versions
513+
if slices.Contains(SupportedMCPVersions, protocolVersion) {
514+
log.Debugf("MCP Protocol Version set from header: %s", protocolVersion)
515+
} else {
516+
log.Warnf("Unsupported MCP Protocol Version in header: %s", protocolVersion)
517+
}
518+
519+
// Remove the header from the request to prevent it from being forwarded
520+
proxywasm.RemoveHttpRequestHeader("MCP-Protocol-Version")
521+
}
522+
474523
if ctx.Method() == "GET" {
475524
proxywasm.SendHttpResponseWithDetail(405, "not_support_sse_on_this_endpoint", nil, nil, -1)
476525
return types.HeaderStopAllIterationAndWatermark

pkg/mcp/server/rest_server.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type RestTool struct {
8787
Description string `json:"description"`
8888
Security SecurityRequirement `json:"security,omitempty"` // Tool-level security for MCP Client to MCP Server
8989
Args []RestToolArg `json:"args"`
90+
OutputSchema map[string]any `json:"outputSchema,omitempty"` // Output schema for MCP Protocol Version 2025-06-18
9091
RequestTemplate RestToolRequestTemplate `json:"requestTemplate,omitempty"`
9192
ResponseTemplate RestToolResponseTemplate `json:"responseTemplate"`
9293
ErrorResponseTemplate string `json:"errorResponseTemplate"`
@@ -689,8 +690,22 @@ func (t *RestMCPTool) Call(httpCtx HttpContext, server Server) error {
689690
return fmt.Errorf("error executing response template: %v", err)
690691
}
691692
result = templateResult
692-
// Send the result
693-
utils.SendMCPToolTextResult(ctx, result, fmt.Sprintf("mcp:tools/call:%s/%s:result", t.serverName, t.name))
693+
694+
// Check if tool has outputSchema and try to parse templateResult as structured content
695+
var structuredContent json.RawMessage
696+
if t.toolConfig.OutputSchema != nil && len(t.toolConfig.OutputSchema) > 0 {
697+
// For direct response tools, check if templateResult is valid JSON
698+
if json.Valid([]byte(result)) {
699+
structuredContent = json.RawMessage(result)
700+
}
701+
}
702+
703+
// Send the result using structured content if available
704+
if structuredContent != nil {
705+
utils.SendMCPToolTextResultWithStructuredContent(ctx, result, structuredContent, fmt.Sprintf("mcp:tools/call:%s/%s:result", t.serverName, t.name))
706+
} else {
707+
utils.SendMCPToolTextResult(ctx, result, fmt.Sprintf("mcp:tools/call:%s/%s:result", t.serverName, t.name))
708+
}
694709
return nil
695710
}
696711

@@ -1006,7 +1021,25 @@ func (t *RestMCPTool) Call(httpCtx HttpContext, server Server) error {
10061021
if result == "" {
10071022
result = "success"
10081023
}
1009-
utils.SendMCPToolTextResult(ctx, result, fmt.Sprintf("mcp:tools/call:%s/%s:result", t.serverName, t.name))
1024+
1025+
// Check if tool has outputSchema and try to parse response as structured content
1026+
var structuredContent json.RawMessage
1027+
if t.toolConfig.OutputSchema != nil && len(t.toolConfig.OutputSchema) > 0 {
1028+
// Try to parse response as JSON for structured content
1029+
if json.Valid(responseBody) {
1030+
structuredContent = json.RawMessage(responseBody)
1031+
}
1032+
// If not valid JSON, don't force structuredContent creation
1033+
// Standard approach: use isError: true + error text (type: "text")
1034+
// Only add structuredContent when there's a structured need for errors
1035+
}
1036+
1037+
// Send the result using structured content if available
1038+
if structuredContent != nil {
1039+
utils.SendMCPToolTextResultWithStructuredContent(ctx, result, structuredContent, fmt.Sprintf("mcp:tools/call:%s/%s:result", t.serverName, t.name))
1040+
} else {
1041+
utils.SendMCPToolTextResult(ctx, result, fmt.Sprintf("mcp:tools/call:%s/%s:result", t.serverName, t.name))
1042+
}
10101043
})
10111044
if err != nil {
10121045
utils.OnMCPToolCallError(ctx, errors.New("route failed"))
@@ -1079,6 +1112,11 @@ func (t *RestMCPTool) InputSchema() map[string]any {
10791112
return schema
10801113
}
10811114

1115+
// OutputSchema implements Tool interface (MCP Protocol Version 2025-06-18)
1116+
func (t *RestMCPTool) OutputSchema() map[string]any {
1117+
return t.toolConfig.OutputSchema
1118+
}
1119+
10821120
func convertHeaders(responseHeaders [][2]string) map[string]string {
10831121
headerMap := make(map[string]string)
10841122
for _, h := range responseHeaders {

pkg/mcp/utils/mcp_rpc.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ package utils
1616

1717
import (
1818
"encoding/base64"
19+
"encoding/json"
1920
"fmt"
21+
2022
"github.com/higress-group/wasm-go/pkg/wrapper"
2123
)
2224

@@ -37,6 +39,19 @@ func OnMCPToolCallSuccess(ctx wrapper.HttpContext, content []map[string]any, deb
3739
}, debugInfo)
3840
}
3941

42+
// OnMCPToolCallSuccessWithStructuredContent sends a successful MCP tool response with structured content
43+
// According to MCP spec, structuredContent is a field in tool results, not a capability
44+
func OnMCPToolCallSuccessWithStructuredContent(ctx wrapper.HttpContext, content []map[string]any, structuredContent json.RawMessage, debugInfo string) {
45+
response := map[string]any{
46+
"content": content,
47+
"isError": false,
48+
}
49+
if structuredContent != nil && len(structuredContent) > 0 {
50+
response["structuredContent"] = structuredContent
51+
}
52+
OnMCPResponseSuccess(ctx, response, debugInfo)
53+
}
54+
4055
func OnMCPToolCallError(ctx wrapper.HttpContext, err error, debugInfo ...string) {
4156
responseDebugInfo := fmt.Sprintf("mcp:tools/call:error(%s)", err)
4257
if len(debugInfo) > 0 {
@@ -71,11 +86,32 @@ func SendMCPToolImageResult(ctx wrapper.HttpContext, image []byte, contentType s
7186
if len(debugInfo) > 0 {
7287
responseDebugInfo = debugInfo[0]
7388
}
74-
OnMCPToolCallSuccess(ctx, []map[string]any{
89+
90+
content := []map[string]any{
7591
{
7692
"type": "image",
7793
"data": base64.StdEncoding.EncodeToString(image),
7894
"mimeType": contentType,
7995
},
80-
}, responseDebugInfo)
96+
}
97+
98+
// Use traditional response format since no structured data is provided
99+
OnMCPToolCallSuccess(ctx, content, responseDebugInfo)
100+
}
101+
102+
// SendMCPToolTextResultWithStructuredContent sends a tool result with both text content and structured content
103+
// According to MCP spec, for backward compatibility, tools that return structured content
104+
// SHOULD also return the serialized JSON in a TextContent block
105+
func SendMCPToolTextResultWithStructuredContent(ctx wrapper.HttpContext, textResult string, structuredContent json.RawMessage, debugInfo ...string) {
106+
responseDebugInfo := "mcp:tools/call::result"
107+
if len(debugInfo) > 0 {
108+
responseDebugInfo = debugInfo[0]
109+
}
110+
content := []map[string]any{
111+
{
112+
"type": "text",
113+
"text": textResult,
114+
},
115+
}
116+
OnMCPToolCallSuccessWithStructuredContent(ctx, content, structuredContent, responseDebugInfo)
81117
}

0 commit comments

Comments
 (0)