Skip to content

Commit 70b7945

Browse files
JAORMXclaude
andcommitted
Use standard JSON Schema format for VMCP composite tool parameters
Fixes #2650 Changes composite tool parameter definitions to use standard JSON Schema format per the MCP specification, instead of a non-standard simplified format. **Breaking Change**: Composite tool parameter format has changed from: ```yaml parameters: param1: type: "string" default: "value" ``` To standard JSON Schema: ```yaml parameters: type: object properties: param1: type: string default: "value" required: ["param1"] ``` Changes: - Update CompositeToolConfig.Parameters to map[string]any (full JSON Schema) - Remove parameter transformation logic from yaml_loader and workflow_converter - Add JSON Schema validation in yaml_loader (checks type: object) - Update Kubernetes CRDs to use runtime.RawExtension for parameters - Update webhook validation to handle runtime.RawExtension - Regenerate CRD manifests and deepcopy code - Update all tests and documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Juan Antonio Osorio <ozz@stacklok.com>
1 parent 4e36464 commit 70b7945

21 files changed

+562
-321
lines changed

cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_types.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,22 @@ type VirtualMCPCompositeToolDefinitionSpec struct {
1919
// +kubebuilder:validation:MinLength=1
2020
Description string `json:"description"`
2121

22-
// Parameters defines the input parameter schema for the workflow
23-
// Each key is a parameter name, each value is the parameter specification
22+
// Parameters defines the input parameter schema for the workflow in JSON Schema format.
23+
// Should be a JSON Schema object with "type": "object" and "properties".
24+
// Per MCP specification, this should follow standard JSON Schema for tool inputSchema.
25+
// Example:
26+
// {
27+
// "type": "object",
28+
// "properties": {
29+
// "param1": {"type": "string", "default": "value"},
30+
// "param2": {"type": "integer"}
31+
// },
32+
// "required": ["param2"]
33+
// }
2434
// +optional
25-
Parameters map[string]ParameterSpec `json:"parameters,omitempty"`
35+
// +kubebuilder:pruning:PreserveUnknownFields
36+
// +kubebuilder:validation:Type=object
37+
Parameters *runtime.RawExtension `json:"parameters,omitempty"`
2638

2739
// Steps defines the workflow step definitions
2840
// Steps are executed sequentially in Phase 1

cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_webhook.go

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -101,59 +101,36 @@ func (r *VirtualMCPCompositeToolDefinition) Validate() error {
101101

102102
// validateParameters validates the parameter schema using JSON Schema validation
103103
func (r *VirtualMCPCompositeToolDefinition) validateParameters() error {
104-
if len(r.Spec.Parameters) == 0 {
104+
if r.Spec.Parameters == nil || len(r.Spec.Parameters.Raw) == 0 {
105105
return nil // No parameters to validate
106106
}
107107

108-
// Build a JSON Schema object from the parameters
109-
// Parameters map to a JSON Schema "properties" object
110-
properties := make(map[string]interface{})
111-
var required []string
112-
113-
for paramName, param := range r.Spec.Parameters {
114-
if param.Type == "" {
115-
return fmt.Errorf("spec.parameters[%s].type is required", paramName)
116-
}
117-
118-
// Build a JSON Schema property definition
119-
property := map[string]interface{}{
120-
"type": param.Type,
121-
}
122-
123-
if param.Description != "" {
124-
property["description"] = param.Description
125-
}
126-
127-
if param.Default != "" {
128-
// Parse default value based on type
129-
property["default"] = param.Default
130-
}
131-
132-
if param.Required {
133-
required = append(required, paramName)
134-
}
135-
136-
properties[paramName] = property
108+
// Parameters should be a JSON Schema object in RawExtension format
109+
// Unmarshal to validate structure
110+
var params map[string]interface{}
111+
if err := json.Unmarshal(r.Spec.Parameters.Raw, &params); err != nil {
112+
return fmt.Errorf("spec.parameters: invalid JSON: %v", err)
137113
}
138114

139-
// Construct a full JSON Schema document
140-
schemaDoc := map[string]interface{}{
141-
"type": "object",
142-
"properties": properties,
115+
// Validate that it has "type" field
116+
typeVal, hasType := params["type"]
117+
if !hasType {
118+
return fmt.Errorf("spec.parameters: must have 'type' field (should be 'object' for JSON Schema)")
143119
}
144120

145-
if len(required) > 0 {
146-
schemaDoc["required"] = required
121+
// Type must be a string
122+
typeStr, ok := typeVal.(string)
123+
if !ok {
124+
return fmt.Errorf("spec.parameters: 'type' field must be a string")
147125
}
148126

149-
// Marshal to JSON
150-
schemaJSON, err := json.Marshal(schemaDoc)
151-
if err != nil {
152-
return fmt.Errorf("spec.parameters: failed to marshal schema: %v", err)
127+
// Type should be "object" for parameter schemas
128+
if typeStr != "object" {
129+
return fmt.Errorf("spec.parameters: 'type' must be 'object' (got '%s')", typeStr)
153130
}
154131

155132
// Validate using JSON Schema validator
156-
if err := validateJSONSchema(schemaJSON); err != nil {
133+
if err := validateJSONSchema(r.Spec.Parameters.Raw); err != nil {
157134
return fmt.Errorf("spec.parameters: invalid JSON Schema: %v", err)
158135
}
159136

cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_webhook_test.go

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,22 @@ func TestVirtualMCPCompositeToolDefinitionValidate(t *testing.T) {
105105
Spec: VirtualMCPCompositeToolDefinitionSpec{
106106
Name: "deploy_app",
107107
Description: "Deploy application with parameters",
108-
Parameters: map[string]ParameterSpec{
109-
"environment": {
110-
Type: "string",
111-
Description: "Target environment",
112-
Required: true,
113-
},
114-
"replicas": {
115-
Type: "integer",
116-
Description: "Number of replicas",
117-
Default: "3",
118-
},
108+
Parameters: &runtime.RawExtension{
109+
Raw: []byte(`{
110+
"type": "object",
111+
"properties": {
112+
"environment": {
113+
"type": "string",
114+
"description": "Target environment"
115+
},
116+
"replicas": {
117+
"type": "integer",
118+
"description": "Number of replicas",
119+
"default": 3
120+
}
121+
},
122+
"required": ["environment"]
123+
}`),
119124
},
120125
Steps: []WorkflowStep{
121126
{
@@ -137,10 +142,15 @@ func TestVirtualMCPCompositeToolDefinitionValidate(t *testing.T) {
137142
Spec: VirtualMCPCompositeToolDefinitionSpec{
138143
Name: "deploy_app",
139144
Description: "Deploy application",
140-
Parameters: map[string]ParameterSpec{
141-
"environment": {
142-
Type: "invalid_type",
143-
},
145+
Parameters: &runtime.RawExtension{
146+
Raw: []byte(`{
147+
"type": "invalid_type_not_object",
148+
"properties": {
149+
"environment": {
150+
"type": "string"
151+
}
152+
}
153+
}`),
144154
},
145155
Steps: []WorkflowStep{
146156
{
@@ -151,7 +161,7 @@ func TestVirtualMCPCompositeToolDefinitionValidate(t *testing.T) {
151161
},
152162
},
153163
wantErr: true,
154-
errMsg: "spec.parameters: invalid JSON Schema",
164+
errMsg: "spec.parameters: 'type' must be 'object'",
155165
},
156166
{
157167
name: "missing step ID",

cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,22 @@ type CompositeToolSpec struct {
185185
// +kubebuilder:validation:Required
186186
Description string `json:"description"`
187187

188-
// Parameters defines the input parameters for the composite tool
188+
// Parameters defines the input parameter schema in JSON Schema format.
189+
// Should be a JSON Schema object with "type": "object" and "properties".
190+
// Per MCP specification, this should follow standard JSON Schema for tool inputSchema.
191+
// Example:
192+
// {
193+
// "type": "object",
194+
// "properties": {
195+
// "param1": {"type": "string", "default": "value"},
196+
// "param2": {"type": "integer"}
197+
// },
198+
// "required": ["param2"]
199+
// }
189200
// +optional
190-
Parameters map[string]ParameterSpec `json:"parameters,omitempty"`
201+
// +kubebuilder:pruning:PreserveUnknownFields
202+
// +kubebuilder:validation:Type=object
203+
Parameters *runtime.RawExtension `json:"parameters,omitempty"`
191204

192205
// Steps defines the workflow steps
193206
// +kubebuilder:validation:Required
@@ -200,26 +213,6 @@ type CompositeToolSpec struct {
200213
Timeout string `json:"timeout,omitempty"`
201214
}
202215

203-
// ParameterSpec defines a parameter for a composite tool
204-
type ParameterSpec struct {
205-
// Type is the parameter type (string, integer, boolean, etc.)
206-
// +kubebuilder:validation:Required
207-
Type string `json:"type"`
208-
209-
// Description describes the parameter
210-
// +optional
211-
Description string `json:"description,omitempty"`
212-
213-
// Default is the default value for the parameter
214-
// +optional
215-
Default string `json:"default,omitempty"`
216-
217-
// Required indicates if the parameter is required
218-
// +kubebuilder:default=false
219-
// +optional
220-
Required bool `json:"required,omitempty"`
221-
}
222-
223216
// WorkflowStep defines a step in a composite tool workflow
224217
type WorkflowStep struct {
225218
// ID is the unique identifier for this step

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 4 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/pkg/vmcpconfig/converter.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package vmcpconfig
33

44
import (
55
"context"
6+
"encoding/json"
67
"time"
78

9+
"sigs.k8s.io/controller-runtime/pkg/log"
10+
811
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
912
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
1013
)
@@ -248,7 +251,7 @@ func (*Converter) convertAggregation(
248251

249252
// convertCompositeTools converts CompositeToolSpec from CRD to vmcp config
250253
func (*Converter) convertCompositeTools(
251-
_ context.Context,
254+
ctx context.Context,
252255
vmcp *mcpv1alpha1.VirtualMCPServer,
253256
) []*vmcpconfig.CompositeToolConfig {
254257
compositeTools := make([]*vmcpconfig.CompositeToolConfig, 0, len(vmcp.Spec.CompositeTools))
@@ -257,7 +260,6 @@ func (*Converter) convertCompositeTools(
257260
tool := &vmcpconfig.CompositeToolConfig{
258261
Name: crdTool.Name,
259262
Description: crdTool.Description,
260-
Parameters: make(map[string]vmcpconfig.ParameterSchema),
261263
Steps: make([]*vmcpconfig.WorkflowStepConfig, 0, len(crdTool.Steps)),
262264
}
263265

@@ -268,11 +270,16 @@ func (*Converter) convertCompositeTools(
268270
}
269271
}
270272

271-
// Convert parameters
272-
for paramName, paramSpec := range crdTool.Parameters {
273-
tool.Parameters[paramName] = vmcpconfig.ParameterSchema{
274-
Type: paramSpec.Type,
275-
Default: paramSpec.Default,
273+
// Convert parameters from runtime.RawExtension to map[string]any
274+
if crdTool.Parameters != nil && len(crdTool.Parameters.Raw) > 0 {
275+
var params map[string]any
276+
if err := json.Unmarshal(crdTool.Parameters.Raw, &params); err != nil {
277+
// Log warning but continue - validation should have caught this at admission time
278+
ctxLogger := log.FromContext(ctx)
279+
ctxLogger.Error(err, "failed to unmarshal composite tool parameters",
280+
"tool", crdTool.Name, "raw", string(crdTool.Parameters.Raw))
281+
} else {
282+
tool.Parameters = params
276283
}
277284
}
278285

0 commit comments

Comments
 (0)