mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): follow Registry pages for exact MCP installation names
This commit is contained in:
1 parent
77990ed9eb
commit
f2cef6dfa8
2 files changed
+187
-16
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user