Skip to content

Commit

Permalink
✨ Add a proper update-ui MCP verb
Browse files Browse the repository at this point in the history
  • Loading branch information
wesen committed Feb 26, 2025
1 parent d48e528 commit 63e2414
Show file tree
Hide file tree
Showing 6 changed files with 952 additions and 24 deletions.
163 changes: 159 additions & 4 deletions cmd/ui-server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,21 +384,66 @@ func (s *Server) handleUIUpdate() http.HandlerFunc {
// Parse JSON body
var jsonData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&jsonData); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "json_parse_error",
"message": "Invalid JSON: " + err.Error(),
},
})
return
}

// Convert to YAML for storage
yamlData, err := yaml.Marshal(jsonData)
if err != nil {
http.Error(w, "Failed to convert to YAML: "+err.Error(), http.StatusInternalServerError)
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "yaml_conversion_error",
"message": "Failed to convert to YAML: " + err.Error(),
},
})
return
}

// Parse into UIDefinition
var def UIDefinition
if err := yaml.Unmarshal(yamlData, &def); err != nil {
http.Error(w, "Invalid UI definition: "+err.Error(), http.StatusBadRequest)
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "ui_definition_error",
"message": "Invalid UI definition: " + err.Error(),
"yaml": string(yamlData),
},
})
return
}

// Validate the UI definition
validationErrors := s.validateUIDefinition(def)
if len(validationErrors) > 0 {
// Return detailed JSON error response with validation errors
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "ui_validation_error",
"message": "UI definition validation failed",
"details": validationErrors,
},
})
return
}

Expand All @@ -410,7 +455,16 @@ func (s *Server) handleUIUpdate() http.HandlerFunc {
}

if err := s.events.Publish("ui-update", event); err != nil {
http.Error(w, "Failed to publish event: "+err.Error(), http.StatusInternalServerError)
// Return detailed JSON error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"type": "event_publish_error",
"message": "Failed to publish event: " + err.Error(),
},
})
return
}

Expand All @@ -424,6 +478,107 @@ func (s *Server) handleUIUpdate() http.HandlerFunc {
}
}

// validateUIDefinition checks a UI definition for common errors
func (s *Server) validateUIDefinition(def UIDefinition) []map[string]interface{} {
var errors []map[string]interface{}

// Check if components exist
if len(def.Components) == 0 {
errors = append(errors, map[string]interface{}{
"path": "components",
"message": "No components defined",
})
return errors
}

// Validate each component
for i, comp := range def.Components {
for typ, props := range comp {
propsMap, ok := props.(map[string]interface{})
if !ok {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s", i, typ),
"message": "Component properties must be a map",
})
continue
}

// Check for required ID
if _, hasID := propsMap["id"]; !hasID && requiresID(typ) {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s", i, typ),
"message": "Component is missing required 'id' property",
})
}

// Validate nested components in forms
if typ == "form" {
if formComps, hasComps := propsMap["components"]; hasComps {
if formCompsList, ok := formComps.([]interface{}); ok {
for j, formComp := range formCompsList {
if formCompMap, ok := formComp.(map[string]interface{}); ok {
for formCompType, formCompProps := range formCompMap {
if _, ok := formCompProps.(map[string]interface{}); !ok {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.components[%d].%s", i, typ, j, formCompType),
"message": "Form component properties must be a map",
})
}
}
} else {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.components[%d]", i, typ, j),
"message": "Form component must be a map",
})
}
}
} else {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.components", i, typ),
"message": "Form components must be an array",
})
}
}
}

// Validate list items
if typ == "list" {
if items, hasItems := propsMap["items"]; hasItems {
if itemsList, ok := items.([]interface{}); ok {
for j, item := range itemsList {
if _, ok := item.(map[string]interface{}); !ok {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.items[%d]", i, typ, j),
"message": "List item must be a map",
})
}
}
} else {
errors = append(errors, map[string]interface{}{
"path": fmt.Sprintf("components[%d].%s.items", i, typ),
"message": "List items must be an array",
})
}
}
}
}
}

return errors
}

// requiresID returns true if the component type requires an ID
func requiresID(componentType string) bool {
switch componentType {
case "text", "title":
// These can optionally have IDs
return false
default:
// All other components require IDs
return true
}
}

// handleUIAction handles POST requests to /api/ui-action
func (s *Server) handleUIAction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Expand Down
110 changes: 110 additions & 0 deletions examples/pages/tests/milking-fail.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
components:
- title:
content: Dairy Farm Milking Management
id: milking-title

- text:
content: Track and manage your herd's milking schedule and production.
id: intro-text

- form:
id: milking-dashboard
components:
- title:
content: Milking Session
id: session-title

- input:
type: date
placeholder: \"Select date\"
id: milking-date
value: \"2025-02-26\"
required: true

- checkbox:
label: \"Morning Milking Completed\"
id: morning-milking
checked: false

- checkbox:
label: \"Evening Milking Completed\"
id: evening-milking
checked: false

- form:
id: cow-selection
components:
- title:
content: Cow Selection
id: cow-selection-title

- input:
type: text
placeholder: \"Search cow by ID or name\"
id: cow-search
required: false

- list:
type: ul
title: \"Available Cows\"
items:
- text:
content: \"Select a cow to record milk production:\"
- checkbox:
label: \"Bessie (#1024) - Holstein\"
id: cow-1024
- checkbox:
label: \"Daisy (#1045) - Jersey\"
id: cow-1045
- checkbox:
label: \"Buttercup (#1078) - Brown Swiss\"
id: cow-1078
- checkbox:
label: \"Clover (#1135) - Holstein\"
id: cow-1135

- form:
id: milk-production
components:
- title:
content: Milk Production
id: production-title

- input:
type: number
placeholder: \"Enter milk quantity (liters)\"
id: milk-quantity
required: true

- textarea:
placeholder: \"Notes (health observations, behavior, etc.)\"
id: milking-notes
rows: 3

- checkbox:
label: \"Flag for veterinary attention\"
id: vet-flag
checked: false

- button:
text: \"Record Production\"
id: record-btn
type: primary

- list:
type: ul
title: \"Today's Statistics\"
items:
- text:
content: \"Total milk collected: 275 liters\"
- text:
content: \"Cows milked: 18/24\"
- text:
content: \"Average per cow: 15.3 liters\"
- text:
content: \"Next scheduled milking: Today at 17:00\"

- button:
text: \"Complete Milking Session\"
id: complete-session
type: success
2 changes: 1 addition & 1 deletion examples/ui/update-dino-form.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
YAML_CONTENT=$(cat "$SCRIPT_DIR/example-form.yaml")

# Run the update-ui command with the YAML content
go-go-mcp run-command "$SCRIPT_DIR/update-ui.yaml" \
mcp run-command "$SCRIPT_DIR/update-ui.yaml" \
--components "$YAML_CONTENT" \
--verbose
Loading

0 comments on commit 63e2414

Please sign in to comment.