From 8e8d4ce6fc8099a5cd6197c92da65f7c451b5824 Mon Sep 17 00:00:00 2001 From: David Ndungu Date: Fri, 3 Jul 2026 00:34:28 -0700 Subject: [PATCH] feat(registry): add verification record and reconcile schema drift The on-disk registry.json uses {schema_version, apis} but RegistryIndex's JSON tags read {version, entries}, so FetchIndex/LoadCachedIndex parsed the real catalog to an empty index -- mint registry list/search/install silently returned zero entries. Map the tags to the canonical on-disk keys so mint parses the real shape; the in-memory Version/Entries field names are unchanged. Add an optional Verification block (tier, verified_at, verifier_version, failure_code, notes) to RegistryEntry per ADR-179/180, and a round-trip test against the real mcp-registry/registry.json (skips when the sibling checkout is absent) plus an always-on golden sample covering the verification record. --- internal/registry/types.go | 36 ++++++-- internal/registry/types_test.go | 140 ++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 9 deletions(-) diff --git a/internal/registry/types.go b/internal/registry/types.go index 0a72dd9..1c33f63 100644 --- a/internal/registry/types.go +++ b/internal/registry/types.go @@ -1,18 +1,36 @@ package registry +// Verification records the functional-verification state of a registry entry. +// It is populated by the mcp-registry verification harness (see ADR-179/180). +// Tier is one of "t0".."t5"; the remaining fields are optional and only set +// once an entry has been through the harness. +type Verification struct { + Tier string `json:"tier"` + VerifiedAt string `json:"verified_at,omitempty"` + VerifierVersion string `json:"verifier_version,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + Notes string `json:"notes,omitempty"` +} + // RegistryEntry represents a single MCP server in the registry. type RegistryEntry struct { - Name string `json:"name"` - Description string `json:"description"` - Tags []string `json:"tags"` - SpecURL string `json:"spec_url"` - AuthType string `json:"auth_type"` - AuthEnvVar string `json:"auth_env_var"` - MinMintVersion string `json:"min_mint_version"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + SpecURL string `json:"spec_url"` + AuthType string `json:"auth_type"` + AuthEnvVar string `json:"auth_env_var"` + MinMintVersion string `json:"min_mint_version"` + Verification *Verification `json:"verification,omitempty"` } // RegistryIndex represents the full registry index. +// +// The on-disk registry.json uses the keys {schema_version, apis}; the in-memory +// field names stay Version/Entries. The JSON tags map the two so mint parses the +// real on-disk shape directly (previously the tags read {version, entries}, which +// silently produced an empty index against the real catalog). type RegistryIndex struct { - Version int `json:"version"` - Entries []RegistryEntry `json:"entries"` + Version int `json:"schema_version"` + Entries []RegistryEntry `json:"apis"` } diff --git a/internal/registry/types_test.go b/internal/registry/types_test.go index 1b6be12..4450276 100644 --- a/internal/registry/types_test.go +++ b/internal/registry/types_test.go @@ -2,6 +2,7 @@ package registry import ( "encoding/json" + "os" "testing" ) @@ -148,3 +149,142 @@ func TestRegistryIndex_EmptyEntries(t *testing.T) { t.Errorf("Entries = %v, want nil", got.Entries) } } + +// goldenIndexJSON is a minimal on-disk registry document in the real +// {schema_version, apis} shape, including a populated verification block. It is +// the always-on assertion that mint parses the canonical on-disk shape and the +// verification record, without depending on the sibling mcp-registry checkout. +const goldenIndexJSON = `{ + "schema_version": 1, + "apis": [ + { + "name": "stripe", + "description": "Stripe payments API", + "tags": ["fintech", "payments"], + "spec_url": "https://example.com/stripe.json", + "auth_type": "bearer", + "auth_env_var": "STRIPE_API_KEY", + "min_mint_version": "0.2.0", + "verification": { + "tier": "t1", + "verified_at": "2026-07-03T00:00:00Z", + "verifier_version": "0.1.0" + } + }, + { + "name": "unverified", + "description": "An entry with no verification block yet", + "tags": ["misc"], + "spec_url": "https://example.com/other.json", + "auth_type": "none", + "auth_env_var": "OTHER_TOKEN", + "min_mint_version": "0.2.0" + } + ] +}` + +// TestRegistryIndex_ParsesOnDiskShape proves mint parses the canonical on-disk +// document ({schema_version, apis}) rather than the legacy {version, entries}. +// Before the tag fix this unmarshaled to Version=0, Entries=nil. +func TestRegistryIndex_ParsesOnDiskShape(t *testing.T) { + var idx RegistryIndex + if err := json.Unmarshal([]byte(goldenIndexJSON), &idx); err != nil { + t.Fatalf("unmarshal golden: %v", err) + } + if idx.Version != 1 { + t.Errorf("Version = %d, want 1 (schema_version not mapped)", idx.Version) + } + if len(idx.Entries) != 2 { + t.Fatalf("Entries len = %d, want 2 (apis not mapped)", len(idx.Entries)) + } + if idx.Entries[0].Name != "stripe" { + t.Errorf("Entries[0].Name = %q, want %q", idx.Entries[0].Name, "stripe") + } +} + +// TestRegistryEntry_Verification checks the optional verification block: present +// entries expose the full record, absent ones leave it nil. +func TestRegistryEntry_Verification(t *testing.T) { + var idx RegistryIndex + if err := json.Unmarshal([]byte(goldenIndexJSON), &idx); err != nil { + t.Fatalf("unmarshal golden: %v", err) + } + + got := idx.Entries[0].Verification + if got == nil { + t.Fatal("Verification = nil, want populated block") + } + if got.Tier != "t1" { + t.Errorf("Tier = %q, want %q", got.Tier, "t1") + } + if got.VerifiedAt != "2026-07-03T00:00:00Z" { + t.Errorf("VerifiedAt = %q, want %q", got.VerifiedAt, "2026-07-03T00:00:00Z") + } + if got.VerifierVersion != "0.1.0" { + t.Errorf("VerifierVersion = %q, want %q", got.VerifierVersion, "0.1.0") + } + + if idx.Entries[1].Verification != nil { + t.Errorf("Entries[1].Verification = %v, want nil (optional block absent)", idx.Entries[1].Verification) + } +} + +// TestVerification_OmitEmpty confirms the verification block is omitted entirely +// when absent, so the backfill only adds keys and never rewrites existing entries. +func TestVerification_OmitEmpty(t *testing.T) { + entry := RegistryEntry{Name: "x", Description: "no verification"} + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if _, ok := m["verification"]; ok { + t.Errorf("verification key present on empty entry, want omitted") + } +} + +// TestRegistryIndex_ParsesRealRegistry reconciles the on-disk vs struct drift +// against the REAL registry.json in the sibling mcp-registry checkout. It guards +// the zero-entries regression: parsing the real catalog must yield entries. +// +// The sibling repo is not present in mint CI, so the test skips when the file is +// absent; set MINT_REGISTRY_FILE to point at a specific registry.json to force it. +func TestRegistryIndex_ParsesRealRegistry(t *testing.T) { + path := os.Getenv("MINT_REGISTRY_FILE") + if path == "" { + for _, cand := range []string{ + "../../../mcp-registry/registry.json", + "../../../../mcp-registry/registry.json", + } { + if _, err := os.Stat(cand); err == nil { + path = cand + break + } + } + } + if path == "" { + t.Skip("sibling mcp-registry/registry.json not found; set MINT_REGISTRY_FILE to run") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var idx RegistryIndex + if err := json.Unmarshal(data, &idx); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + if idx.Version != 1 { + t.Errorf("Version = %d, want 1", idx.Version) + } + if len(idx.Entries) == 0 { + t.Fatalf("real registry parsed to zero entries: on-disk/struct drift regressed") + } + if idx.Entries[0].Name == "" { + t.Errorf("first entry has empty name; struct did not bind apis[]") + } + t.Logf("parsed %d entries from %s (schema_version=%d)", len(idx.Entries), path, idx.Version) +}