<!-- Provide a brief summary of your changes -->
This PR serves as a placeholder and brings together all of the changes
related to supporting vendor extensions passed through the API. The idea
is to have a single place to track and review the work as it comes
together (even if it’s still a bit of a work in progress).
**The changes in this PR cover:**
* Updates on the data model
* Updates on both databases (in-memory and postgresql), added a new
table to PSQL for tracking the extensions leaving the server table only
for the clean server.json properties
* Updates the seed.json file (thus the big line change)
* Adds a seed-migrate tool to help migrating an existing seed file to
the new format
* Updates the seed import mechanism to support this
* Updates the existing tests - unit, integration and validation tests
* Adds the publish handler to the OpenAPI spec (it was missing)
* Fixes linting errors
* Updates on the API side on (publish, list, get) to support the new
format of `ServerResponse` -
```
{
"server": {},
"x-io.modelcontextprotocol.registry": {}, /*not available for publish since this gets populated by the registry*/
"x-publisher": {}
}
```
## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
Motivated by the discussions in:
https://github.com/modelcontextprotocol/registry/discussions/284
## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->
Locally and by running all of the tests
## Breaking Changes
<!-- Will users need to update their code or configurations? -->
Yes, it changes both the API and the format of the seed file
## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
---
Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
Co-authored-by: adam jones <domdomegg+git@gmail.com>
178 lines
No EOL
4.9 KiB
Go
178 lines
No EOL
4.9 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/registry/internal/model"
|
|
)
|
|
|
|
// ReadSeedFile reads seed data from various sources:
|
|
// 1. Local file paths (*.json files) - expects extension wrapper format
|
|
// 2. Direct HTTP URLs to seed.json files - expects extension wrapper format
|
|
// 3. Registry root URLs (automatically appends /v0/servers and paginates)
|
|
// Only the extension wrapper format is supported (array of ServerResponse objects)
|
|
func ReadSeedFile(ctx context.Context, path string) ([]*model.ServerRecord, error) {
|
|
var data []byte
|
|
var err error
|
|
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
// Handle HTTP URLs
|
|
if strings.HasSuffix(path, "/v0/servers") || strings.Contains(path, "/v0/servers") {
|
|
// This is a registry API endpoint - fetch paginated data
|
|
return fetchFromRegistryAPI(ctx, path)
|
|
}
|
|
// This is a direct file URL
|
|
data, err = fetchFromHTTP(ctx, path)
|
|
} else {
|
|
// Handle local file paths
|
|
data, err = os.ReadFile(path)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read seed data from %s: %w", path, err)
|
|
}
|
|
|
|
// Parse extension wrapper format (only supported format)
|
|
var serverResponses []model.ServerResponse
|
|
if err := json.Unmarshal(data, &serverResponses); err != nil {
|
|
return nil, fmt.Errorf("failed to parse seed data as extension wrapper format: %w", err)
|
|
}
|
|
|
|
if len(serverResponses) == 0 {
|
|
return []*model.ServerRecord{}, nil
|
|
}
|
|
|
|
// Convert ServerResponse to ServerRecord
|
|
var records []*model.ServerRecord
|
|
for _, response := range serverResponses {
|
|
record := convertServerResponseToRecord(response)
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
func fetchFromHTTP(ctx context.Context, url string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch from HTTP: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP request failed with status: %d", resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*model.ServerRecord, error) {
|
|
var allRecords []*model.ServerRecord
|
|
cursor := ""
|
|
|
|
for {
|
|
url := baseURL
|
|
if cursor != "" {
|
|
if strings.Contains(url, "?") {
|
|
url += "&cursor=" + cursor
|
|
} else {
|
|
url += "?cursor=" + cursor
|
|
}
|
|
}
|
|
|
|
data, err := fetchFromHTTP(ctx, url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch page from registry API: %w", err)
|
|
}
|
|
|
|
var response struct {
|
|
Servers []model.ServerResponse `json:"servers"`
|
|
Metadata *struct {
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &response); err != nil {
|
|
return nil, fmt.Errorf("failed to parse registry API response: %w", err)
|
|
}
|
|
|
|
// Convert and add servers
|
|
for _, serverResponse := range response.Servers {
|
|
record := convertServerResponseToRecord(serverResponse)
|
|
allRecords = append(allRecords, record)
|
|
}
|
|
|
|
// Check if there's a next page
|
|
if response.Metadata == nil || response.Metadata.NextCursor == "" {
|
|
break
|
|
}
|
|
cursor = response.Metadata.NextCursor
|
|
}
|
|
|
|
return allRecords, nil
|
|
}
|
|
|
|
func convertServerResponseToRecord(response model.ServerResponse) *model.ServerRecord {
|
|
// Extract registry metadata from the extension
|
|
registryExt := response.XIOModelContextProtocolRegistry
|
|
|
|
// Parse timestamps
|
|
publishedAt, _ := time.Parse(time.RFC3339, getStringFromInterface(registryExt, "published_at"))
|
|
updatedAt, _ := time.Parse(time.RFC3339, getStringFromInterface(registryExt, "updated_at"))
|
|
|
|
registryMetadata := model.RegistryMetadata{
|
|
ID: getStringFromInterface(registryExt, "id"),
|
|
IsLatest: getBoolFromInterface(registryExt, "is_latest"),
|
|
PublishedAt: publishedAt,
|
|
UpdatedAt: updatedAt,
|
|
ReleaseDate: getStringFromInterface(registryExt, "release_date"),
|
|
}
|
|
|
|
// Publisher extensions
|
|
publisherExtensions := make(map[string]interface{})
|
|
if response.XPublisher != nil {
|
|
if publisherMap, ok := response.XPublisher.(map[string]interface{}); ok {
|
|
publisherExtensions = publisherMap
|
|
}
|
|
}
|
|
|
|
return &model.ServerRecord{
|
|
ServerJSON: response.Server,
|
|
RegistryMetadata: registryMetadata,
|
|
PublisherExtensions: publisherExtensions,
|
|
}
|
|
}
|
|
|
|
func getStringFromInterface(data interface{}, key string) string {
|
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
|
if value, exists := dataMap[key]; exists {
|
|
if strValue, ok := value.(string); ok {
|
|
return strValue
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getBoolFromInterface(data interface{}, key string) bool {
|
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
|
if value, exists := dataMap[key]; exists {
|
|
if boolValue, ok := value.(bool); ok {
|
|
return boolValue
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
} |