Skip to content

Commit 6ec592d

Browse files
peppescgclaude
andauthored
fix(registry): surface legacy registry format as a structured API error (#5260)
* Surface legacy registry format as a structured API error Before this change, when a configured custom registry URL served data in the legacy ToolHive format, NewRemoteRegistryProvider returned a plain fmt.Errorf that the API handler did not recognise. The API responded with a generic HTTP 500 "Failed to get registry provider", and the actionable migration hint was visible only in main.log. Desktop clients had no way to distinguish this case from an internal error. Add a typed *LegacyFormatError (sister of *UnavailableError) carrying the offending source URL. Wire it through the four registry GET handlers and the v0.1 router so the response is now HTTP 503 with a structured "registry_legacy_format" code and the migration hint in the message body. The upstream_parser sentinel errLegacyFormat is now an instance of *LegacyFormatError; its Is() method preserves errors.Is(err, errLegacyFormat) for existing callers while enabling errors.As extraction of the URL. Fixes #5259 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Switch registry_legacy_format status code to 502 Bad Gateway 503 was inconsistent with the existing PUT registry handler that already returns 502 for the same legacy-format detection via ErrRegistryValidationFailed (pkg/api/v1/registry.go updateRegistry handler). 502 is also the correct RFC 9110 §15.6.3 code: thv serve acts as a gateway to the upstream registry and the upstream returned a response we cannot process. 503 is reserved for "temporary overload or scheduled maintenance" which does not match a misconfigured registry source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix stale 503 references in doc comments Two doc comments missed the 502 update from the previous commit. The code itself already returns 502 Bad Gateway via writeRegistryLegacyFormatError; only the comments were inconsistent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 030594a commit 6ec592d

7 files changed

Lines changed: 280 additions & 5 deletions

File tree

pkg/api/v1/registry.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ func writeRegistryAuthRequiredError(w http.ResponseWriter) {
5656
// structured JSON 503 response when the upstream registry is unreachable.
5757
const RegistryUnavailableCode = "registry_unavailable"
5858

59+
// RegistryLegacyFormatCode is the machine-readable error code returned in the
60+
// structured JSON 502 response when the configured registry source serves data
61+
// in the legacy ToolHive format instead of the upstream MCP registry format.
62+
// Desktop clients (Studio) match on this value to display a targeted recovery
63+
// flow (reset to default registry, point to a `?format=upstream` endpoint, or
64+
// run `thv registry convert`).
65+
const RegistryLegacyFormatCode = "registry_legacy_format"
66+
5967
// writeRegistryUnavailableError writes a structured JSON 503 response when the
6068
// upstream registry cannot be reached or returns an unexpected error (e.g. 404).
6169
func writeRegistryUnavailableError(w http.ResponseWriter, unavailableErr *regpkg.UnavailableError) {
@@ -68,6 +76,24 @@ func writeRegistryUnavailableError(w http.ResponseWriter, unavailableErr *regpkg
6876
_ = json.NewEncoder(w).Encode(body)
6977
}
7078

79+
// writeRegistryLegacyFormatError writes a structured JSON 502 Bad Gateway
80+
// response when the configured registry source returns data in the legacy
81+
// ToolHive format. HTTP 502 is correct per RFC 9110 §15.6.3: thv serve is
82+
// acting as a gateway to the upstream registry, and the upstream returned a
83+
// response we cannot process. It also aligns with the existing PUT handler
84+
// which already returns 502 for the same legacy-format detection. The message
85+
// embeds legacyhint.MigrationMessage so CLI users still see the actionable
86+
// migration step, while desktop clients can branch on Code.
87+
func writeRegistryLegacyFormatError(w http.ResponseWriter, legacyErr *regpkg.LegacyFormatError) {
88+
body := registryErrorResponse{
89+
Code: RegistryLegacyFormatCode,
90+
Message: legacyErr.Error(),
91+
}
92+
w.Header().Set("Content-Type", "application/json")
93+
w.WriteHeader(http.StatusBadGateway)
94+
_ = json.NewEncoder(w).Encode(body)
95+
}
96+
7197
// resolveAuthStatus returns the auth_status and auth_type strings for API responses
7298
// by delegating to the AuthManager.
7399
func (rr *RegistryRoutes) resolveAuthStatus() (authStatus, authType string) {
@@ -267,6 +293,12 @@ func (rr *RegistryRoutes) getCurrentProvider(w http.ResponseWriter) (regpkg.Prov
267293
writeRegistryAuthRequiredError(w)
268294
return nil, false
269295
}
296+
var legacyErr *regpkg.LegacyFormatError
297+
if errors.As(err, &legacyErr) {
298+
slog.Error("upstream registry in legacy format", "error", err)
299+
writeRegistryLegacyFormatError(w, legacyErr)
300+
return nil, false
301+
}
270302
var unavailableErr *regpkg.UnavailableError
271303
if errors.As(err, &unavailableErr) {
272304
slog.Error("upstream registry unavailable", "error", err)
@@ -373,6 +405,12 @@ func (rr *RegistryRoutes) listRegistries(w http.ResponseWriter, _ *http.Request)
373405
writeRegistryAuthRequiredError(w)
374406
return
375407
}
408+
var legacyErr *regpkg.LegacyFormatError
409+
if errors.As(err, &legacyErr) {
410+
slog.Error("upstream registry in legacy format", "error", err)
411+
writeRegistryLegacyFormatError(w, legacyErr)
412+
return
413+
}
376414
var unavailableErr *regpkg.UnavailableError
377415
if errors.As(err, &unavailableErr) {
378416
slog.Error("upstream registry unavailable", "error", err)
@@ -454,6 +492,12 @@ func (rr *RegistryRoutes) getRegistry(w http.ResponseWriter, r *http.Request) {
454492
writeRegistryAuthRequiredError(w)
455493
return
456494
}
495+
var legacyErr *regpkg.LegacyFormatError
496+
if errors.As(err, &legacyErr) {
497+
slog.Error("upstream registry in legacy format", "error", err)
498+
writeRegistryLegacyFormatError(w, legacyErr)
499+
return
500+
}
457501
var unavailableErr *regpkg.UnavailableError
458502
if errors.As(err, &unavailableErr) {
459503
slog.Error("upstream registry unavailable", "error", err)
@@ -741,6 +785,12 @@ func (rr *RegistryRoutes) listServers(w http.ResponseWriter, r *http.Request) {
741785
writeRegistryAuthRequiredError(w)
742786
return
743787
}
788+
var legacyErr *regpkg.LegacyFormatError
789+
if errors.As(err, &legacyErr) {
790+
slog.Error("upstream registry in legacy format", "error", err)
791+
writeRegistryLegacyFormatError(w, legacyErr)
792+
return
793+
}
744794
var unavailableErr *regpkg.UnavailableError
745795
if errors.As(err, &unavailableErr) {
746796
slog.Error("upstream registry unavailable", "error", err)

pkg/api/v1/registry_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,113 @@ func TestRegistryAPI_GetEndpoint_UnavailableUpstream(t *testing.T) {
140140
}
141141
}
142142

143+
// TestRegistryAPI_GetEndpoint_LegacyFormat tests that GET endpoints return 502
144+
// with a structured "registry_legacy_format" code when the configured custom
145+
// registry URL serves data in the legacy ToolHive format. 502 is correct per
146+
// RFC 9110 §15.6.3: thv serve acts as a gateway to the upstream registry and
147+
// the upstream returned a response we cannot process.
148+
//
149+
//nolint:paralleltest // Uses global registry provider singleton
150+
func TestRegistryAPI_GetEndpoint_LegacyFormat(t *testing.T) {
151+
// Mock server that returns a valid HTTP 200 but with legacy ToolHive
152+
// registry JSON (top-level "servers" instead of nested "data.servers").
153+
// validateConnectivity() must detect this and reject the provider with a
154+
// *LegacyFormatError.
155+
legacyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
156+
w.Header().Set("Content-Type", "application/json")
157+
w.WriteHeader(http.StatusOK)
158+
_, _ = w.Write([]byte(`{
159+
"version": "1.0.0",
160+
"servers": {"example": {"image": "example:latest"}}
161+
}`))
162+
}))
163+
defer legacyServer.Close()
164+
165+
// Configure registry to point at the mock legacy-format server via the
166+
// remote URL provider path (RegistryUrl, not RegistryApiUrl).
167+
cfg := &config.Config{
168+
RegistryUrl: legacyServer.URL,
169+
AllowPrivateRegistryIp: true,
170+
}
171+
configProvider, cleanup := CreateTestConfigProvider(t, cfg)
172+
defer cleanup()
173+
174+
registry.ResetDefaultProvider()
175+
t.Cleanup(registry.ResetDefaultProvider)
176+
177+
routes := &RegistryRoutes{
178+
configProvider: configProvider,
179+
configService: registry.NewConfiguratorWithProvider(configProvider),
180+
serveMode: true,
181+
}
182+
183+
endpoints := []struct {
184+
name string
185+
method string
186+
path string
187+
handler http.HandlerFunc
188+
urlParams map[string]string
189+
}{
190+
{
191+
name: "listRegistries",
192+
method: http.MethodGet,
193+
path: "/",
194+
handler: routes.listRegistries,
195+
},
196+
{
197+
name: "getRegistry",
198+
method: http.MethodGet,
199+
path: "/default",
200+
handler: routes.getRegistry,
201+
urlParams: map[string]string{"name": "default"},
202+
},
203+
{
204+
name: "listServers",
205+
method: http.MethodGet,
206+
path: "/default/servers",
207+
handler: routes.listServers,
208+
urlParams: map[string]string{"name": "default"},
209+
},
210+
}
211+
212+
for _, ep := range endpoints {
213+
t.Run(ep.name, func(t *testing.T) {
214+
registry.ResetDefaultProvider()
215+
216+
req := httptest.NewRequest(ep.method, ep.path, nil)
217+
if ep.urlParams != nil {
218+
rctx := chi.NewRouteContext()
219+
for k, v := range ep.urlParams {
220+
rctx.URLParams.Add(k, v)
221+
}
222+
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
223+
}
224+
225+
w := httptest.NewRecorder()
226+
ep.handler(w, req)
227+
228+
assert.Equal(t, http.StatusBadGateway, w.Code,
229+
"Expected 502 Bad Gateway when registry is in legacy format")
230+
231+
var body registryErrorResponse
232+
err := json.NewDecoder(w.Body).Decode(&body)
233+
require.NoError(t, err, "Response should be valid JSON")
234+
assert.Equal(t, RegistryLegacyFormatCode, body.Code,
235+
"Response code should be registry_legacy_format")
236+
// The body must carry the actionable migration hint so CLI users
237+
// and desktop clients both have something to act on.
238+
assert.Contains(t, body.Message, "legacy ToolHive format",
239+
"Response message should mention the legacy format")
240+
assert.Contains(t, body.Message, "thv registry convert",
241+
"Response message should include the migration command hint")
242+
assert.Contains(t, body.Message, legacyServer.URL,
243+
"Response message should identify the offending source URL")
244+
assert.Contains(t, w.Header().Get("Content-Type"), "application/json",
245+
"Response Content-Type should be application/json")
246+
})
247+
}
248+
}
249+
143250
func TestRegistryRouter(t *testing.T) {
144251
t.Parallel()
145252

pkg/api/v1/registry_v01.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ func getRegistryProvider(w http.ResponseWriter) (regpkg.Provider, bool) {
5252
writeRegistryAuthRequiredError(w)
5353
return nil, false
5454
}
55+
var legacyErr *regpkg.LegacyFormatError
56+
if errors.As(err, &legacyErr) {
57+
slog.Error("upstream registry in legacy format", "error", err)
58+
writeRegistryLegacyFormatError(w, legacyErr)
59+
return nil, false
60+
}
5561
var unavailableErr *regpkg.UnavailableError
5662
if errors.As(err, &unavailableErr) {
5763
slog.Error("upstream registry unavailable", "error", err)

pkg/registry/errors.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package registry
66
import (
77
"errors"
88
"fmt"
9+
10+
"github.com/stacklok/toolhive/pkg/registry/legacyhint"
911
)
1012

1113
// ErrServerNotFound indicates a server was not found in the registry.
@@ -29,3 +31,31 @@ func (e *UnavailableError) Error() string {
2931
func (e *UnavailableError) Unwrap() error {
3032
return e.Err
3133
}
34+
35+
// LegacyFormatError indicates the registry source contains data in the legacy
36+
// ToolHive registry format instead of the upstream MCP registry format.
37+
// API handlers translate this into a structured HTTP 502 with a
38+
// "registry_legacy_format" code so desktop clients can surface a targeted
39+
// recovery flow (instead of a generic error screen).
40+
//
41+
// URL is optional and identifies the offending source (remote URL or local
42+
// file path) when known. The Error() message embeds legacyhint.MigrationMessage
43+
// so CLI consumers continue to see the same actionable hint.
44+
type LegacyFormatError struct {
45+
URL string
46+
}
47+
48+
func (e *LegacyFormatError) Error() string {
49+
if e.URL != "" {
50+
return fmt.Sprintf("registry at %s: %s", e.URL, legacyhint.MigrationMessage)
51+
}
52+
return legacyhint.MigrationMessage
53+
}
54+
55+
// Is enables errors.Is matching against any *LegacyFormatError sentinel,
56+
// regardless of the URL field. Existing callers using a zero-value sentinel
57+
// (e.g. errLegacyFormat) keep matching when the returned error carries a URL.
58+
func (*LegacyFormatError) Is(target error) bool {
59+
_, ok := target.(*LegacyFormatError)
60+
return ok
61+
}

pkg/registry/errors_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,82 @@ func TestUnavailableError_ErrorsAs(t *testing.T) {
6666
assert.Equal(t, "https://example.com", target.URL)
6767
assert.Equal(t, inner, target.Err)
6868
}
69+
70+
// TestLegacyFormatError_Error covers the two surface forms (with and without
71+
// URL) so CLI users keep seeing the migration hint and remote callers also see
72+
// where the legacy content came from.
73+
func TestLegacyFormatError_Error(t *testing.T) {
74+
t.Parallel()
75+
76+
tests := []struct {
77+
name string
78+
err *LegacyFormatError
79+
expectedPrefix string
80+
}{
81+
{
82+
name: "with URL",
83+
err: &LegacyFormatError{URL: "https://example.com/registry.json"},
84+
expectedPrefix: "registry at https://example.com/registry.json: ",
85+
},
86+
{
87+
name: "without URL",
88+
err: &LegacyFormatError{},
89+
expectedPrefix: "",
90+
},
91+
}
92+
93+
for _, tt := range tests {
94+
t.Run(tt.name, func(t *testing.T) {
95+
t.Parallel()
96+
msg := tt.err.Error()
97+
if tt.expectedPrefix != "" {
98+
assert.Truef(t,
99+
len(msg) > len(tt.expectedPrefix) && msg[:len(tt.expectedPrefix)] == tt.expectedPrefix,
100+
"expected message to start with %q, got %q", tt.expectedPrefix, msg)
101+
}
102+
// Migration hint must always be present so CLI users get the
103+
// actionable step regardless of whether a URL was attached.
104+
assert.Contains(t, msg, "legacy ToolHive format")
105+
assert.Contains(t, msg, "thv registry convert")
106+
})
107+
}
108+
}
109+
110+
// TestLegacyFormatError_ErrorsIs verifies the Is method matches any
111+
// *LegacyFormatError regardless of URL. This keeps errors.Is(err, errLegacyFormat)
112+
// working when the returned error carries a populated URL from a remote source.
113+
func TestLegacyFormatError_ErrorsIs(t *testing.T) {
114+
t.Parallel()
115+
116+
withURL := &LegacyFormatError{URL: "https://example.com/registry.json"}
117+
sentinel := &LegacyFormatError{}
118+
119+
assert.True(t, errors.Is(withURL, sentinel),
120+
"errors.Is(withURL, sentinel) should match via Is method")
121+
assert.True(t, errors.Is(sentinel, withURL),
122+
"errors.Is(sentinel, withURL) should match via Is method")
123+
124+
// Wrapped via fmt.Errorf should still match through Unwrap chain.
125+
wrapped := fmt.Errorf("custom registry at %s is not reachable: %w", withURL.URL, withURL)
126+
assert.True(t, errors.Is(wrapped, sentinel),
127+
"errors.Is should follow the Unwrap chain to find a *LegacyFormatError")
128+
129+
// A different error type must not match.
130+
other := &UnavailableError{URL: "https://example.com", Err: fmt.Errorf("boom")}
131+
assert.False(t, errors.Is(other, sentinel),
132+
"errors.Is must not cross-match *UnavailableError as legacy")
133+
}
134+
135+
// TestLegacyFormatError_ErrorsAs verifies the typed extraction used by the
136+
// API layer to surface a structured 502 response with the source URL.
137+
func TestLegacyFormatError_ErrorsAs(t *testing.T) {
138+
t.Parallel()
139+
140+
original := &LegacyFormatError{URL: "https://example.com/registry.json"}
141+
wrapped := fmt.Errorf("custom registry at %s is not reachable: %w", original.URL, original)
142+
143+
var target *LegacyFormatError
144+
require.True(t, errors.As(wrapped, &target),
145+
"errors.As must extract *LegacyFormatError from the Unwrap chain")
146+
assert.Equal(t, "https://example.com/registry.json", target.URL)
147+
}

pkg/registry/provider_remote.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func (p *RemoteRegistryProvider) validateConnectivity() error {
8282
}
8383

8484
if legacyhint.Looks(data) {
85-
return fmt.Errorf("registry at %s: %s", p.registryURL, legacyhint.MigrationMessage)
85+
return &LegacyFormatError{URL: p.registryURL}
8686
}
8787

8888
var upstream types.UpstreamRegistry

pkg/registry/upstream_parser.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package registry
55

66
import (
77
"encoding/json"
8-
"errors"
98
"fmt"
109

1110
v0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
@@ -18,9 +17,13 @@ import (
1817
// registry format. Without this check, Go's JSON decoder silently produces an
1918
// empty UpstreamRegistry (the legacy top-level "servers" field does not match
2019
// upstream's "data.servers" path), leaving the caller with an empty registry
21-
// and no actionable error. The error wording carries the migration step so
22-
// consumers can surface it without a typed match.
23-
var errLegacyFormat = errors.New(legacyhint.MigrationMessage)
20+
// and no actionable error.
21+
//
22+
// It's an instance of *LegacyFormatError so the API layer can detect it via
23+
// errors.As and emit a structured "registry_legacy_format" response. The Is
24+
// method on LegacyFormatError keeps errors.Is(err, errLegacyFormat) working
25+
// even when the returned error carries a populated URL.
26+
var errLegacyFormat = &LegacyFormatError{}
2427

2528
// parseRegistryData parses raw JSON in the upstream MCP registry format and
2629
// converts it into the internal types.Registry plus any embedded skills.

0 commit comments

Comments
 (0)