Skip to content

Commit 222cb07

Browse files
fix: include output schema in tool approval hash migration (#527)
* fix output schema hash migration * harden output schema hash migration * address output schema migration review * preserve direct output schema in tools list * fix(quarantine): only fold output schema into approval hash when present The output-schema segment was written unconditionally, changing the approval hash for every tool — including those with no outputSchema — which tripped TestCalculateToolApprovalHash_Stability and forced an unnecessary re-baseline of all approved tools on upgrade. Gate the segment on a non-empty normalized output schema so tools without an outputSchema keep their original hash (no re-baseline, no re-quarantine). Tools that do expose an outputSchema still get a new hash, handled by the version-gated migration in checkToolApprovals. --------- Co-authored-by: Algis Dumbris <a.dumbris@gmail.com>
1 parent 424f9a7 commit 222cb07

14 files changed

Lines changed: 659 additions & 113 deletions

File tree

‎internal/config/config.go‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,9 @@ type ServerConfig struct {
239239
// when the server is configured with both Command and an HTTP/SSE URL — i.e.,
240240
// mcpproxy starts the process AND connects via network. Stdio servers ignore
241241
// this field. Zero or unset → 30s default.
242-
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
243-
EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools
244-
DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools
242+
LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"`
243+
EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools
244+
DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools
245245
}
246246

247247
// OAuthConfig represents OAuth configuration for a server
@@ -523,14 +523,15 @@ func ConvertFromCursorFormat(cursorConfig *CursorMCPConfig) []*ServerConfig {
523523

524524
// ToolMetadata represents tool information stored in the index
525525
type ToolMetadata struct {
526-
Name string `json:"name"`
527-
ServerName string `json:"server_name"`
528-
Description string `json:"description"`
529-
ParamsJSON string `json:"params_json"`
530-
Hash string `json:"hash"`
531-
Created time.Time `json:"created"`
532-
Updated time.Time `json:"updated"`
533-
Annotations *ToolAnnotations `json:"annotations,omitempty"`
526+
Name string `json:"name"`
527+
ServerName string `json:"server_name"`
528+
Description string `json:"description"`
529+
ParamsJSON string `json:"params_json"`
530+
OutputSchemaJSON string `json:"output_schema_json,omitempty"`
531+
Hash string `json:"hash"`
532+
Created time.Time `json:"created"`
533+
Updated time.Time `json:"updated"`
534+
Annotations *ToolAnnotations `json:"annotations,omitempty"`
534535
}
535536

536537
// ToolAnnotations represents MCP tool behavior hints

‎internal/hash/hash.go‎

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,103 @@ import (
77
"fmt"
88
)
99

10-
// ToolHash computes SHA-256 hash for tool change detection
11-
// Format: sha256(serverName + toolName + description + parametersSchemaJSON)
10+
type toolHashContract struct {
11+
ServerName string `json:"server_name"`
12+
ToolName string `json:"tool_name"`
13+
Description string `json:"description"`
14+
InputSchema json.RawMessage `json:"input_schema,omitempty"`
15+
OutputSchemaJSON json.RawMessage `json:"output_schema,omitempty"`
16+
}
17+
18+
// ToolHash computes SHA-256 hash for tool change detection.
19+
// Format: sha256(canonical JSON of serverName, toolName, description, input schema)
1220
func ToolHash(serverName, toolName, description string, parametersSchema interface{}) (string, error) {
13-
// Serialize parameters schema to JSON for consistent hashing
14-
var schemaBytes []byte
15-
var err error
21+
return ToolHashWithOutputSchema(serverName, toolName, description, parametersSchema, "")
22+
}
1623

17-
if parametersSchema != nil {
18-
schemaBytes, err = json.Marshal(parametersSchema)
19-
if err != nil {
20-
return "", fmt.Errorf("failed to marshal parameters schema: %w", err)
21-
}
24+
// ToolHashWithOutputSchema computes SHA-256 hash for the full tool contract.
25+
// Output schema is included because it describes the data shape returned to the
26+
// agent and therefore belongs to the human-approved tool contract.
27+
// Format: sha256(canonical JSON of serverName, toolName, description, input schema, output schema)
28+
func ToolHashWithOutputSchema(serverName, toolName, description string, parametersSchema interface{}, outputSchemaJSON string) (string, error) {
29+
inputSchema, err := canonicalSchemaFromInterface(parametersSchema)
30+
if err != nil {
31+
return "", fmt.Errorf("failed to marshal parameters schema: %w", err)
2232
}
2333

24-
// Combine server name, tool name, description, and schema JSON
25-
combined := serverName + toolName + description + string(schemaBytes)
34+
outputSchema, err := canonicalSchemaFromString(outputSchemaJSON)
35+
if err != nil {
36+
return "", fmt.Errorf("failed to marshal output schema: %w", err)
37+
}
38+
39+
contract := toolHashContract{
40+
ServerName: serverName,
41+
ToolName: toolName,
42+
Description: description,
43+
InputSchema: inputSchema,
44+
OutputSchemaJSON: outputSchema,
45+
}
46+
47+
contractBytes, err := json.Marshal(contract)
48+
if err != nil {
49+
return "", fmt.Errorf("failed to marshal tool hash contract: %w", err)
50+
}
2651

27-
// Compute SHA-256 hash
2852
hasher := sha256.New()
29-
hasher.Write([]byte(combined))
53+
hasher.Write(contractBytes)
3054
hashBytes := hasher.Sum(nil)
3155

3256
return hex.EncodeToString(hashBytes), nil
3357
}
3458

59+
func canonicalSchemaFromInterface(schema interface{}) (json.RawMessage, error) {
60+
if schema == nil {
61+
return nil, nil
62+
}
63+
64+
var raw json.RawMessage
65+
switch value := schema.(type) {
66+
case json.RawMessage:
67+
raw = value
68+
case []byte:
69+
raw = value
70+
case string:
71+
raw = []byte(value)
72+
default:
73+
data, err := json.Marshal(value)
74+
if err != nil {
75+
return nil, err
76+
}
77+
raw = data
78+
}
79+
80+
return canonicalSchemaFromBytes(raw)
81+
}
82+
83+
func canonicalSchemaFromString(schemaJSON string) (json.RawMessage, error) {
84+
if schemaJSON == "" {
85+
return nil, nil
86+
}
87+
return canonicalSchemaFromBytes([]byte(schemaJSON))
88+
}
89+
90+
func canonicalSchemaFromBytes(schemaJSON []byte) (json.RawMessage, error) {
91+
if len(schemaJSON) == 0 {
92+
return nil, nil
93+
}
94+
95+
var parsed interface{}
96+
if err := json.Unmarshal(schemaJSON, &parsed); err != nil {
97+
return nil, err
98+
}
99+
100+
canonical, err := json.Marshal(parsed)
101+
if err != nil {
102+
return nil, err
103+
}
104+
return json.RawMessage(canonical), nil
105+
}
106+
35107
// StringHash computes SHA-256 hash of a string
36108
func StringHash(input string) string {
37109
hasher := sha256.New()
@@ -60,7 +132,12 @@ func VerifyToolHash(serverName, toolName, description string, parametersSchema i
60132

61133
// ComputeToolHash computes a SHA256 hash for a tool (alias for ToolHash that doesn't return error)
62134
func ComputeToolHash(serverName, toolName, description string, inputSchema interface{}) string {
63-
hash, err := ToolHash(serverName, toolName, description, inputSchema)
135+
return ComputeToolHashWithOutputSchema(serverName, toolName, description, inputSchema, "")
136+
}
137+
138+
// ComputeToolHashWithOutputSchema computes a SHA256 hash for a tool including output schema.
139+
func ComputeToolHashWithOutputSchema(serverName, toolName, description string, inputSchema interface{}, outputSchemaJSON string) string {
140+
hash, err := ToolHashWithOutputSchema(serverName, toolName, description, inputSchema, outputSchemaJSON)
64141
if err != nil {
65142
// If hashing fails, return a default hash based on server and tool name
66143
fallback := StringHash(fmt.Sprintf("%s:%s", serverName, toolName))

‎internal/hash/hash_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ func TestComputeToolHash_FallbackOnMarshalError(t *testing.T) {
136136
assert.NotEmpty(t, hash, "Should return fallback hash on marshal error")
137137
}
138138

139+
func TestComputeToolHashWithOutputSchema_CanonicalizesOutputSchemaJSON(t *testing.T) {
140+
inputSchema := map[string]interface{}{"type": "object"}
141+
142+
hash1 := ComputeToolHashWithOutputSchema("server", "tool", "desc", inputSchema, `{"type":"object","properties":{"url":{"type":"string"}}}`)
143+
hash2 := ComputeToolHashWithOutputSchema("server", "tool", "desc", inputSchema, `{
144+
"properties": {"url": {"type": "string"}},
145+
"type": "object"
146+
}`)
147+
148+
assert.Equal(t, hash1, hash2, "Semantically identical output schemas should hash the same")
149+
}
150+
151+
func TestToolHashWithOutputSchema_UsesStructuredEncoding(t *testing.T) {
152+
hash1, err := ToolHashWithOutputSchema("ab", "c", "d", map[string]interface{}{"type": "object"}, `{"type":"object"}`)
153+
require.NoError(t, err)
154+
hash2, err := ToolHashWithOutputSchema("a", "bc", "d", map[string]interface{}{"type": "object"}, `{"type":"object"}`)
155+
require.NoError(t, err)
156+
157+
assert.NotEqual(t, hash1, hash2, "Different contract field tuples must not collide through string concatenation")
158+
}
159+
139160
func TestComputeToolHash_DescriptionOnlyChange(t *testing.T) {
140161
schema := map[string]interface{}{
141162
"type": "object",

‎internal/index/bleve.go‎

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ type BleveIndex struct {
2121

2222
// ToolDocument represents a tool document in the index
2323
type ToolDocument struct {
24-
ToolName string `json:"tool_name"` // Just the tool name (without server prefix)
25-
FullToolName string `json:"full_tool_name"` // Complete server:tool format
26-
ServerName string `json:"server_name"`
27-
Description string `json:"description"`
28-
ParamsJSON string `json:"params_json"`
29-
Hash string `json:"hash"`
30-
Tags string `json:"tags"`
31-
SearchableText string `json:"searchable_text"` // Combined searchable content
24+
ToolName string `json:"tool_name"` // Just the tool name (without server prefix)
25+
FullToolName string `json:"full_tool_name"` // Complete server:tool format
26+
ServerName string `json:"server_name"`
27+
Description string `json:"description"`
28+
ParamsJSON string `json:"params_json"`
29+
OutputSchemaJSON string `json:"output_schema_json,omitempty"`
30+
Hash string `json:"hash"`
31+
Tags string `json:"tags"`
32+
SearchableText string `json:"searchable_text"` // Combined searchable content
3233
}
3334

3435
// NewBleveIndex creates a new Bleve index
@@ -147,14 +148,15 @@ func (b *BleveIndex) IndexTool(toolMeta *config.ToolMetadata) error {
147148
toolMeta.ParamsJSON)
148149

149150
doc := &ToolDocument{
150-
ToolName: toolName,
151-
FullToolName: toolMeta.Name,
152-
ServerName: toolMeta.ServerName,
153-
Description: toolMeta.Description,
154-
ParamsJSON: toolMeta.ParamsJSON,
155-
Hash: toolMeta.Hash,
156-
Tags: "", // Can be extended later
157-
SearchableText: searchableText,
151+
ToolName: toolName,
152+
FullToolName: toolMeta.Name,
153+
ServerName: toolMeta.ServerName,
154+
Description: toolMeta.Description,
155+
ParamsJSON: toolMeta.ParamsJSON,
156+
OutputSchemaJSON: toolMeta.OutputSchemaJSON,
157+
Hash: toolMeta.Hash,
158+
Tags: "", // Can be extended later
159+
SearchableText: searchableText,
158160
}
159161

160162
// Use server:tool format as document ID for uniqueness
@@ -249,7 +251,7 @@ func (b *BleveIndex) SearchTools(queryStr string, limit int) ([]*config.SearchRe
249251
// Create search request
250252
searchReq := bleve.NewSearchRequest(boolQuery)
251253
searchReq.Size = limit
252-
searchReq.Fields = []string{"tool_name", "full_tool_name", "server_name", "description", "params_json", "hash"}
254+
searchReq.Fields = []string{"tool_name", "full_tool_name", "server_name", "description", "params_json", "output_schema_json", "hash"}
253255
searchReq.Highlight = bleve.NewHighlight()
254256

255257
b.logger.Debug("Searching tools with enhanced query", zap.String("query", queryStr), zap.Int("limit", limit))
@@ -263,11 +265,12 @@ func (b *BleveIndex) SearchTools(queryStr string, limit int) ([]*config.SearchRe
263265
var results []*config.SearchResult
264266
for _, hit := range searchResult.Hits {
265267
toolMeta := &config.ToolMetadata{
266-
Name: getStringField(hit.Fields, "full_tool_name"),
267-
ServerName: getStringField(hit.Fields, "server_name"),
268-
Description: getStringField(hit.Fields, "description"),
269-
ParamsJSON: getStringField(hit.Fields, "params_json"),
270-
Hash: getStringField(hit.Fields, "hash"),
268+
Name: getStringField(hit.Fields, "full_tool_name"),
269+
ServerName: getStringField(hit.Fields, "server_name"),
270+
Description: getStringField(hit.Fields, "description"),
271+
ParamsJSON: getStringField(hit.Fields, "params_json"),
272+
OutputSchemaJSON: getStringField(hit.Fields, "output_schema_json"),
273+
Hash: getStringField(hit.Fields, "hash"),
271274
}
272275

273276
results = append(results, &config.SearchResult{

0 commit comments

Comments
 (0)