返回 DeepSeek-Reasonix
registry.go
根目录 / internal / mcpregistry / registry.go
1 // Package mcpregistry provides the explicit browse/install path for the
2 // official Model Context Protocol Registry. It is never called during boot or
3 // tool discovery, so a slow or unavailable registry cannot affect a session.
4 package mcpregistry
5
6 import (
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "net/http"
12 "net/url"
13 "os"
14 "path/filepath"
15 "regexp"
16 "sort"
17 "strings"
18 "time"
19
20 "reasonix/internal/config"
21 "reasonix/internal/fileutil"
22 )
23
24 const DefaultBaseURL = "https://registry.modelcontextprotocol.io"
25
26 const (
27 defaultLimit = 20
28 maxLimit = 100
29 maxBody = 8 << 20
30 maxCacheAge = 30 * 24 * time.Hour
31 )
32
33 type Client struct {
34 BaseURL string
35 HTTP *http.Client
36 CachePath string
37 Now func() time.Time
38 }
39
40 func New(cachePath string) *Client {
41 return &Client{
42 BaseURL: DefaultBaseURL,
43 HTTP: &http.Client{Timeout: 15 * time.Second},
44 CachePath: cachePath,
45 Now: time.Now,
46 }
47 }
48
49 // Entry is one registry server reduced to the configuration Reasonix can
50 // install without prompting for missing secrets or server-specific arguments.
51 type Entry struct {
52 Name string `json:"name"`
53 SuggestedName string `json:"suggestedName"`
54 Title string `json:"title,omitempty"`
55 Description string `json:"description,omitempty"`
56 Version string `json:"version,omitempty"`
57 RepositoryURL string `json:"repositoryUrl,omitempty"`
58 Installable bool `json:"installable"`
59 UnavailableReason string `json:"unavailableReason,omitempty"`
60 Transport string `json:"transport,omitempty"`
61 Command string `json:"command,omitempty"`
62 Args []string `json:"args,omitempty"`
63 URL string `json:"url,omitempty"`
64 }
65
66 type Result struct {
67 Entries []Entry
68 Cached bool
69 Warning string
70 }
71
72 func (c *Client) Search(ctx context.Context, query string, limit int) (Result, error) {
73 query = strings.TrimSpace(query)
74 if limit <= 0 {
75 limit = defaultLimit
76 }
77 if limit > maxLimit {
78 limit = maxLimit
79 }
80 key := cacheKey(query, limit)
81 entries, err := c.fetch(ctx, query, limit)
82 if err == nil {
83 c.storeCache(key, entries)
84 return Result{Entries: entries}, nil
85 }
86 if cached, ok := c.loadCache(key); ok {
87 return Result{Entries: cached, Cached: true, Warning: err.Error()}, nil
88 }
89 return Result{}, err
90 }
91
92 func (c *Client) Resolve(ctx context.Context, registryName string) (Entry, Result, error) {
93 name := strings.TrimSpace(registryName)
94 if name == "" {
95 return Entry{}, Result{}, fmt.Errorf("registry server name is required")
96 }
97 // Installation must use current Registry metadata. Search deliberately falls
98 // back to cache for offline browsing, but a cached package may have been
99 // removed or marked unavailable since it was stored.
100 entries, err := c.fetch(ctx, name, maxLimit)
101 if err != nil {
102 return Entry{}, Result{}, err
103 }
104 c.storeCache(cacheKey(name, maxLimit), entries)
105 result := Result{Entries: entries}
106 for _, entry := range result.Entries {
107 if entry.Name == name || strings.EqualFold(entry.Name, name) {
108 return entry, result, nil
109 }
110 }
111 return Entry{}, result, fmt.Errorf("MCP Registry has no server named %q", name)
112 }
113
114 func (e Entry) PluginEntry(localName string) (config.PluginEntry, error) {
115 if !e.Installable {
116 reason := e.UnavailableReason
117 if reason == "" {
118 reason = "the registry entry has no directly installable transport"
119 }
120 return config.PluginEntry{}, fmt.Errorf("registry server %q requires manual setup: %s", e.Name, reason)
121 }
122 name := strings.TrimSpace(localName)
123 if name == "" {
124 name = SuggestedName(e.Name)
125 }
126 entry := config.PluginEntry{Name: name}
127 switch e.Transport {
128 case "stdio":
129 entry.Command = e.Command
130 entry.Args = append([]string(nil), e.Args...)
131 case "http", "sse":
132 entry.Type = e.Transport
133 entry.URL = e.URL
134 default:
135 return config.PluginEntry{}, fmt.Errorf("registry server %q has unsupported transport %q", e.Name, e.Transport)
136 }
137 return entry, nil
138 }
139
140 var invalidLocalName = regexp.MustCompile(`[^a-z0-9._-]+`)
141
142 func SuggestedName(registryName string) string {
143 name := strings.TrimSpace(registryName)
144 if slash := strings.LastIndex(name, "/"); slash >= 0 {
145 name = name[slash+1:]
146 }
147 name = strings.ToLower(name)
148 name = invalidLocalName.ReplaceAllString(name, "-")
149 name = strings.Trim(name, "-._")
150 if name == "" {
151 return "mcp-server"
152 }
153 if len(name) > 64 {
154 name = strings.TrimRight(name[:64], "-._")
155 }
156 return name
157 }
158
159 type apiResponse struct {
160 Servers []struct {
161 Server apiServer `json:"server"`
162 } `json:"servers"`
163 }
164
165 type apiServer struct {
166 Name string `json:"name"`
167 Title string `json:"title"`
168 Description string `json:"description"`
169 Version string `json:"version"`
170 Repository *apiRepo `json:"repository"`
171 Packages []apiPackage `json:"packages"`
172 Remotes []apiRemote `json:"remotes"`
173 }
174
175 type apiRepo struct {
176 URL string `json:"url"`
177 }
178
179 type apiPackage struct {
180 RegistryType string `json:"registryType"`
181 Identifier string `json:"identifier"`
182 Version string `json:"version"`
183 Transport apiTransport `json:"transport"`
184 EnvironmentVariables []json.RawMessage `json:"environmentVariables"`
185 PackageArguments []json.RawMessage `json:"packageArguments"`
186 RuntimeArguments []json.RawMessage `json:"runtimeArguments"`
187 }
188
189 type apiRemote struct {
190 Type string `json:"type"`
191 URL string `json:"url"`
192 Headers []json.RawMessage `json:"headers"`
193 Variables map[string]json.RawMessage `json:"variables"`
194 }
195
196 type apiTransport struct {
197 Type string `json:"type"`
198 }
199
200 func (c *Client) fetch(ctx context.Context, query string, limit int) ([]Entry, error) {
201 base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
202 if base == "" {
203 base = DefaultBaseURL
204 }
205 endpoint, err := url.Parse(base + "/v0.1/servers")
206 if err != nil {
207 return nil, err
208 }
209 values := endpoint.Query()
210 values.Set("limit", fmt.Sprintf("%d", limit))
211 values.Set("version", "latest")
212 if query != "" {
213 values.Set("search", query)
214 }
215 endpoint.RawQuery = values.Encode()
216 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
217 if err != nil {
218 return nil, err
219 }
220 req.Header.Set("Accept", "application/json")
221 req.Header.Set("User-Agent", "reasonix-mcp-registry/dev")
222 client := c.HTTP
223 if client == nil {
224 client = &http.Client{Timeout: 15 * time.Second}
225 }
226 resp, err := client.Do(req)
227 if err != nil {
228 return nil, fmt.Errorf("query MCP Registry: %w", err)
229 }
230 defer resp.Body.Close()
231 if resp.StatusCode/100 != 2 {
232 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
233 return nil, fmt.Errorf("query MCP Registry: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
234 }
235 var response apiResponse
236 decoder := json.NewDecoder(io.LimitReader(resp.Body, maxBody))
237 if err := decoder.Decode(&response); err != nil {
238 return nil, fmt.Errorf("decode MCP Registry response: %w", err)
239 }
240 entries := make([]Entry, 0, len(response.Servers))
241 for _, item := range response.Servers {
242 if entry, ok := normalize(item.Server); ok {
243 entries = append(entries, entry)
244 }
245 }
246 return entries, nil
247 }
248
249 func normalize(server apiServer) (Entry, bool) {
250 if strings.TrimSpace(server.Name) == "" {
251 return Entry{}, false
252 }
253 entry := Entry{
254 Name: server.Name,
255 SuggestedName: SuggestedName(server.Name),
256 Title: server.Title,
257 Description: server.Description,
258 Version: server.Version,
259 }
260 if server.Repository != nil {
261 entry.RepositoryURL = server.Repository.URL
262 }
263 var reasons []string
264 for _, remote := range server.Remotes {
265 transport := strings.ToLower(strings.TrimSpace(remote.Type))
266 if transport != "streamable-http" && transport != "http" && transport != "sse" {
267 continue
268 }
269 if strings.TrimSpace(remote.URL) == "" {
270 continue
271 }
272 if len(remote.Headers) > 0 || len(remote.Variables) > 0 {
273 reasons = append(reasons, "remote transport requires headers or URL variables")
274 continue
275 }
276 entry.Installable = true
277 entry.Transport = "http"
278 if transport == "sse" {
279 entry.Transport = "sse"
280 }
281 entry.URL = remote.URL
282 return entry, true
283 }
284 for _, pkg := range server.Packages {
285 transport := strings.ToLower(strings.TrimSpace(pkg.Transport.Type))
286 if transport != "" && transport != "stdio" {
287 continue
288 }
289 if len(pkg.EnvironmentVariables) > 0 || len(pkg.PackageArguments) > 0 || len(pkg.RuntimeArguments) > 0 {
290 reasons = append(reasons, "package requires environment variables or arguments")
291 continue
292 }
293 identifier := strings.TrimSpace(pkg.Identifier)
294 if identifier == "" {
295 continue
296 }
297 version := strings.TrimSpace(pkg.Version)
298 if version == "" {
299 version = strings.TrimSpace(server.Version)
300 }
301 switch strings.ToLower(strings.TrimSpace(pkg.RegistryType)) {
302 case "npm":
303 entry.Command = "npx"
304 entry.Args = []string{"-y", npmPackageVersion(identifier, version)}
305 case "pypi":
306 entry.Command = "uvx"
307 entry.Args = []string{pythonPackageVersion(identifier, version)}
308 default:
309 continue
310 }
311 entry.Installable = true
312 entry.Transport = "stdio"
313 return entry, true
314 }
315 if len(reasons) > 0 {
316 sort.Strings(reasons)
317 entry.UnavailableReason = reasons[0]
318 } else {
319 entry.UnavailableReason = "no supported stdio, Streamable HTTP, or SSE transport"
320 }
321 return entry, true
322 }
323
324 func npmPackageVersion(identifier, version string) string {
325 if version == "" {
326 return identifier
327 }
328 return identifier + "@" + version
329 }
330
331 func pythonPackageVersion(identifier, version string) string {
332 if version == "" {
333 return identifier
334 }
335 return identifier + "==" + version
336 }
337
338 type cacheFile struct {
339 // FetchedAt is retained as a read-only fallback for caches written by older
340 // Reasonix versions. New writes timestamp each query independently so a
341 // successful lookup cannot keep unrelated stale results alive.
342 FetchedAt time.Time `json:"fetchedAt,omitempty"`
343 QueryFetchedAt map[string]time.Time `json:"queryFetchedAt,omitempty"`
344 Queries map[string][]Entry `json:"queries"`
345 }
346
347 func cacheKey(query string, limit int) string {
348 return strings.ToLower(strings.TrimSpace(query)) + "\x00" + fmt.Sprintf("%d", limit)
349 }
350
351 func (c *Client) now() time.Time {
352 if c.Now != nil {
353 return c.Now()
354 }
355 return time.Now()
356 }
357
358 func (c *Client) loadCache(key string) ([]Entry, bool) {
359 if strings.TrimSpace(c.CachePath) == "" {
360 return nil, false
361 }
362 data, err := os.ReadFile(c.CachePath)
363 if err != nil {
364 return nil, false
365 }
366 var cache cacheFile
367 if json.Unmarshal(data, &cache) != nil {
368 return nil, false
369 }
370 entries, ok := cache.Queries[key]
371 if !ok {
372 return nil, false
373 }
374 fetchedAt := cache.QueryFetchedAt[key]
375 if fetchedAt.IsZero() {
376 fetchedAt = cache.FetchedAt
377 }
378 if fetchedAt.IsZero() || c.now().Sub(fetchedAt) > maxCacheAge {
379 return nil, false
380 }
381 return append([]Entry(nil), entries...), ok
382 }
383
384 func (c *Client) storeCache(key string, entries []Entry) {
385 if strings.TrimSpace(c.CachePath) == "" {
386 return
387 }
388 cache := cacheFile{
389 QueryFetchedAt: map[string]time.Time{},
390 Queries: map[string][]Entry{},
391 }
392 if data, err := os.ReadFile(c.CachePath); err == nil {
393 _ = json.Unmarshal(data, &cache)
394 if cache.Queries == nil {
395 cache.Queries = map[string][]Entry{}
396 }
397 if cache.QueryFetchedAt == nil {
398 cache.QueryFetchedAt = map[string]time.Time{}
399 }
400 }
401 now := c.now().UTC()
402 if cache.FetchedAt.IsZero() {
403 cache.FetchedAt = now
404 }
405 cache.QueryFetchedAt[key] = now
406 cache.Queries[key] = append([]Entry(nil), entries...)
407 data, err := json.MarshalIndent(cache, "", " ")
408 if err != nil {
409 return
410 }
411 if os.MkdirAll(filepath.Dir(c.CachePath), 0o700) != nil {
412 return
413 }
414 _ = fileutil.AtomicWriteFile(c.CachePath, data, 0o600)
415 }
416
416 lines GO