mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
Module of the runtime-core split (off main). internal/mcp had no drift on main, so this is a clean extract of the zenline MCP improvements. Deps (config, tools) are on main; uses tools.PropertySchema.Properties/Required from #119. No new deps. - schema.go: serialize nested object Properties/Required (and object-typed Items) when converting tools.PropertySchema <-> MCP JSON schema, so tools with nested args are advertised faithfully to/from MCP hosts instead of being flattened (audit H6). - client.go/server.go: bound MCP stdio handshake/calls so a hung or non-responsive MCP server can't block the caller forever; surface a timeout instead (audit H9). hang_test.go covers it. - registry.go: recognize the hardened client/schema paths; registry_test/schema_test added. build/vet/-race/full-suite + GOOS=windows build green. Co-authored-by: KRATOS <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
122 lines
3.9 KiB
Go
122 lines
3.9 KiB
Go
package mcp
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
func intPtr(value int) *int {
|
|
return &value
|
|
}
|
|
|
|
// A property describing an array of objects must round-trip its nested element
|
|
// shape (items + nested properties + required) through both MCP directions.
|
|
func TestPropertyToMCPMapsNestedArrayAndObjectShape(t *testing.T) {
|
|
property := tools.PropertySchema{
|
|
Type: "array",
|
|
Items: &tools.PropertySchema{
|
|
Type: "object",
|
|
Properties: map[string]tools.PropertySchema{
|
|
"path": {Type: "string", Description: "file path"},
|
|
"line": {Type: "integer", Minimum: intPtr(1)},
|
|
"nested": {Type: "object", Properties: map[string]tools.PropertySchema{"x": {Type: "string"}}, Required: []string{"x"}},
|
|
},
|
|
Required: []string{"path"},
|
|
},
|
|
}
|
|
|
|
out := propertyToMCP(property)
|
|
items, ok := out["items"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("items missing or wrong type: %#v", out)
|
|
}
|
|
if items["type"] != "object" {
|
|
t.Fatalf("items type = %#v, want object", items["type"])
|
|
}
|
|
props, ok := items["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("items.properties missing: %#v", items)
|
|
}
|
|
pathProp, ok := props["path"].(map[string]any)
|
|
if !ok || pathProp["description"] != "file path" {
|
|
t.Fatalf("items.properties.path = %#v", props["path"])
|
|
}
|
|
required, ok := items["required"].([]string)
|
|
if !ok || len(required) != 1 || required[0] != "path" {
|
|
t.Fatalf("items.required = %#v, want [path]", items["required"])
|
|
}
|
|
nested, ok := props["nested"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("nested property missing: %#v", props)
|
|
}
|
|
if _, ok := nested["properties"].(map[string]any); !ok {
|
|
t.Fatalf("nested.properties missing: %#v", nested)
|
|
}
|
|
if nestedReq, ok := nested["required"].([]string); !ok || len(nestedReq) != 1 || nestedReq[0] != "x" {
|
|
t.Fatalf("nested.required = %#v, want [x]", nested["required"])
|
|
}
|
|
}
|
|
|
|
// propertyFromMCP must reconstruct the same nested shape propertyToMCP emits.
|
|
func TestPropertyFromMCPMapsNestedArrayAndObjectShape(t *testing.T) {
|
|
input := map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"path": map[string]any{"type": "string", "description": "file path"},
|
|
"line": map[string]any{"type": "integer", "minimum": float64(1)},
|
|
},
|
|
"required": []any{"path"},
|
|
},
|
|
}
|
|
|
|
property := propertyFromMCP(input)
|
|
if property.Type != "array" {
|
|
t.Fatalf("type = %q, want array", property.Type)
|
|
}
|
|
if property.Items == nil {
|
|
t.Fatal("Items is nil, want reconstructed element schema")
|
|
}
|
|
if property.Items.Type != "object" {
|
|
t.Fatalf("Items.Type = %q, want object", property.Items.Type)
|
|
}
|
|
if len(property.Items.Properties) != 2 {
|
|
t.Fatalf("Items.Properties = %#v, want 2 entries", property.Items.Properties)
|
|
}
|
|
if property.Items.Properties["path"].Description != "file path" {
|
|
t.Fatalf("Items.Properties.path = %#v", property.Items.Properties["path"])
|
|
}
|
|
if line := property.Items.Properties["line"]; line.Minimum == nil || *line.Minimum != 1 {
|
|
t.Fatalf("Items.Properties.line.Minimum = %#v, want 1", line.Minimum)
|
|
}
|
|
if len(property.Items.Required) != 1 || property.Items.Required[0] != "path" {
|
|
t.Fatalf("Items.Required = %#v, want [path]", property.Items.Required)
|
|
}
|
|
}
|
|
|
|
// A full round-trip (tools -> MCP -> tools) must preserve nested object/array shape.
|
|
func TestSchemaRoundTripPreservesNestedShape(t *testing.T) {
|
|
original := tools.PropertySchema{
|
|
Type: "object",
|
|
Properties: map[string]tools.PropertySchema{
|
|
"edits": {
|
|
Type: "array",
|
|
Items: &tools.PropertySchema{
|
|
Type: "object",
|
|
Properties: map[string]tools.PropertySchema{"old": {Type: "string"}, "new": {Type: "string"}},
|
|
Required: []string{"old", "new"},
|
|
},
|
|
},
|
|
},
|
|
Required: []string{"edits"},
|
|
}
|
|
|
|
round := propertyFromMCP(propertyToMCP(original))
|
|
if !reflect.DeepEqual(round, original) {
|
|
t.Fatalf("round-trip mismatch:\n got = %#v\nwant = %#v", round, original)
|
|
}
|
|
}
|