From a369e9a68d308838f33689a522caf16d24b274ae Mon Sep 17 00:00:00 2001 From: Benjamin Huo Date: Thu, 12 Nov 2026 11:07:50 +0800 Subject: [PATCH] fix: address object output schema review feedback Signed-off-by: Benjamin Huo --- AGENTS.md | 1 + docs/release-notes.md | 15 +++++++++++++++ pkg/kubescape/kubescape.go | 12 ++++++------ 3 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 docs/release-notes.md diff --git a/AGENTS.md b/AGENTS.md index ef5d9e8..ef530b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ When a helper builds the `*mcp.CallToolResult` itself (e.g. `runKubectlCommand` **Output-schema pitfalls.** These are enforced by the SDK at call time and are easy to trip: +- **Keep input and output schemas object-rooted for client compatibility.** Clients implementing the MCP 2025-06-18 Tool schema require a root `type: "object"`; an array-rooted output can cause them to reject the entire `tools/list` response. Wrap list outputs in a concrete struct (for example, a DTO with a `Matches []Match` field tagged `json:"matches"`). Nested nullable fields remain valid JSON Schema. `cmd/tools_output_schema_test.go` checks the root type of every registered tool's input and output schemas. - **Zero values are validated on every path, including errors.** A field whose zero value marshals to `null` but whose schema type is non-nullable (notably `map[K]V`) makes *all* error returns fail with `validating tool output`. Give such fields `omitempty`. Slices and pointers infer as nullable (`["null", ...]`) and are safe. - **`json.RawMessage` does not mean "arbitrary JSON".** The schema inference treats it as a byte slice and validation then rejects real objects and arrays. For genuinely dynamic JSON, return the raw text through `mcp.TextOutput` rather than a `json.RawMessage` field, or re-indent it in place with `json.Indent` without decoding into `interface{}` (see `prettyJSONBody` in `pkg/prometheus`). - **Not every type can be an `Out`.** Third-party structs with custom JSON marshallers can fail schema inference, which makes `mcp.AddTool` *panic* at registration (the server will not start). `v1beta1.WorkloadConfigurationScan` is one such type; such handlers return `mcp.TextOutput`. `cmd/tools_output_schema_test.go` registers every provider and fails if any `Out` type cannot produce a valid schema. diff --git a/docs/release-notes.md b/docs/release-notes.md new file mode 100644 index 0000000..5088cbb --- /dev/null +++ b/docs/release-notes.md @@ -0,0 +1,15 @@ +# Release notes + +## Unreleased + +### Kubescape vulnerability details: structured output change + +`kubescape_get_vulnerability_details` now uses an object-rooted output schema so clients implementing the MCP 2025-06-18 Tool schema can accept the server's `tools/list` response. + +Its successful `structuredContent` changes from an array of matches to an object containing that array: + +```json +{"matches": [{"vulnerability": {"id": "CVE-2021-1234"}}]} +``` + +Consumers of `structuredContent` must read the `matches` property instead of treating the result as an array. The JSON above is illustrative; match entries retain their existing fields. The text content continues to contain the original JSON array, and failures remain tool errors (`isError: true`). diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go index 9e7207f..1c18640 100644 --- a/pkg/kubescape/kubescape.go +++ b/pkg/kubescape/kubescape.go @@ -821,7 +821,7 @@ func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request *sdkmcp.CallToolRequest, in getVulnerabilityDetailsInput) (*sdkmcp.CallToolResult, getVulnerabilityDetailsOutput, error) { if k.initError != nil { toolErr := errors.NewKubescapeError("get_vulnerability_details", k.initError) - return kubescapeErrResult(toolErr), getVulnerabilityDetailsOutput{Matches: []v1beta1.Match{}}, nil + return kubescapeErrResult(toolErr), getVulnerabilityDetailsOutput{}, nil } namespace := in.Namespace @@ -832,10 +832,10 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque cveID := in.CveID if manifestName == "" { - return mcp.NewToolResultError("manifest_name parameter is required"), getVulnerabilityDetailsOutput{Matches: []v1beta1.Match{}}, nil + return mcp.NewToolResultError("manifest_name parameter is required"), getVulnerabilityDetailsOutput{}, nil } if cveID == "" { - return mcp.NewToolResultError("cve_id parameter is required"), getVulnerabilityDetailsOutput{Matches: []v1beta1.Match{}}, nil + return mcp.NewToolResultError("cve_id parameter is required"), getVulnerabilityDetailsOutput{}, nil } manifest, err := k.spdxClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{}) @@ -843,7 +843,7 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque toolErr := errors.NewKubescapeError("get_vulnerability_manifest", err). WithContext("namespace", namespace). WithContext("manifest_name", manifestName) - return kubescapeErrResult(toolErr), getVulnerabilityDetailsOutput{Matches: []v1beta1.Match{}}, nil + return kubescapeErrResult(toolErr), getVulnerabilityDetailsOutput{}, nil } // Find matching CVE entries @@ -855,14 +855,14 @@ func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, reque } if len(matches) == 0 { - return mcp.NewToolResultError(fmt.Sprintf("CVE %s not found in manifest %s", cveID, manifestName)), getVulnerabilityDetailsOutput{Matches: []v1beta1.Match{}}, nil + return mcp.NewToolResultError(fmt.Sprintf("CVE %s not found in manifest %s", cveID, manifestName)), getVulnerabilityDetailsOutput{}, nil } // Preserve the array-shaped text response for existing consumers, while // exposing an object-shaped structured result and output schema. content, err := json.Marshal(matches) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), getVulnerabilityDetailsOutput{Matches: []v1beta1.Match{}}, nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), getVulnerabilityDetailsOutput{}, nil } return mcp.NewToolResultText(string(content)), getVulnerabilityDetailsOutput{Matches: matches}, nil }