From ebf9aff6630076c1790b737adec6acf2f14ee050 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 24 Aug 2026 10:23:54 -0700 Subject: [PATCH] fix(mcp): advertise exact output schemas --- docs/docs/reference/mcp-tools.md | 4 +- go.mod | 2 +- mcpserver/e2e_test.go | 28 +++ mcpserver/server.go | 114 ++++++++++++- mcpserver/server_test.go | 284 +++++++++++++++++++++++++++++-- 5 files changed, 410 insertions(+), 22 deletions(-) diff --git a/docs/docs/reference/mcp-tools.md b/docs/docs/reference/mcp-tools.md index 7f532c6..d30878b 100644 --- a/docs/docs/reference/mcp-tools.md +++ b/docs/docs/reference/mcp-tools.md @@ -13,7 +13,7 @@ description: Exact inputs, listed descriptions, successful structured outputs, d Each input is an object with one required string property. Additional properties are rejected by the SDK before subject resolution or service work. Every valid call then resolves a trusted subject through `mcpserver.InvocationResolver` before it reaches the CodeMode service. -On success, the official SDK returns the documented value in `CallToolResult.StructuredContent` and one JSON `TextContent` item that mirrors it. The schemas below describe the structured value, not the surrounding MCP result. Each tool's listed `tools/list` description is the model-facing authoring contract for that tool. +On success, `CallToolResult.StructuredContent` contains the value described by the tool's `outputSchema`, and one JSON `TextContent` item mirrors the same value. The schemas below are the `outputSchema` values advertised by `tools/list`; they describe the successful value itself, not the surrounding MCP result. Each tool's listed `tools/list` description is the model-facing authoring contract for that tool. ## `search_api` @@ -132,6 +132,8 @@ For the site-wide sample, the requested name is `records.lookup`. Its stable ID, } ``` +The required `input` and `output` properties are always arrays. A capability with no input or output fields uses `[]`, not `null`. + `input` and `output` preserve Go field declaration order. Field-shape types have these values: | Position | Go field type | `type` value | `required` | diff --git a/go.mod b/go.mod index d2e4598..b286854 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/meigma/codemode go 1.26.6 require ( + github.com/google/jsonschema-go v0.4.3 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/open-policy-agent/opa v1.19.1 github.com/stretchr/testify v1.12.1 @@ -15,7 +16,6 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.2.1 // indirect diff --git a/mcpserver/e2e_test.go b/mcpserver/e2e_test.go index 5c0d389..d063b36 100644 --- a/mcpserver/e2e_test.go +++ b/mcpserver/e2e_test.go @@ -244,6 +244,9 @@ func TestActualMCPSecureLoop(t *testing.T) { names = append(names, tool.Name) } assert.ElementsMatch(t, []string{"search_api", "describe_api", "execute"}, names) + requireSearchAPIOutputSchema(t, listedOutputSchema(t, listed.Tools, "search_api")) + requireDescribeAPIOutputSchema(t, listedOutputSchema(t, listed.Tools, "describe_api")) + requireExecuteOutputSchema(t, listedOutputSchema(t, listed.Tools, "execute")) searched, err := session.CallTool(t.Context(), &mcp.CallToolParams{ Name: "search_api", @@ -258,6 +261,8 @@ func TestActualMCPSecureLoop(t *testing.T) { assert.Equal(t, "records.lookup(*, key: str, limit: int | None)", searchResults[0].Signature) assert.Equal(t, "Look up one record by key.", searchResults[0].Summary) assertDiscoveryOmitsGoTypeNames(t, searched, "lookupResult", "StatusResult") + requireNonNullJSONArray(t, searched.StructuredContent) + requireJSONTextMirror(t, searched, searchResults) described, err := session.CallTool(t.Context(), &mcp.CallToolParams{ Name: "describe_api", @@ -284,6 +289,7 @@ func TestActualMCPSecureLoop(t *testing.T) { assert.Equal(t, "int", description.Output[1].Type) assert.True(t, description.Output[1].Required) assertDiscoveryOmitsGoTypeNames(t, described, "lookupResult", "StatusResult") + requireNonNullDescribeFieldArrays(t, described) statusSearch, err := session.CallTool(t.Context(), &mcp.CallToolParams{ Name: "search_api", @@ -297,6 +303,8 @@ func TestActualMCPSecureLoop(t *testing.T) { assert.Equal(t, "health.status", statusResults[0].Name) assert.Equal(t, "health.status()", statusResults[0].Signature) assertDiscoveryOmitsGoTypeNames(t, statusSearch, "lookupResult", "StatusResult") + requireNonNullJSONArray(t, statusSearch.StructuredContent) + requireJSONTextMirror(t, statusSearch, statusResults) statusDescribed, err := session.CallTool(t.Context(), &mcp.CallToolParams{ Name: "describe_api", @@ -312,6 +320,17 @@ func TestActualMCPSecureLoop(t *testing.T) { require.Len(t, statusDescription.Output, 1) assert.Equal(t, "state", statusDescription.Output[0].Name) assertDiscoveryOmitsGoTypeNames(t, statusDescribed, "lookupResult", "StatusResult") + requireNonNullDescribeFieldArrays(t, statusDescribed) + require.Empty(t, requireJSONObject(t, statusDescribed.StructuredContent)["input"]) + + emptySearch, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Name: "search_api", + Arguments: map[string]any{"query": "zzzz-no-match"}, + }) + require.NoError(t, err) + assertSuccessfulTool(t, emptySearch) + assertNoCanary(t, emptySearch) + requireSuccessfulStructuredValue(t, emptySearch, []any{}) hidden, err := session.CallTool(t.Context(), &mcp.CallToolParams{ Name: "describe_api", @@ -507,3 +526,12 @@ func assertDiscoveryOmitsGoTypeNames(t *testing.T, result *mcp.CallToolResult, f assert.NotContains(t, text.Text, name) } } + +// requireNonNullDescribeFieldArrays requires describe structured content to carry +// non-null input and output arrays. +func requireNonNullDescribeFieldArrays(t *testing.T, result *mcp.CallToolResult) { + t.Helper() + object := requireJSONObject(t, result.StructuredContent) + requireNonNullJSONArray(t, object["input"]) + requireNonNullJSONArray(t, object["output"]) +} diff --git a/mcpserver/server.go b/mcpserver/server.go index 3e939eb..d51a68f 100644 --- a/mcpserver/server.go +++ b/mcpserver/server.go @@ -6,6 +6,7 @@ import ( "fmt" "reflect" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/meigma/codemode" @@ -66,19 +67,64 @@ func New(service Service, resolver InvocationResolver) (*mcp.Server, error) { return nil, fmt.Errorf("%w: invocation resolver is required", codemode.ErrInvalidRegistration) } + searchOutputSchema, schemaErr := jsonschema.For[[]codemode.SearchResult](nil) + if schemaErr != nil { + return nil, fmt.Errorf("%w: search_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + schemaErr = requireNonNullArray(searchOutputSchema) + if schemaErr != nil { + return nil, fmt.Errorf("%w: search_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + schemaErr = requireResolvedSchema(searchOutputSchema) + if schemaErr != nil { + return nil, fmt.Errorf("%w: search_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + + describeOutputSchema, schemaErr := jsonschema.For[codemode.Description](nil) + if schemaErr != nil { + return nil, fmt.Errorf("%w: describe_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + if describeOutputSchema == nil || describeOutputSchema.Properties == nil { + return nil, fmt.Errorf("%w: describe_api output schema: missing properties", codemode.ErrInvalidRegistration) + } + schemaErr = requireNonNullArray(describeOutputSchema.Properties["input"]) + if schemaErr != nil { + return nil, fmt.Errorf("%w: describe_api output schema: input: %w", codemode.ErrInvalidRegistration, schemaErr) + } + schemaErr = requireNonNullArray(describeOutputSchema.Properties["output"]) + if schemaErr != nil { + return nil, fmt.Errorf("%w: describe_api output schema: output: %w", codemode.ErrInvalidRegistration, schemaErr) + } + schemaErr = requireResolvedSchema(describeOutputSchema) + if schemaErr != nil { + return nil, fmt.Errorf("%w: describe_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + + executeOutputSchema, schemaErr := jsonschema.For[executeOutput](nil) + if schemaErr != nil { + return nil, fmt.Errorf("%w: execute output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + schemaErr = requireResolvedSchema(executeOutputSchema) + if schemaErr != nil { + return nil, fmt.Errorf("%w: execute output schema: %w", codemode.ErrInvalidRegistration, schemaErr) + } + bound := &adapter{service: service, resolver: resolver} server := mcp.NewServer(&mcp.Implementation{Name: "codemode", Version: "1"}, nil) mcp.AddTool(server, &mcp.Tool{ - Name: "search_api", - Description: "Search enabled names and summaries with a short literal substring. Retry an empty result with a shorter term.", + Name: "search_api", + Description: "Search enabled names and summaries with a short literal substring. Retry an empty result with a shorter term.", + OutputSchema: searchOutputSchema, }, bound.search) mcp.AddTool(server, &mcp.Tool{ - Name: "describe_api", - Description: "Describe one enabled capability by the exact name returned by search_api, without whitespace or case changes.", + Name: "describe_api", + Description: "Describe one enabled capability by the exact name returned by search_api, without whitespace or case changes.", + OutputSchema: describeOutputSchema, }, bound.describe) mcp.AddTool(server, &mcp.Tool{ - Name: "execute", - Description: "Execute one Starlark program that defines def main(): with zero arguments, calls only names confirmed through search_api and describe_api inside main, and returns main's final result.", + Name: "execute", + Description: "Execute one Starlark program that defines def main(): with zero arguments, calls only names confirmed through search_api and describe_api inside main, and returns main's final result.", + OutputSchema: executeOutputSchema, }, bound.execute) return server, nil } @@ -95,8 +141,8 @@ func (bound *adapter) search( } return bound.service.Search(input.Query) }) - if outcome.value == nil && outcome.err == nil { - outcome.value = []codemode.SearchResult{} + if outcome.err == nil { + outcome.value = nonNilSlice(outcome.value) } return nil, outcome.value, outcome.err } @@ -113,6 +159,10 @@ func (bound *adapter) describe( } return bound.service.Describe(codemode.CapabilityName(input.Name)) }) + if outcome.err == nil { + outcome.value.Input = nonNilSlice(outcome.value.Input) + outcome.value.Output = nonNilSlice(outcome.value.Output) + } return nil, outcome.value, outcome.err } @@ -208,3 +258,51 @@ func isNil(value any) bool { kind == reflect.Slice return nilable && reflected.IsNil() } + +// nonNilSlice returns values, or an empty slice when values is nil. +func nonNilSlice[T any](values []T) []T { + if values == nil { + return []T{} + } + return values +} + +// jsonSchemaArrayType is the JSON Schema name for an array. +const jsonSchemaArrayType = "array" + +// requireNonNullArray narrows a nullable JSON Schema slice node to type array. +func requireNonNullArray(schema *jsonschema.Schema) error { + if schema == nil { + return errors.New("schema is required") + } + if schema.Type == jsonSchemaArrayType && schema.Types == nil { + return nil + } + hasArray := false + hasNull := false + for _, typ := range schema.Types { + switch typ { + case jsonSchemaArrayType: + hasArray = true + case "null": + hasNull = true + default: + return fmt.Errorf("schema type includes %q", typ) + } + } + if schema.Type != "" || !hasArray || !hasNull { + return errors.New("schema must be a nullable array") + } + schema.Type = jsonSchemaArrayType + schema.Types = nil + return nil +} + +// requireResolvedSchema reports whether schema can be used as a tool output schema. +func requireResolvedSchema(schema *jsonschema.Schema) error { + if schema == nil { + return errors.New("inferred schema is nil") + } + _, err := schema.Resolve(nil) + return err +} diff --git a/mcpserver/server_test.go b/mcpserver/server_test.go index aa7cdd2..95d40eb 100644 --- a/mcpserver/server_test.go +++ b/mcpserver/server_test.go @@ -2,6 +2,7 @@ package mcpserver_test import ( "context" + "encoding/json" "errors" "fmt" "sync/atomic" @@ -63,8 +64,8 @@ func TestNewRejectsMissingDependencies(t *testing.T) { } } -// TestNewRegistersExactlyThreeTools proves the adapter exposes only the three official tools -// and lists authoring guidance on each description. +// TestNewRegistersExactlyThreeTools proves the adapter exposes only the three official tools, +// lists authoring guidance on each description, and advertises non-null output schemas. func TestNewRegistersExactlyThreeTools(t *testing.T) { session := newTestSession(t, mocks.NewMockService(t), mocks.NewMockInvocationResolver(t)) @@ -79,14 +80,19 @@ func TestNewRegistersExactlyThreeTools(t *testing.T) { // cues are required authoring phrases on the listed description. cues []string + + // assertOutput inspects the advertised outputSchema. + assertOutput func(*testing.T, map[string]any) }{ { - name: "search_api", - cues: []string{"short literal substring", "shorter term"}, + name: "search_api", + cues: []string{"short literal substring", "shorter term"}, + assertOutput: requireSearchAPIOutputSchema, }, { - name: "describe_api", - cues: []string{"exact name returned by search_api", "without whitespace or case changes"}, + name: "describe_api", + cues: []string{"exact name returned by search_api", "without whitespace or case changes"}, + assertOutput: requireDescribeAPIOutputSchema, }, { name: "execute", @@ -97,6 +103,7 @@ func TestNewRegistersExactlyThreeTools(t *testing.T) { "confirmed through search_api and describe_api", "final result", }, + assertOutput: requireExecuteOutputSchema, }, } for _, tt := range tests { @@ -105,6 +112,7 @@ func TestNewRegistersExactlyThreeTools(t *testing.T) { for _, cue := range tt.cues { assert.Contains(t, description, cue) } + tt.assertOutput(t, listedOutputSchema(t, listed.Tools, tt.name)) }) } } @@ -204,8 +212,8 @@ func TestToolsResolveSubjectBeforeServiceWork(t *testing.T) { "signature": "records.lookup()", "summary": "lookup", "description": "", - "input": nil, - "output": nil, + "input": []any{}, + "output": []any{}, }, describeResult.StructuredContent) executeResult, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ Name: "execute", @@ -236,6 +244,104 @@ func TestToolsIgnoreUntrustedClientMetadata(t *testing.T) { assert.Equal(t, []any{}, result.StructuredContent) } +// TestToolsSerializeSuccessfulEmptySlicesAsArrays proves successful nil and empty +// service slices serialize as JSON arrays, not null, including the text mirror. +func TestToolsSerializeSuccessfulEmptySlicesAsArrays(t *testing.T) { + tests := []struct { + // name identifies the successful slice serialization. + name string + + // tool is the official tool under test. + tool string + + // arguments are the valid tool arguments. + arguments map[string]any + + // configure installs one generated service expectation. + configure func(*mocks.MockService) + + // want is the successful structured content and JSON text mirror. + want any + }{ + { + name: "search nil results", + tool: "search_api", + arguments: map[string]any{"query": "lookup"}, + configure: func(service *mocks.MockService) { + service.EXPECT().Search("lookup").Return(nil, nil).Once() + }, + want: []any{}, + }, + { + name: "search empty results", + tool: "search_api", + arguments: map[string]any{"query": "lookup"}, + configure: func(service *mocks.MockService) { + service.EXPECT().Search("lookup").Return([]codemode.SearchResult{}, nil).Once() + }, + want: []any{}, + }, + { + name: "search populated results", + tool: "search_api", + arguments: map[string]any{"query": "lookup"}, + configure: func(service *mocks.MockService) { + service.EXPECT().Search("lookup").Return([]codemode.SearchResult{ + {Name: "records.lookup", Signature: "records.lookup()", Summary: "lookup"}, + }, nil).Once() + }, + want: []any{ + map[string]any{ + "name": "records.lookup", + "signature": "records.lookup()", + "summary": "lookup", + }, + }, + }, + { + name: "describe nil field slices", + tool: "describe_api", + arguments: map[string]any{"name": "records.lookup"}, + configure: func(service *mocks.MockService) { + service.EXPECT(). + Describe(codemode.CapabilityName("records.lookup")). + Return(codemode.Description{ + Name: "records.lookup", + Signature: "records.lookup()", + Summary: "lookup", + }, nil). + Once() + }, + want: map[string]any{ + "name": "records.lookup", + "signature": "records.lookup()", + "summary": "lookup", + "description": "", + "input": []any{}, + "output": []any{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := mocks.NewMockService(t) + resolver := mocks.NewMockInvocationResolver(t) + resolver.EXPECT().Resolve(mock.Anything).Return(authz.Subject{ID: "subject-1"}, nil).Once() + tt.configure(service) + session := newTestSession(t, service, resolver) + + result, err := session.client.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tt.tool, + Arguments: tt.arguments, + }) + + require.NoError(t, err) + requireSuccessfulStructuredValue(t, result, tt.want) + }) + } +} + // TestToolsProjectResolverFailures proves resolver errors and empty subjects never reach the service. func TestToolsProjectResolverFailures(t *testing.T) { tests := []struct { @@ -560,17 +666,171 @@ func toolNames(tools []*mcp.Tool) []string { return names } -// listedDescription returns the tools/list description for name. -func listedDescription(t *testing.T, tools []*mcp.Tool, name string) string { +// listedTool returns the tools/list record named name. +func listedTool(t *testing.T, tools []*mcp.Tool, name string) *mcp.Tool { t.Helper() for _, tool := range tools { if tool.Name == name { - return tool.Description + return tool } } require.FailNow(t, "expected listed tool "+name) - return "" + return nil +} + +// listedDescription returns the tools/list description for name. +func listedDescription(t *testing.T, tools []*mcp.Tool, name string) string { + t.Helper() + return listedTool(t, tools, name).Description +} + +// listedOutputSchema returns the advertised tools/list outputSchema for name. +func listedOutputSchema(t *testing.T, tools []*mcp.Tool, name string) map[string]any { + t.Helper() + return requireJSONObject(t, listedTool(t, tools, name).OutputSchema) +} + +// requireSearchAPIOutputSchema requires search_api to advertise a non-null SearchResult array. +func requireSearchAPIOutputSchema(t *testing.T, schema map[string]any) { + t.Helper() + requireNonNullJSONType(t, schema, "array") + requireSearchResultItemSchema(t, schema["items"]) +} + +// requireDescribeAPIOutputSchema requires describe_api to advertise an object whose +// required input and output properties are non-null field-shape arrays. +func requireDescribeAPIOutputSchema(t *testing.T, schema map[string]any) { + t.Helper() + requireNonNullJSONType(t, schema, "object") + requireRequiredNames(t, schema, "name", "signature", "summary", "description", "input", "output") + properties := requireJSONObject(t, schema["properties"]) + requireNonNullJSONType(t, requireJSONObject(t, properties["name"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["signature"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["summary"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["description"]), "string") + input := requireJSONObject(t, properties["input"]) + requireNonNullJSONType(t, input, "array") + requireFieldShapeItemSchema(t, input["items"]) + output := requireJSONObject(t, properties["output"]) + requireNonNullJSONType(t, output, "array") + requireFieldShapeItemSchema(t, output["items"]) +} + +// requireExecuteOutputSchema requires execute to advertise its existing result object shape. +func requireExecuteOutputSchema(t *testing.T, schema map[string]any) { + t.Helper() + requireNonNullJSONType(t, schema, "object") + requireRequiredNames(t, schema, "result") + properties := requireJSONObject(t, schema["properties"]) + _, ok := properties["result"] + require.True(t, ok, "execute outputSchema must describe a result property") +} + +// requireSearchResultItemSchema requires items to be the inferred SearchResult object. +func requireSearchResultItemSchema(t *testing.T, items any) { + t.Helper() + item := requireJSONObject(t, items) + requireNonNullJSONType(t, item, "object") + requireRequiredNames(t, item, "name", "signature", "summary") + properties := requireJSONObject(t, item["properties"]) + requireNonNullJSONType(t, requireJSONObject(t, properties["name"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["signature"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["summary"]), "string") +} + +// requireFieldShapeItemSchema requires items to be the inferred field-shape object. +func requireFieldShapeItemSchema(t *testing.T, items any) { + t.Helper() + item := requireJSONObject(t, items) + requireNonNullJSONType(t, item, "object") + requireRequiredNames(t, item, "name", "type", "required") + properties := requireJSONObject(t, item["properties"]) + requireNonNullJSONType(t, requireJSONObject(t, properties["name"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["type"]), "string") + requireNonNullJSONType(t, requireJSONObject(t, properties["required"]), "boolean") +} + +// requireJSONObject decodes value as a JSON object. +func requireJSONObject(t *testing.T, value any) map[string]any { + t.Helper() + require.NotNil(t, value, "expected a JSON object") + if object, ok := value.(map[string]any); ok { + return object + } + raw, err := json.Marshal(value) + require.NoError(t, err) + var object map[string]any + require.NoError(t, json.Unmarshal(raw, &object), "expected JSON object, got %s", raw) + return object +} + +// requireNonNullJSONType requires schema.type to be exactly typ and to exclude null. +func requireNonNullJSONType(t *testing.T, schema map[string]any, typ string) { + t.Helper() + require.NotNil(t, schema, "expected a JSON Schema object") + switch typed := schema["type"].(type) { + case string: + assert.Equal(t, typ, typed, "schema type must be %q without null", typ) + case []any: + assert.Equal(t, []any{typ}, typed, "schema type must be exactly %q without null", typ) + default: + require.Fail(t, "schema type must be present and exclude null", "got %T %[1]v", schema["type"]) + } +} + +// requireRequiredNames requires schema.required to include every name. +func requireRequiredNames(t *testing.T, schema map[string]any, names ...string) { + t.Helper() + required, ok := schema["required"].([]any) + require.True(t, ok, "schema required must be an array") + got := make([]string, 0, len(required)) + for _, item := range required { + name, ok := item.(string) + require.True(t, ok, "schema required entries must be strings") + got = append(got, name) + } + for _, name := range names { + assert.Contains(t, got, name) + } +} + +// requireNonNullJSONArray requires value to decode as a JSON array, not null. +func requireNonNullJSONArray(t *testing.T, value any) { + t.Helper() + require.NotNil(t, value, "expected a non-null JSON array") + raw, err := json.Marshal(value) + require.NoError(t, err) + require.NotEqual(t, "null", string(raw), "expected a non-null JSON array") + var array []any + require.NoError(t, json.Unmarshal(raw, &array), "expected JSON array, got %s", raw) + require.NotNil(t, array, "expected a non-null JSON array") +} + +// requireJSONTextMirror requires one TextContent item that JSON-equals expected. +func requireJSONTextMirror(t *testing.T, result *mcp.CallToolResult, expected any) { + t.Helper() + require.NotNil(t, result) + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "successful content must be one JSON text item") + raw, err := json.Marshal(expected) + require.NoError(t, err) + assert.JSONEq(t, string(raw), text.Text) +} + +// requireSuccessfulStructuredValue requires a successful tool result whose structured +// content and JSON text mirror equal expected. +func requireSuccessfulStructuredValue(t *testing.T, result *mcp.CallToolResult, expected any) { + t.Helper() + require.NotNil(t, result) + require.False(t, result.IsError, "tool call failed, content: %+v", result.Content) + want, err := json.Marshal(expected) + require.NoError(t, err) + got, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) + requireJSONTextMirror(t, result, expected) } // requireToolValidationError asserts the SDK rejected malformed typed arguments.