fix(studio): follow Registry pages for exact MCP installation names

This commit is contained in:
xiexian committed 2026-10-03 00:17:39 +08:00
1 parent 77990ed9eb
commit f2cef6dfa8
2 files changed
+187 -16

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
package mcpregistry
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"reasonix/internal/base/testenv"
)
func registryPaginationItem(name string) map[string]any {
return map[string]any{"server": map[string]any{
"name": name, "version": "1.0.0",
"remotes": []any{map[string]any{"type": "streamable-http", "url": "https://mcp.example/mcp"}},
}}
}
func TestResolveFindsExactNameAfterFirstPage(t *testing.T) {
const name = "io.example/demo"
const cursor = "opaque cursor+/=?&"
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
q := r.URL.Query()
if r.URL.Path != "/v0.1/servers" || q.Get("search") != name || q.Get("version") != "latest" || q.Get("limit") != "100" {
t.Errorf("request = %s", r.URL)
w.WriteHeader(http.StatusBadRequest)
return
}
var items []any
next := ""
switch q.Get("cursor") {
case "":
for i := range 100 {
items = append(items, registryPaginationItem(fmt.Sprintf("%s-addon-%03d", name, i)))
}
next = cursor
case cursor:
items = []any{registryPaginationItem(name)}
default:
t.Errorf("cursor changed: %q", q.Get("cursor"))
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"servers": items, "metadata": map[string]any{"count": len(items), "nextCursor": next},
})
}))
t.Cleanup(server.Close)
client := New(filepath.Join(testenv.TempDir(t), "registry.json"))
client.BaseURL = server.URL
entry, result, err := client.Resolve(t.Context(), " "+name+" ")
if err != nil || entry.Name != name || !entry.Installable || result.Cached || result.Warning != "" {
t.Fatalf("Resolve name=%q installable=%t cached=%t entries=%d warning=%q err=%v", entry.Name, entry.Installable, result.Cached, len(result.Entries), result.Warning, err)
}
if requests.Load() != 2 {
t.Fatalf("requests = %d, want two pages", requests.Load())
}
configured, err := entry.PluginEntry("manual")
if err != nil || configured.Name != "manual" || configured.URL != entry.URL || configured.Type != "http" {
t.Fatalf("PluginEntry = %+v, %v", configured, err)
}
server.Close()
browse, err := client.Search(t.Context(), name, 100)
if err != nil || !browse.Cached || len(browse.Entries) != 100 || browse.Entries[0].Name != name+"-addon-000" {
t.Fatalf("browse cached=%t entries=%d err=%v", browse.Cached, len(browse.Entries), err)
}
if _, _, err := client.Resolve(t.Context(), name); err == nil {
t.Fatal("Resolve used cached metadata after the registry stopped")
}
}
func TestResolvePaginationStopsAtMatchEndOrFailure(t *testing.T) {
const name = "io.example/demo"
for _, tc := range []struct {
name string
requests int32
found bool
}{
{"first-page", 1, true},
{"missing", 2, false},
{"failed-page", 2, false},
{"cursor-cycle", 3, false},
} {
t.Run(tc.name, func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
cursor := r.URL.Query().Get("cursor")
if cursor != "" && tc.name == "failed-page" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
items := []any{registryPaginationItem(name + "-other")}
next := ""
switch {
case tc.found:
items = []any{registryPaginationItem(name)}
next = "unused"
case cursor == "", cursor == "page-b":
next = "page-a"
case tc.name == "cursor-cycle":
next = "page-b"
}
_ = json.NewEncoder(w).Encode(map[string]any{
"servers": items, "metadata": map[string]any{"nextCursor": next},
})
}))
t.Cleanup(server.Close)
client := New("")
client.BaseURL = server.URL
entry, result, err := client.Resolve(t.Context(), name)
if (err == nil) != tc.found || result.Cached || (tc.found && entry.Name != name) {
t.Errorf("Resolve = %+v, %+v, %v", entry, result, err)
}
if requests.Load() != tc.requests {
t.Errorf("requests = %d, want %d", requests.Load(), tc.requests)
}
})
}
}
func TestSearchRemainsBoundedToFirstRegistryPage(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.URL.Query().Get("limit") != "1" || r.URL.Query().Get("cursor") != "" {
t.Errorf("browse request = %s", r.URL)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"servers": []any{registryPaginationItem("io.example/demo")},
"metadata": map[string]any{"nextCursor": "unused"},
})
}))
t.Cleanup(server.Close)
client := New("")
client.BaseURL = server.URL
result, err := client.Search(t.Context(), "demo", 1)
if err != nil || len(result.Entries) != 1 || requests.Load() != 1 {
t.Fatalf("Search = %+v, %v, requests=%d", result, err, requests.Load())
}
}
+41 -16
View File
@@ -97,18 +97,32 @@ func (c *Client) Resolve(ctx context.Context, registryName string) (Entry, Resul
// Installation must use current Registry metadata. Search deliberately falls
// back to cache for offline browsing, but a cached package may have been
// removed or marked unavailable since it was stored.
entries, err := c.fetch(ctx, name, maxLimit)
if err != nil {
return Entry{}, Result{}, err
}
c.storeCache(cacheKey(name, maxLimit), entries)
result := Result{Entries: entries}
for _, entry := range result.Entries {
if entry.Name == name || strings.EqualFold(entry.Name, name) {
return entry, result, nil
cursor := ""
seen := make(map[string]bool)
for {
entries, next, err := c.fetchPage(ctx, name, maxLimit, cursor)
if err != nil {
return Entry{}, Result{}, err
}
if cursor == "" {
// The browse cache holds one page at the requested limit.
c.storeCache(cacheKey(name, maxLimit), entries)
}
result := Result{Entries: entries}
for _, entry := range entries {
if entry.Name == name || strings.EqualFold(entry.Name, name) {
return entry, result, nil
}
}
if next == "" {
return Entry{}, result, fmt.Errorf("MCP Registry has no server named %q", name)
}
if seen[next] {
return Entry{}, Result{}, fmt.Errorf("MCP Registry repeated a pagination cursor")
}
seen[next] = true
cursor = next
}
return Entry{}, result, fmt.Errorf("MCP Registry has no server named %q", name)
}
func (e Entry) PluginEntry(localName string) (config.PluginEntry, error) {
@@ -160,6 +174,9 @@ type apiResponse struct {
Servers []struct {
Server apiServer `json:"server"`
} `json:"servers"`
Metadata struct {
NextCursor string `json:"nextCursor"`
} `json:"metadata"`
}
type apiServer struct {
@@ -198,13 +215,18 @@ type apiTransport struct {
}
func (c *Client) fetch(ctx context.Context, query string, limit int) ([]Entry, error) {
entries, _, err := c.fetchPage(ctx, query, limit, "")
return entries, err
}
func (c *Client) fetchPage(ctx context.Context, query string, limit int, cursor string) ([]Entry, string, error) {
base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
if base == "" {
base = DefaultBaseURL
}
endpoint, err := url.Parse(base + "/v0.1/servers")
if err != nil {
return nil, err
return nil, "", err
}
values := endpoint.Query()
values.Set("limit", fmt.Sprintf("%d", limit))
@@ -212,10 +234,13 @@ func (c *Client) fetch(ctx context.Context, query string, limit int) ([]Entry, e
if query != "" {
values.Set("search", query)
}
if cursor != "" {
values.Set("cursor", cursor)
}
endpoint.RawQuery = values.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
return nil, "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "reasonix-mcp-registry/dev")
@@ -225,17 +250,17 @@ func (c *Client) fetch(ctx context.Context, query string, limit int) ([]Entry, e
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("query MCP Registry: %w", err)
return nil, "", fmt.Errorf("query MCP Registry: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("query MCP Registry: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return nil, "", fmt.Errorf("query MCP Registry: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var response apiResponse
decoder := json.NewDecoder(io.LimitReader(resp.Body, maxBody))
if err := decoder.Decode(&response); err != nil {
return nil, fmt.Errorf("decode MCP Registry response: %w", err)
return nil, "", fmt.Errorf("decode MCP Registry response: %w", err)
}
entries := make([]Entry, 0, len(response.Servers))
for _, item := range response.Servers {
@@ -243,7 +268,7 @@ func (c *Client) fetch(ctx context.Context, query string, limit int) ([]Entry, e
entries = append(entries, entry)
}
}
return entries, nil
return entries, response.Metadata.NextCursor, nil
}
func normalize(server apiServer) (Entry, bool) {