Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -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`).
12 changes: 6 additions & 6 deletions pkg/kubescape/kubescape.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -832,18 +832,18 @@ 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{})
if err != nil {
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
Expand All @@ -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
}
Expand Down
Loading