Allow for vendor extensions on servers (#298)

<!-- 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>
This commit is contained in:
Radoslav Dimitrov 2025-08-23 11:59:03 +03:00 • committed by GitHub
commit 8d7e471fd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 19980 additions and 15589 deletions

View file

@ -46,7 +46,7 @@ func main() {
// Initialize services based on environment
switch cfg.DatabaseType {
case config.DatabaseTypeMemory:
db = database.NewMemoryDB(map[string]*model.Server{})
db = database.NewMemoryDB(map[string]*model.ServerDetail{})
registryService = service.NewRegistryServiceWithDB(db)
case config.DatabaseTypePostgreSQL:
// Use PostgreSQL for real registry service

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,45 @@
# Server JSON Examples
_These examples show the PublishRequest format used by the `/v0/publish` API endpoint. Each example includes the `server` specification and optional `x-publisher` extensions for build metadata._
## Basic Server with NPM Package
```json
{
"name": "io.modelcontextprotocol/brave-search",
"description": "MCP server for Brave Search API integration",
"status": "active",
"repository": {
"url": "https://github.com/modelcontextprotocol/servers",
"source": "github"
"server": {
"name": "io.modelcontextprotocol/brave-search",
"description": "MCP server for Brave Search API integration",
"status": "active",
"repository": {
"url": "https://github.com/modelcontextprotocol/servers",
"source": "github"
},
"version_detail": {
"version": "1.0.2"
},
"packages": [
{
"registry_name": "npm",
"name": "@modelcontextprotocol/server-brave-search",
"version": "1.0.2",
"environment_variables": [
{
"name": "BRAVE_API_KEY",
"description": "Brave Search API Key",
"is_required": true,
"is_secret": true
}
]
}
]
},
"version_detail": {
"version": "1.0.2"
},
"packages": [
{
"registry_name": "npm",
"name": "@modelcontextprotocol/server-brave-search",
"version": "1.0.2",
"environment_variables": [
{
"name": "BRAVE_API_KEY",
"description": "Brave Search API Key",
"is_required": true,
"is_secret": true
}
]
"x-publisher": {
"tool": "npm-publisher",
"version": "1.0.1",
"build_info": {
"timestamp": "2023-12-01T10:30:00Z"
}
]
}
}
```
@ -38,28 +49,38 @@ Suppose your MCP server application requires a `mcp start` CLI arguments to star
```json
{
"name": "Knapcode.SampleMcpServer",
"description": "Sample NuGet MCP server for a random number and random weather",
"version_detail": {
"version": "0.4.0-beta"
"server": {
"name": "Knapcode.SampleMcpServer",
"description": "Sample NuGet MCP server for a random number and random weather",
"version_detail": {
"version": "0.4.0-beta"
},
"packages": [
{
"registry_name": "nuget",
"name": "Knapcode.SampleMcpServer",
"version": "0.4.0-beta",
"package_arguments": [
{
"type": "positional",
"value": "mcp"
},
{
"type": "positional",
"value": "start"
}
]
}
]
},
"packages": [
{
"registry_name": "nuget",
"name": "Knapcode.SampleMcpServer",
"version": "0.4.0-beta",
"package_arguments": [
{
"type": "positional",
"value": "mcp"
},
{
"type": "positional",
"value": "start"
}
]
"x-publisher": {
"tool": "nuget-publisher",
"version": "2.1.0",
"build_info": {
"timestamp": "2023-11-15T14:22:00Z",
"pipeline_id": "nuget-build-456"
}
]
}
}
```
@ -69,82 +90,94 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
```json
{
"name": "io.modelcontextprotocol/filesystem",
"description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.",
"status": "active",
"repository": {
"url": "https://github.com/modelcontextprotocol/servers",
"source": "github",
"id": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9"
},
"version_detail": {
"version": "1.0.2"
},
"packages": [
{
"registry_name": "npm",
"name": "@modelcontextprotocol/server-filesystem",
"version": "1.0.2",
"package_arguments": [
{
"type": "positional",
"value_hint": "target_dir",
"description": "Path to access",
"default": "/Users/username/Desktop",
"is_required": true,
"is_repeated": true
}
],
"environment_variables": [
{
"name": "LOG_LEVEL",
"description": "Logging level (debug, info, warn, error)",
"default": "info"
}
]
"server": {
"name": "io.modelcontextprotocol/filesystem",
"description": "Node.js server implementing Model Context Protocol (MCP) for filesystem operations.",
"status": "active",
"repository": {
"url": "https://github.com/modelcontextprotocol/servers",
"source": "github",
"id": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9"
},
{
"registry_name": "docker",
"name": "mcp/filesystem",
"version": "1.0.2",
"runtime_arguments": [
{
"type": "named",
"description": "Mount a volume into the container",
"name": "--mount",
"value": "type=bind,src={source_path},dst={target_path}",
"is_required": true,
"is_repeated": true,
"variables": {
"source_path": {
"description": "Source path on host",
"format": "filepath",
"is_required": true
},
"target_path": {
"description": "Path to mount in the container. It should be rooted in `/project` directory.",
"is_required": true,
"default": "/project"
"version_detail": {
"version": "1.0.2"
},
"packages": [
{
"registry_name": "npm",
"name": "@modelcontextprotocol/server-filesystem",
"version": "1.0.2",
"package_arguments": [
{
"type": "positional",
"value_hint": "target_dir",
"description": "Path to access",
"default": "/Users/username/Desktop",
"is_required": true,
"is_repeated": true
}
],
"environment_variables": [
{
"name": "LOG_LEVEL",
"description": "Logging level (debug, info, warn, error)",
"default": "info"
}
]
},
{
"registry_name": "docker",
"name": "mcp/filesystem",
"version": "1.0.2",
"runtime_arguments": [
{
"type": "named",
"description": "Mount a volume into the container",
"name": "--mount",
"value": "type=bind,src={source_path},dst={target_path}",
"is_required": true,
"is_repeated": true,
"variables": {
"source_path": {
"description": "Source path on host",
"format": "filepath",
"is_required": true
},
"target_path": {
"description": "Path to mount in the container. It should be rooted in `/project` directory.",
"is_required": true,
"default": "/project"
}
}
}
}
],
"package_arguments": [
{
"type": "positional",
"value_hint": "target_dir",
"value": "/project"
}
],
"environment_variables": [
{
"name": "LOG_LEVEL",
"description": "Logging level (debug, info, warn, error)",
"default": "info"
}
]
],
"package_arguments": [
{
"type": "positional",
"value_hint": "target_dir",
"value": "/project"
}
],
"environment_variables": [
{
"name": "LOG_LEVEL",
"description": "Logging level (debug, info, warn, error)",
"default": "info"
}
]
}
]
},
"x-publisher": {
"tool": "ci-publisher",
"version": "3.2.1",
"build_info": {
"commit": "a1b2c3d4e5f6789",
"timestamp": "2023-12-01T10:30:00Z",
"pipeline_id": "filesystem-build-789",
"environment": "production"
}
]
}
}
```
@ -152,22 +185,34 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
```json
{
"name": "Remote Filesystem Server",
"description": "Cloud-hosted MCP filesystem server",
"repository": {
"url": "https://github.com/example/remote-fs",
"source": "github",
"id": "xyz789ab-cdef-0123-4567-890ghijklmno"
"server": {
"name": "Remote Filesystem Server",
"description": "Cloud-hosted MCP filesystem server",
"repository": {
"url": "https://github.com/example/remote-fs",
"source": "github",
"id": "xyz789ab-cdef-0123-4567-890ghijklmno"
},
"version_detail": {
"version": "2.0.0"
},
"remotes": [
{
"transport_type": "sse",
"url": "https://mcp-fs.example.com/sse"
}
]
},
"version_detail": {
"version": "2.0.0"
},
"remotes": [
{
"transport_type": "sse",
"url": "https://mcp-fs.example.com/sse"
"x-publisher": {
"tool": "cloud-deployer",
"version": "2.4.0",
"build_info": {
"commit": "f7e8d9c2b1a0",
"timestamp": "2023-12-05T08:45:00Z",
"deployment_id": "remote-fs-deploy-456",
"region": "us-west-2"
}
]
}
}
```
@ -175,37 +220,49 @@ This will essentially instruct the MCP client to execute `dnx Knapcode.SampleMcp
```json
{
"name": "weather-mcp-server",
"description": "Python MCP server for weather data access",
"repository": {
"url": "https://github.com/example/weather-mcp",
"source": "github",
"id": "def456gh-ijkl-7890-mnop-qrstuvwxyz12"
"server": {
"name": "weather-mcp-server",
"description": "Python MCP server for weather data access",
"repository": {
"url": "https://github.com/example/weather-mcp",
"source": "github",
"id": "def456gh-ijkl-7890-mnop-qrstuvwxyz12"
},
"version_detail": {
"version": "0.5.0"
},
"packages": [
{
"registry_name": "pypi",
"name": "weather-mcp-server",
"version": "0.5.0",
"runtime_hint": "uvx",
"environment_variables": [
{
"name": "WEATHER_API_KEY",
"description": "API key for weather service",
"is_required": true,
"is_secret": true
},
{
"name": "WEATHER_UNITS",
"description": "Temperature units (celsius, fahrenheit)",
"default": "celsius"
}
]
}
]
},
"version_detail": {
"version": "0.5.0"
},
"packages": [
{
"registry_name": "pypi",
"name": "weather-mcp-server",
"version": "0.5.0",
"runtime_hint": "uvx",
"environment_variables": [
{
"name": "WEATHER_API_KEY",
"description": "API key for weather service",
"is_required": true,
"is_secret": true
},
{
"name": "WEATHER_UNITS",
"description": "Temperature units (celsius, fahrenheit)",
"default": "celsius"
}
]
"x-publisher": {
"tool": "poetry-publisher",
"version": "1.8.3",
"build_info": {
"python_version": "3.11.5",
"timestamp": "2023-11-28T16:20:00Z",
"build_id": "pypi-weather-123",
"dependencies_hash": "sha256:a9b8c7d6e5f4"
}
]
}
}
```
@ -215,32 +272,45 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
```json
{
"name": "Knapcode.SampleMcpServer",
"description": "Sample NuGet MCP server for a random number and random weather",
"repository": {
"url": "https://github.com/joelverhagen/Knapcode.SampleMcpServer",
"source": "github",
"id": "example-nuget-id-0000-1111-222222222222"
"server": {
"name": "Knapcode.SampleMcpServer",
"description": "Sample NuGet MCP server for a random number and random weather",
"repository": {
"url": "https://github.com/joelverhagen/Knapcode.SampleMcpServer",
"source": "github",
"id": "example-nuget-id-0000-1111-222222222222"
},
"version_detail": {
"version": "0.5.0"
},
"packages": [
{
"registry_name": "nuget",
"name": "Knapcode.SampleMcpServer",
"version": "0.5.0",
"runtime_hint": "dnx",
"environment_variables": [
{
"name": "WEATHER_CHOICES",
"description": "Comma separated list of weather descriptions to randomly select.",
"is_required": true,
"is_secret": false
}
]
}
]
},
"version_detail": {
"version": "0.5.0"
},
"packages": [
{
"registry_name": "nuget",
"name": "Knapcode.SampleMcpServer",
"version": "0.5.0",
"runtime_hint": "dnx",
"environment_variables": [
{
"name": "WEATHER_CHOICES",
"description": "Comma separated list of weather descriptions to randomly select.",
"is_required": true,
"is_secret": false
}
]
"x-publisher": {
"tool": "dotnet-publisher",
"version": "8.0.100",
"build_info": {
"dotnet_version": "8.0.0",
"timestamp": "2023-12-10T12:15:00Z",
"configuration": "Release",
"target_framework": "net8.0",
"build_number": "20231210.1"
}
]
}
}
```
@ -248,85 +318,98 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
```json
{
"name": "mcp-database-manager",
"description": "MCP server for database operations with support for multiple database types",
"repository": {
"url": "https://github.com/example/mcp-database",
"source": "github",
"id": "ghi789jk-lmno-1234-pqrs-tuvwxyz56789"
},
"version_detail": {
"version": "3.1.0"
},
"packages": [
{
"registry_name": "docker",
"name": "mcp/database-manager",
"version": "3.1.0",
"runtime_arguments": [
{
"type": "named",
"name": "--network",
"value": "host",
"description": "Use host network mode"
},
{
"type": "named",
"name": "-e",
"value": "DB_TYPE={db_type}",
"description": "Database type to connect to",
"is_repeated": true,
"variables": {
"db_type": {
"description": "Type of database",
"choices": ["postgres", "mysql", "mongodb", "redis"],
"is_required": true
"server": {
"name": "mcp-database-manager",
"description": "MCP server for database operations with support for multiple database types",
"repository": {
"url": "https://github.com/example/mcp-database",
"source": "github",
"id": "ghi789jk-lmno-1234-pqrs-tuvwxyz56789"
},
"version_detail": {
"version": "3.1.0"
},
"packages": [
{
"registry_name": "docker",
"name": "mcp/database-manager",
"version": "3.1.0",
"runtime_arguments": [
{
"type": "named",
"name": "--network",
"value": "host",
"description": "Use host network mode"
},
{
"type": "named",
"name": "-e",
"value": "DB_TYPE={db_type}",
"description": "Database type to connect to",
"is_repeated": true,
"variables": {
"db_type": {
"description": "Type of database",
"choices": ["postgres", "mysql", "mongodb", "redis"],
"is_required": true
}
}
}
}
],
"package_arguments": [
{
"type": "named",
"name": "--host",
"description": "Database host",
"default": "localhost",
"is_required": true
},
{
"type": "named",
"name": "--port",
"description": "Database port",
"format": "number"
},
{
"type": "positional",
"value_hint": "database_name",
"description": "Name of the database to connect to",
"is_required": true
}
],
"environment_variables": [
{
"name": "DB_USERNAME",
"description": "Database username",
"is_required": true
},
{
"name": "DB_PASSWORD",
"description": "Database password",
"is_required": true,
"is_secret": true
},
{
"name": "SSL_MODE",
"description": "SSL connection mode",
"default": "prefer",
"choices": ["disable", "prefer", "require"]
}
]
],
"package_arguments": [
{
"type": "named",
"name": "--host",
"description": "Database host",
"default": "localhost",
"is_required": true
},
{
"type": "named",
"name": "--port",
"description": "Database port",
"format": "number"
},
{
"type": "positional",
"value_hint": "database_name",
"description": "Name of the database to connect to",
"is_required": true
}
],
"environment_variables": [
{
"name": "DB_USERNAME",
"description": "Database username",
"is_required": true
},
{
"name": "DB_PASSWORD",
"description": "Database password",
"is_required": true,
"is_secret": true
},
{
"name": "SSL_MODE",
"description": "SSL connection mode",
"default": "prefer",
"choices": ["disable", "prefer", "require"]
}
]
}
]
},
"x-publisher": {
"tool": "docker-buildx",
"version": "0.12.1",
"build_info": {
"docker_version": "24.0.7",
"timestamp": "2023-12-08T14:30:00Z",
"platform": "linux/amd64,linux/arm64",
"registry": "docker.io",
"image_digest": "sha256:1a2b3c4d5e6f7890"
}
]
}
}
```
@ -334,57 +417,73 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
```json
{
"name": "hybrid-mcp-server",
"description": "MCP server available as both local package and remote service",
"repository": {
"url": "https://github.com/example/hybrid-mcp",
"source": "github",
"id": "klm012no-pqrs-3456-tuvw-xyz789abcdef"
},
"version_detail": {
"version": "1.5.0"
},
"packages": [
{
"registry_name": "npm",
"name": "@example/hybrid-mcp-server",
"version": "1.5.0",
"runtime_hint": "npx",
"package_arguments": [
{
"type": "named",
"name": "--mode",
"description": "Operation mode",
"default": "local",
"choices": ["local", "cached", "proxy"]
}
]
}
],
"remotes": [
{
"transport_type": "sse",
"url": "https://hybrid-mcp.example.com/sse",
"headers": [
{
"name": "X-API-Key",
"description": "API key for authentication",
"is_required": true,
"is_secret": true
},
{
"name": "X-Region",
"description": "Service region",
"default": "us-east-1",
"choices": ["us-east-1", "eu-west-1", "ap-southeast-1"]
}
]
"server": {
"name": "hybrid-mcp-server",
"description": "MCP server available as both local package and remote service",
"repository": {
"url": "https://github.com/example/hybrid-mcp",
"source": "github",
"id": "klm012no-pqrs-3456-tuvw-xyz789abcdef"
},
{
"transport_type": "streamable",
"url": "https://hybrid-mcp.example.com/stream"
"version_detail": {
"version": "1.5.0"
},
"packages": [
{
"registry_name": "npm",
"name": "@example/hybrid-mcp-server",
"version": "1.5.0",
"runtime_hint": "npx",
"package_arguments": [
{
"type": "named",
"name": "--mode",
"description": "Operation mode",
"default": "local",
"choices": ["local", "cached", "proxy"]
}
]
}
],
"remotes": [
{
"transport_type": "sse",
"url": "https://hybrid-mcp.example.com/sse",
"headers": [
{
"name": "X-API-Key",
"description": "API key for authentication",
"is_required": true,
"is_secret": true
},
{
"name": "X-Region",
"description": "Service region",
"default": "us-east-1",
"choices": ["us-east-1", "eu-west-1", "ap-southeast-1"]
}
]
},
{
"transport_type": "streamable",
"url": "https://hybrid-mcp.example.com/stream"
}
]
},
"x-publisher": {
"tool": "hybrid-deployer",
"version": "1.7.2",
"build_info": {
"timestamp": "2023-12-03T11:00:00Z",
"deployment_strategy": "blue-green",
"npm_version": "10.2.4",
"node_version": "20.10.0",
"service_endpoints": {
"sse": "deployed",
"streamable": "deployed"
}
}
]
}
}
```
@ -392,31 +491,43 @@ The `dnx` tool ships with the .NET 10 SDK, starting with Preview 6.
```json
{
"name": "io.legacy/old-weather-server",
"description": "Legacy weather server - DEPRECATED: Use weather-v2 instead for new projects",
"status": "deprecated",
"repository": {
"url": "https://github.com/example/old-weather",
"source": "github",
"id": "legacy-abc123-def456-789012-345678-901234567890"
"server": {
"name": "io.legacy/old-weather-server",
"description": "Legacy weather server - DEPRECATED: Use weather-v2 instead for new projects",
"status": "deprecated",
"repository": {
"url": "https://github.com/example/old-weather",
"source": "github",
"id": "legacy-abc123-def456-789012-345678-901234567890"
},
"version_detail": {
"version": "0.9.5"
},
"packages": [
{
"registry_name": "npm",
"name": "@legacy/old-weather-server",
"version": "0.9.5",
"environment_variables": [
{
"name": "WEATHER_API_KEY",
"description": "Weather API key",
"is_required": true,
"is_secret": true
}
]
}
]
},
"version_detail": {
"version": "0.9.5"
},
"packages": [
{
"registry_name": "npm",
"name": "@legacy/old-weather-server",
"version": "0.9.5",
"environment_variables": [
{
"name": "WEATHER_API_KEY",
"description": "Weather API key",
"is_required": true,
"is_secret": true
}
]
"x-publisher": {
"tool": "legacy-publisher",
"version": "0.8.1",
"build_info": {
"timestamp": "2023-06-15T09:30:00Z",
"deprecation_notice": "This publisher is deprecated. Use npm-publisher v2.0+ for new projects.",
"maintenance_mode": true,
"final_version": true
}
]
}
}
```

View file

@ -58,7 +58,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ServerDetail'
$ref: '#/components/schemas/ServerResponse'
'404':
description: Server not found
content:
@ -69,7 +69,85 @@ paths:
error:
type: string
example: "Server not found"
/publish:
post:
summary: Publish MCP server
description: Publish a new MCP server to the registry or update an existing one
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PublishRequest'
examples:
basic_server:
summary: Basic server with NPM package
value:
server:
name: "io.modelcontextprotocol/filesystem"
description: "Node.js server implementing Model Context Protocol (MCP) for filesystem operations."
status: "active"
repository:
url: "https://github.com/modelcontextprotocol/servers"
source: "github"
version_detail:
version: "1.0.2"
packages:
- registry_name: "npm"
name: "@modelcontextprotocol/server-filesystem"
version: "1.0.2"
x-publisher:
tool: "publisher-cli"
version: "1.2.3"
build_info:
commit: "abc123def456"
timestamp: "2023-12-01T10:30:00Z"
responses:
'200':
description: Successfully published server
content:
application/json:
schema:
$ref: '#/components/schemas/ServerResponse'
'401':
description: Unauthorized - Invalid or missing authentication token
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Invalid or expired Registry JWT token"
'403':
description: Forbidden - Insufficient permissions
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "You do not have permission to publish this server"
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "Failed to publish server"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Registry JWT token. Convention is that this can be obtained from a `/v0/auth/` endpoint.
schemas:
Repository:
type: object
@ -154,7 +232,7 @@ components:
servers:
type: array
items:
$ref: '#/components/schemas/Server'
$ref: '#/components/schemas/ServerResponse'
metadata:
type: object
properties:
@ -354,3 +432,82 @@ components:
type: array
items:
$ref: '#/components/schemas/Remote'
ServerResponse:
description: API response format for MCP servers, including registry metadata and publisher extensions
type: object
required:
- server
- x-io.modelcontextprotocol.registry
properties:
server:
$ref: '#/components/schemas/ServerDetail'
description: The core MCP server specification
x-io.modelcontextprotocol.registry:
type: object
description: Registry-specific metadata managed by the MCP registry system
required:
- id
- published_at
- updated_at
- is_latest
properties:
id:
type: string
format: uuid
description: Unique registry identifier for this server entry
example: "550e8400-e29b-41d4-a716-446655440000"
published_at:
type: string
format: date-time
description: Timestamp when the server was first published to the registry
example: "2023-12-01T10:30:00Z"
updated_at:
type: string
format: date-time
description: Timestamp when the server entry was last updated
example: "2023-12-01T11:00:00Z"
is_latest:
type: boolean
description: Whether this is the latest version of the server
example: true
release_date:
type: string
format: date-time
description: Release date of the server version
example: "2023-12-01T10:30:00Z"
additionalProperties: false
x-publisher:
type: object
description: Publisher-specific metadata and build information attached during publishing
additionalProperties: true
example:
tool: "publisher-cli"
version: "1.2.3"
build_info:
commit: "abc123def456"
timestamp: "2023-12-01T10:30:00Z"
pipeline_id: "build-789"
additionalProperties: false
PublishRequest:
description: Request format for publishing MCP servers to the registry
type: object
required:
- server
properties:
server:
$ref: '#/components/schemas/ServerDetail'
description: The MCP server specification to publish
x-publisher:
type: object
description: Publisher-specific metadata and build information
additionalProperties: true
example:
tool: "publisher-cli"
version: "1.2.3"
build_info:
commit: "abc123def456"
timestamp: "2023-12-01T10:30:00Z"
pipeline_id: "build-789"
additionalProperties: false

View file

@ -2,6 +2,7 @@ package v0
import (
"context"
"encoding/json"
"net/http"
"strings"
@ -15,7 +16,7 @@ import (
// PublishServerInput represents the input for publishing a server
type PublishServerInput struct {
Authorization string `header:"Authorization" doc:"Registry JWT token (obtained from /v0/auth/token/github)" required:"true"`
Body model.PublishRequest
RawBody []byte `body:"raw"`
}
// RegisterPublishEndpoint registers the publish endpoint
@ -33,7 +34,7 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
Security: []map[string][]string{
{"bearer": {}},
},
}, func(ctx context.Context, input *PublishServerInput) (*Response[model.Server], error) {
}, func(ctx context.Context, input *PublishServerInput) (*Response[model.ServerResponse], error) {
// Extract bearer token
const bearerPrefix = "Bearer "
authHeader := input.Authorization
@ -48,30 +49,34 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
return nil, huma.Error401Unauthorized("Invalid or expired Registry JWT token", err)
}
// Convert PublishRequest body to ServerDetail
serverDetail := input.Body.ServerDetail
// Validate that only allowed extension fields are present
if err := model.ValidatePublishRequestExtensions(input.RawBody); err != nil {
return nil, huma.Error400BadRequest("Invalid request format", err)
}
// Parse the validated request body
var publishRequest model.PublishRequest
if err := json.Unmarshal(input.RawBody, &publishRequest); err != nil {
return nil, huma.Error400BadRequest("Invalid JSON format", err)
}
// Get server details from request body
serverDetail := publishRequest.Server
// Verify that the token's repository matches the server being published
if !jwtManager.HasPermission(serverDetail.Name, auth.PermissionActionPublish, claims.Permissions) {
return nil, huma.Error403Forbidden("You do not have permission to publish this server")
}
// Publish the server details
err = registry.Publish(&serverDetail)
// Publish the server with extensions
publishedServer, err := registry.Publish(publishRequest)
if err != nil {
return nil, huma.Error500InternalServerError("Failed to publish server", err)
}
// Create response with the published server data
return &Response[model.Server]{
Body: model.Server{
ID: serverDetail.ID,
Name: serverDetail.Name,
Description: serverDetail.Description,
Status: serverDetail.Status,
Repository: serverDetail.Repository,
VersionDetail: serverDetail.VersionDetail,
},
// Return the published server in extension wrapper format
return &Response[model.ServerResponse]{
Body: *publishedServer,
}, nil
})
}

View file

@ -54,20 +54,17 @@ func TestPublishIntegration(t *testing.T) {
t.Run("successful publish with GitHub auth", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.testuser/test-mcp-server",
Description: "A test MCP server for integration testing",
Repository: model.Repository{
URL: "https://github.com/testuser/test-mcp-server",
Source: "github",
ID: "testuser/test-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Server: model.ServerDetail{
Name: "io.github.testuser/test-mcp-server",
Description: "A test MCP server for integration testing",
Repository: model.Repository{
URL: "https://github.com/testuser/test-mcp-server",
Source: "github",
ID: "testuser/test-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
// Packages field removed as it depends on the actual model.Package structure
},
}
@ -94,28 +91,26 @@ func TestPublishIntegration(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
var response model.Server
var response model.ServerResponse
err = json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, response.Name)
assert.Equal(t, publishReq.VersionDetail.Version, response.VersionDetail.Version)
assert.Equal(t, publishReq.Server.Name, response.Server.Name)
assert.Equal(t, publishReq.Server.VersionDetail.Version, response.Server.VersionDetail.Version)
})
t.Run("successful publish without auth (no prefix)", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-mcp-server-no-auth",
Description: "A test MCP server without authentication",
Repository: model.Repository{
URL: "https://example.com/test-mcp-server",
Source: "example",
ID: "test-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Server: model.ServerDetail{
Name: "test-mcp-server-no-auth",
Description: "A test MCP server without authentication",
Repository: model.Repository{
URL: "https://example.com/test-mcp-server",
Source: "example",
ID: "test-mcp-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
}
@ -142,19 +137,17 @@ func TestPublishIntegration(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
var response model.Server
var response model.ServerResponse
err = json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, response.Name)
assert.Equal(t, publishReq.Server.Name, response.Server.Name)
})
t.Run("publish fails with missing authorization header", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
},
Server: model.ServerDetail{
Name: "test-server",
},
}
@ -174,10 +167,8 @@ func TestPublishIntegration(t *testing.T) {
t.Run("publish fails with invalid token", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
},
Server: model.ServerDetail{
Name: "test-server",
},
}
@ -197,13 +188,11 @@ func TestPublishIntegration(t *testing.T) {
t.Run("publish fails when permission denied", func(t *testing.T) {
publishReq := model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.other/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Server: model.ServerDetail{
Name: "io.github.other/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
}

View file

@ -28,22 +28,25 @@ type MockRegistryService struct {
mock.Mock
}
func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) {
func (m *MockRegistryService) List(cursor string, limit int) ([]model.ServerResponse, string, error) {
args := m.Called(cursor, limit)
return args.Get(0).([]model.Server), args.String(1), args.Error(2)
return args.Get(0).([]model.ServerResponse), args.String(1), args.Error(2)
}
func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) {
func (m *MockRegistryService) GetByID(id string) (*model.ServerResponse, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.ServerDetail), args.Error(1)
return args.Get(0).(*model.ServerResponse), args.Error(1)
}
func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error {
args := m.Called(serverDetail)
return args.Error(0)
func (m *MockRegistryService) Publish(request model.PublishRequest) (*model.ServerResponse, error) {
args := m.Called(request)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.ServerResponse), args.Error(1)
}
// Helper function to generate a valid JWT token for testing
@ -77,21 +80,16 @@ func TestPublishEndpoint(t *testing.T) {
{
name: "successful publish with GitHub auth",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id",
Name: "io.github.example/test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
Server: model.ServerDetail{
Name: "io.github.example/test-server",
Description: "A test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
@ -103,28 +101,23 @@ func TestPublishEndpoint(t *testing.T) {
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(&model.ServerResponse{}, nil)
},
expectedStatus: http.StatusOK,
},
{
name: "successful publish with no auth (AuthMethodNone)",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
ID: "test-id-2",
Name: "example/test-server",
Description: "A test server without auth",
Repository: model.Repository{
URL: "https://example.com/test-server",
Source: "example",
ID: "example/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
Server: model.ServerDetail{
Name: "example/test-server",
Description: "A test server without auth",
Repository: model.Repository{
URL: "https://example.com/test-server",
Source: "example",
ID: "example/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
@ -135,7 +128,7 @@ func TestPublishEndpoint(t *testing.T) {
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(&model.ServerResponse{}, nil)
},
expectedStatus: http.StatusOK,
},
@ -158,13 +151,11 @@ func TestPublishEndpoint(t *testing.T) {
{
name: "invalid token",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Server: model.ServerDetail{
Name: "test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
@ -176,13 +167,11 @@ func TestPublishEndpoint(t *testing.T) {
{
name: "permission denied",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "io.github.other/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Server: model.ServerDetail{
Name: "io.github.other/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
@ -199,13 +188,11 @@ func TestPublishEndpoint(t *testing.T) {
{
name: "registry service error",
requestBody: model.PublishRequest{
ServerDetail: model.ServerDetail{
Server: model.Server{
Name: "example/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Server: model.ServerDetail{
Name: "example/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
},
@ -216,7 +203,7 @@ func TestPublishEndpoint(t *testing.T) {
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(errors.New("database error"))
registry.On("Publish", mock.AnythingOfType("model.PublishRequest")).Return(nil, errors.New("database error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Failed to publish server",

View file

@ -25,8 +25,8 @@ type ListServersInput struct {
// ListServersBody represents the paginated server list response body
type ListServersBody struct {
Servers []model.Server `json:"servers" doc:"List of MCP servers"`
Metadata *Metadata `json:"metadata,omitempty" doc:"Pagination metadata"`
Servers []model.ServerResponse `json:"servers" doc:"List of MCP servers with extensions"`
Metadata *Metadata `json:"metadata,omitempty" doc:"Pagination metadata"`
}
// ServerDetailInput represents the input for getting server details
@ -85,7 +85,7 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
Summary: "Get MCP server details",
Description: "Get detailed information about a specific MCP server",
Tags: []string{"servers"},
}, func(_ context.Context, input *ServerDetailInput) (*Response[model.ServerDetail], error) {
}, func(_ context.Context, input *ServerDetailInput) (*Response[model.ServerResponse], error) {
// Get the server details from the registry service
serverDetail, err := registry.GetByID(input.ID)
if err != nil {
@ -95,7 +95,7 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) {
return nil, huma.Error500InternalServerError("Failed to get server details", err)
}
return &Response[model.ServerDetail]{
return &Response[model.ServerResponse]{
Body: *serverDetail,
}, nil
})

View file

@ -23,16 +23,55 @@ func TestServersListEndpoint(t *testing.T) {
queryParams string
setupMocks func(*MockRegistryService)
expectedStatus int
expectedServers []model.Server
expectedServers []model.ServerResponse
expectedMeta *v0.Metadata
expectedError string
}{
{
name: "successful list with default parameters",
setupMocks: func(registry *MockRegistryService) {
servers := []model.Server{
servers := []model.ServerResponse{
{
ID: "550e8400-e29b-41d4-a716-446655440001",
Server: model.ServerDetail{
Name: "test-server-1",
Description: "First test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-1",
Source: "github",
ID: "example/test-server-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "550e8400-e29b-41d4-a716-446655440001",
},
},
{
Server: model.ServerDetail{
Name: "test-server-2",
Description: "Second test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-2",
Source: "github",
ID: "example/test-server-2",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "550e8400-e29b-41d4-a716-446655440002",
},
},
}
registry.Mock.On("List", "", 30).Return(servers, "", nil)
},
expectedStatus: http.StatusOK,
expectedServers: []model.ServerResponse{
{
Server: model.ServerDetail{
Name: "test-server-1",
Description: "First test server",
Repository: model.Repository{
@ -41,13 +80,15 @@ func TestServersListEndpoint(t *testing.T) {
ID: "example/test-server-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
Version: "1.0.0",
},
},
{
ID: "550e8400-e29b-41d4-a716-446655440002",
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "550e8400-e29b-41d4-a716-446655440001",
},
},
{
Server: model.ServerDetail{
Name: "test-server-2",
Description: "Second test server",
Repository: model.Repository{
@ -56,44 +97,11 @@ func TestServersListEndpoint(t *testing.T) {
ID: "example/test-server-2",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
ReleaseDate: "2025-05-26T00:00:00Z",
IsLatest: true,
Version: "2.0.0",
},
},
}
registry.Mock.On("List", "", 30).Return(servers, "", nil)
},
expectedStatus: http.StatusOK,
expectedServers: []model.Server{
{
ID: "550e8400-e29b-41d4-a716-446655440001",
Name: "test-server-1",
Description: "First test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-1",
Source: "github",
ID: "example/test-server-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-25T00:00:00Z",
IsLatest: true,
},
},
{
ID: "550e8400-e29b-41d4-a716-446655440002",
Name: "test-server-2",
Description: "Second test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-2",
Source: "github",
ID: "example/test-server-2",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
ReleaseDate: "2025-05-26T00:00:00Z",
IsLatest: true,
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "550e8400-e29b-41d4-a716-446655440002",
},
},
},
@ -102,9 +110,32 @@ func TestServersListEndpoint(t *testing.T) {
name: "successful list with cursor and limit",
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10",
setupMocks: func(registry *MockRegistryService) {
servers := []model.Server{
servers := []model.ServerResponse{
{
ID: "550e8400-e29b-41d4-a716-446655440003",
Server: model.ServerDetail{
Name: "test-server-3",
Description: "Third test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-3",
Source: "github",
ID: "example/test-server-3",
},
VersionDetail: model.VersionDetail{
Version: "1.5.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "550e8400-e29b-41d4-a716-446655440003",
},
},
}
nextCursor := uuid.New().String()
registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil)
},
expectedStatus: http.StatusOK,
expectedServers: []model.ServerResponse{
{
Server: model.ServerDetail{
Name: "test-server-3",
Description: "Third test server",
Repository: model.Repository{
@ -113,30 +144,11 @@ func TestServersListEndpoint(t *testing.T) {
ID: "example/test-server-3",
},
VersionDetail: model.VersionDetail{
Version: "1.5.0",
ReleaseDate: "2025-05-27T00:00:00Z",
IsLatest: true,
Version: "1.5.0",
},
},
}
nextCursor := uuid.New().String()
registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil)
},
expectedStatus: http.StatusOK,
expectedServers: []model.Server{
{
ID: "550e8400-e29b-41d4-a716-446655440003",
Name: "test-server-3",
Description: "Third test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-3",
Source: "github",
ID: "example/test-server-3",
},
VersionDetail: model.VersionDetail{
Version: "1.5.0",
ReleaseDate: "2025-05-27T00:00:00Z",
IsLatest: true,
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "550e8400-e29b-41d4-a716-446655440003",
},
},
},
@ -183,7 +195,7 @@ func TestServersListEndpoint(t *testing.T) {
{
name: "registry service error",
setupMocks: func(registry *MockRegistryService) {
registry.Mock.On("List", "", 30).Return([]model.Server{}, "", errors.New("database connection error"))
registry.Mock.On("List", "", 30).Return([]model.ServerResponse{}, "", errors.New("database connection error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Failed to get registry list",
@ -220,8 +232,8 @@ func TestServersListEndpoint(t *testing.T) {
// Parse response body
var resp struct {
Servers []model.Server `json:"servers"`
Metadata *v0.Metadata `json:"metadata,omitempty"`
Servers []model.ServerResponse `json:"servers"`
Metadata *v0.Metadata `json:"metadata,omitempty"`
}
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
@ -255,16 +267,15 @@ func TestServersDetailEndpoint(t *testing.T) {
serverID string
setupMocks func(*MockRegistryService, string)
expectedStatus int
expectedServer *model.ServerDetail
expectedServer *model.ServerResponse
expectedError string
}{
{
name: "successful get server detail",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
serverDetail := &model.ServerDetail{
Server: model.Server{
ID: serverID,
serverDetail := &model.ServerResponse{
Server: model.ServerDetail{
Name: "test-server-detail",
Description: "Test server detail",
Repository: model.Repository{
@ -273,11 +284,12 @@ func TestServersDetailEndpoint(t *testing.T) {
ID: "example/test-server-detail",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
ReleaseDate: "2025-05-27T12:00:00Z",
IsLatest: true,
Version: "2.0.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": serverID,
},
}
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
},
@ -339,13 +351,12 @@ func TestServersDetailEndpoint(t *testing.T) {
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Parse response body
var serverDetailResp model.ServerDetail
var serverDetailResp model.ServerResponse
err := json.NewDecoder(w.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check that we got a valid response
assert.NotEmpty(t, serverDetailResp.ID)
assert.NotEmpty(t, serverDetailResp.Name)
assert.NotEmpty(t, serverDetailResp.Server.Name)
} else if tc.expectedError != "" {
// Check error message for non-200 responses
assert.Contains(t, w.Body.String(), tc.expectedError)
@ -364,26 +375,29 @@ func TestServersEndpointsIntegration(t *testing.T) {
// Test data
serverID := uuid.New().String()
servers := []model.Server{
servers := []model.ServerResponse{
{
ID: serverID,
Name: "integration-test-server",
Description: "Integration test server",
Repository: model.Repository{
URL: "https://github.com/example/integration-test",
Source: "github",
ID: "example/integration-test",
Server: model.ServerDetail{
Name: "integration-test-server",
Description: "Integration test server",
Repository: model.Repository{
URL: "https://github.com/example/integration-test",
Source: "github",
ID: "example/integration-test",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2025-05-27T00:00:00Z",
IsLatest: true,
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": serverID,
},
},
}
serverDetail := &model.ServerDetail{
Server: servers[0],
serverDetail := &model.ServerResponse{
Server: servers[0].Server,
XIOModelContextProtocolRegistry: servers[0].XIOModelContextProtocolRegistry,
}
// Setup mocks
@ -424,7 +438,7 @@ func TestServersEndpointsIntegration(t *testing.T) {
// Parse response body
var listResp struct {
Servers []model.Server `json:"servers"`
Servers []model.ServerResponse `json:"servers"`
}
err = json.NewDecoder(resp.Body).Decode(&listResp)
assert.NoError(t, err)
@ -455,7 +469,7 @@ func TestServersEndpointsIntegration(t *testing.T) {
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
// Parse response body
var serverDetailResp model.ServerDetail
var serverDetailResp model.ServerResponse
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
@ -465,4 +479,4 @@ func TestServersEndpointsIntegration(t *testing.T) {
// Verify mock expectations
mockRegistry.AssertExpectations(t)
}
}

View file

@ -19,9 +19,8 @@ import (
)
func mockServerEndpoint(registry *MockRegistryService, serverID string) {
serverDetail := &model.ServerDetail{
Server: model.Server{
ID: serverID,
serverDetail := &model.ServerResponse{
Server: model.ServerDetail{
Name: "test-server-detail",
Description: "Test server detail",
Repository: model.Repository{
@ -30,11 +29,12 @@ func mockServerEndpoint(registry *MockRegistryService, serverID string) {
ID: "example/test-server-detail",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
ReleaseDate: "2025-05-27T12:00:00Z",
IsLatest: true,
Version: "2.0.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": serverID,
},
}
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
}

View file

@ -16,14 +16,14 @@ var (
ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version")
)
// Database defines the interface for database operations on MCPRegistry entries
// Database defines the interface for database operations with extension wrapper architecture
type Database interface {
// List retrieves all MCPRegistry entries with optional filtering
List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*model.Server, string, error)
// GetByID retrieves a single ServerDetail by it's ID
GetByID(ctx context.Context, id string) (*model.ServerDetail, error)
// Publish adds a new ServerDetail to the database
Publish(ctx context.Context, serverDetail *model.ServerDetail) error
// List retrieves all ServerRecord entries with optional filtering
List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*model.ServerRecord, string, error)
// GetByID retrieves a single ServerRecord by its ID
GetByID(ctx context.Context, id string) (*model.ServerRecord, error)
// Publish adds a new server to the database with separated server.json and extensions
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error)
// ImportSeed imports initial data from a seed file
ImportSeed(ctx context.Context, seedFilePath string) error
// Close closes the database connection

View file

@ -5,11 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
@ -17,255 +14,165 @@ import (
)
// ReadSeedFile reads seed data from various sources:
// 1. Local file paths (*.json files)
// 2. Direct HTTP URLs to seed.json files
// 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)
func ReadSeedFile(ctx context.Context, path string) ([]model.ServerDetail, error) {
log.Printf("Reading seed data from %s", path)
// 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
// Set default seed file path if not provided
if path == "" {
// Try to find the seed.json in the data directory
path = filepath.Join("data", "seed.json")
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("seed file not found at %s", path)
}
}
// Check if path is an HTTP URL
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
// Determine if this is a direct seed file URL or a registry root URL
if strings.HasSuffix(path, ".json") || strings.Contains(path, "seed.json") {
// Direct seed file URL - read directly
fileContent, err := readFromHTTP(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to read from HTTP URL: %w", err)
}
return parseSeedJSON(fileContent)
// 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)
}
// Registry root URL - paginate through /v0/servers endpoint
return readFromRegistryWithContext(ctx, path)
// This is a direct file URL
data, err = fetchFromHTTP(ctx, path)
} else {
// Handle local file paths
data, err = os.ReadFile(path)
}
// Read from local file
fileContent, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
return nil, fmt.Errorf("failed to read seed data from %s: %w", path, err)
}
return parseSeedJSON(fileContent)
// 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
}
// readFromHTTP reads content from an HTTP URL with timeout
func readFromHTTP(ctx context.Context, url string) ([]byte, error) {
client := &http.Client{
Timeout: 30 * time.Second,
}
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 request: %w", err)
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch URL: %w", err)
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: %s", resp.StatusCode, resp.Status)
return nil, fmt.Errorf("HTTP request failed with status: %d", resp.StatusCode)
}
// Read the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return body, nil
return io.ReadAll(resp.Body)
}
// parseSeedJSON parses JSON content into ServerDetail objects
func parseSeedJSON(fileContent []byte) ([]model.ServerDetail, error) {
var servers []model.ServerDetail
if err := json.Unmarshal(fileContent, &servers); err != nil {
// Try parsing as a raw JSON array and then convert to our model
var rawData []map[string]any
if jsonErr := json.Unmarshal(fileContent, &rawData); jsonErr != nil {
return nil, fmt.Errorf("failed to parse JSON: %w (original error: %w)", jsonErr, err)
}
}
log.Printf("Found %d server entries in seed data", len(servers))
return servers, nil
}
// PaginatedResponse represents the paginated response from /v0/servers endpoint
// PaginatedResponse represents the structure of a paginated response from /v0/servers endpoint
type PaginatedResponse struct {
Data []model.Server `json:"servers"`
Metadata Metadata `json:"metadata,omitempty"`
}
// Metadata contains pagination metadata
type Metadata struct {
NextCursor string `json:"next_cursor,omitempty"`
Count int `json:"count,omitempty"`
Total int `json:"total,omitempty"`
}
// readFromRegistryWithContext reads all servers from a registry by paginating through /v0/servers endpoint
func readFromRegistryWithContext(ctx context.Context, registryURL string) ([]model.ServerDetail, error) {
log.Printf("Reading from registry: %s", registryURL)
// Ensure the URL doesn't have a trailing slash
registryURL = strings.TrimSuffix(registryURL, "/")
var allServers []model.ServerDetail
func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*model.ServerRecord, error) {
var allRecords []*model.ServerRecord
cursor := ""
pageCount := 0
client := &http.Client{
Timeout: 30 * time.Second,
}
for {
pageCount++
// Add delay between requests as requested (10 seconds by default)
// Can be overridden by SEED_IMPORT_DELAY environment variable for testing
if pageCount > 1 { // Don't delay before the first request
delay := 10 * time.Second
if delayStr := os.Getenv("SEED_IMPORT_DELAY"); delayStr != "" {
if parsedDelay, err := time.ParseDuration(delayStr); err == nil {
delay = parsedDelay
}
}
if delay > 0 {
log.Printf("Waiting %v before fetching page %d...", delay, pageCount)
time.Sleep(delay)
}
}
log.Printf("Fetching page %d from registry", pageCount)
// Build the URL for this page
serverURL := registryURL + "/v0/servers"
url := baseURL
if cursor != "" {
// Add cursor parameter for pagination
parsed, err := url.Parse(serverURL)
if err != nil {
return nil, fmt.Errorf("failed to parse registry URL: %w", err)
if strings.Contains(url, "?") {
url += "&cursor=" + cursor
} else {
url += "?cursor=" + cursor
}
query := parsed.Query()
query.Set("cursor", cursor)
query.Set("limit", "100") // Use maximum limit for efficiency
parsed.RawQuery = query.Encode()
serverURL = parsed.String()
} else {
// First page - use max limit
serverURL += "?limit=100"
}
// Fetch the page
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil)
data, err := fetchFromHTTP(ctx, url)
if err != nil {
return nil, fmt.Errorf("failed to create request for %s: %w", serverURL, err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch servers from %s: %w", serverURL, err)
return nil, fmt.Errorf("failed to fetch page from registry API: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("HTTP request to %s failed with status %d: %s", serverURL, resp.StatusCode, resp.Status)
var response struct {
Servers []model.ServerResponse `json:"servers"`
Metadata *struct {
NextCursor string `json:"next_cursor,omitempty"`
} `json:"metadata,omitempty"`
}
// Read and parse the response
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("failed to read response body from %s: %w", serverURL, err)
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to parse registry API response: %w", err)
}
var pageResponse PaginatedResponse
if err := json.Unmarshal(body, &pageResponse); err != nil {
return nil, fmt.Errorf("failed to parse servers response from %s: %w", serverURL, err)
// Convert and add servers
for _, serverResponse := range response.Servers {
record := convertServerResponseToRecord(serverResponse)
allRecords = append(allRecords, record)
}
log.Printf("Retrieved %d servers from page %d", len(pageResponse.Data), pageCount)
// For each server in this page, get the detailed information
for _, server := range pageResponse.Data {
// Build URL for server detail
detailURL := registryURL + "/v0/servers/" + server.ID
detailReq, err := http.NewRequestWithContext(ctx, http.MethodGet, detailURL, nil)
if err != nil {
log.Printf("Warning: failed to create request for server %s: %v", server.ID, err)
// Fall back to basic server information
serverDetail := model.ServerDetail{
Server: server,
}
allServers = append(allServers, serverDetail)
continue
}
detailResp, err := client.Do(detailReq)
if err != nil {
log.Printf("Warning: failed to fetch details for server %s: %v", server.ID, err)
// Fall back to basic server information
serverDetail := model.ServerDetail{
Server: server,
}
allServers = append(allServers, serverDetail)
continue
}
if detailResp.StatusCode != http.StatusOK {
log.Printf("Warning: failed to fetch details for server %s (status %d)", server.ID, detailResp.StatusCode)
detailResp.Body.Close()
// Fall back to basic server information
serverDetail := model.ServerDetail{
Server: server,
}
allServers = append(allServers, serverDetail)
continue
}
detailBody, err := io.ReadAll(detailResp.Body)
detailResp.Body.Close()
if err != nil {
log.Printf("Warning: failed to read detail response for server %s: %v", server.ID, err)
// Fall back to basic server information
serverDetail := model.ServerDetail{
Server: server,
}
allServers = append(allServers, serverDetail)
continue
}
var serverDetail model.ServerDetail
if err := json.Unmarshal(detailBody, &serverDetail); err != nil {
log.Printf("Warning: failed to parse detail response for server %s: %v", server.ID, err)
// Fall back to basic server information
serverDetail = model.ServerDetail{
Server: server,
}
}
allServers = append(allServers, serverDetail)
}
// Check if there are more pages
if pageResponse.Metadata.NextCursor == "" {
log.Printf("Reached end of pagination after %d pages", pageCount)
// Check if there's a next page
if response.Metadata == nil || response.Metadata.NextCursor == "" {
break
}
cursor = pageResponse.Metadata.NextCursor
cursor = response.Metadata.NextCursor
}
log.Printf("Successfully retrieved %d servers from registry %s", len(allServers), registryURL)
return allServers, nil
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
}

View file

@ -14,12 +14,11 @@ import (
)
func TestReadSeedFile_LocalFile(t *testing.T) {
// Create a temporary seed file
// Create a temporary seed file in extension wrapper format
tempFile := "/tmp/test_seed.json"
seedData := []model.ServerDetail{
seedData := []model.ServerResponse{
{
Server: model.Server{
ID: "test-id-1",
Server: model.ServerDetail{
Name: "test-server-1",
Description: "Test server 1",
Repository: model.Repository{
@ -28,11 +27,14 @@ func TestReadSeedFile_LocalFile(t *testing.T) {
ID: "123",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: "2023-01-01T00:00:00Z",
IsLatest: true,
Version: "1.0.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "test-id-1",
"published_at": "2023-01-01T00:00:00Z",
"is_latest": true,
},
},
}
@ -56,18 +58,22 @@ func TestReadSeedFile_LocalFile(t *testing.T) {
result, err := database.ReadSeedFile(context.Background(), tempFile)
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "test-server-1", result[0].Name)
assert.Equal(t, "test-server-1", result[0].ServerJSON.Name)
}
func TestReadSeedFile_DirectHTTPURL(t *testing.T) {
// Create a test HTTP server that serves seed JSON directly
seedData := []model.ServerDetail{
// Create a test HTTP server that serves seed JSON directly in extension wrapper format
seedData := []model.ServerResponse{
{
Server: model.Server{
ID: "test-id-1",
Server: model.ServerDetail{
Name: "test-server-1",
Description: "Test server 1",
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "test-id-1",
"published_at": "2023-01-01T00:00:00Z",
"is_latest": true,
},
},
}
@ -83,35 +89,51 @@ func TestReadSeedFile_DirectHTTPURL(t *testing.T) {
result, err := database.ReadSeedFile(context.Background(), server.URL+"/seed.json")
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "test-server-1", result[0].Name)
assert.Equal(t, "test-server-1", result[0].ServerJSON.Name)
}
func TestReadSeedFile_RegistryURL(t *testing.T) {
// Create mock registry servers
server1 := model.Server{
ID: "server-1",
Name: "Test Server 1",
Description: "First test server",
// Create mock registry responses
server1 := model.ServerResponse{
Server: model.ServerDetail{
Name: "Test Server 1",
Description: "First test server",
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "server-1",
"published_at": "2023-01-01T00:00:00Z",
"is_latest": true,
},
}
server2 := model.Server{
ID: "server-2",
Name: "Test Server 2",
Description: "Second test server",
server2 := model.ServerResponse{
Server: model.ServerDetail{
Name: "Test Server 2",
Description: "Second test server",
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "server-2",
"published_at": "2023-01-01T00:00:00Z",
"is_latest": true,
},
}
serverDetail1 := model.ServerDetail{
Server: server1,
Name: "Test Server 1",
Description: "First test server",
Packages: []model.Package{
{
RegistryName: "npm",
Name: "test-package-1",
Version: "1.0.0",
},
},
}
serverDetail2 := model.ServerDetail{
Server: server2,
Name: "Test Server 2",
Description: "Second test server",
Packages: []model.Package{
{
RegistryName: "npm",
Name: "test-package-2",
Version: "2.0.0",
},
@ -125,22 +147,32 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
mux.HandleFunc("/v0/servers", func(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
var response database.PaginatedResponse
type Metadata struct {
NextCursor string `json:"next_cursor,omitempty"`
Count int `json:"count,omitempty"`
}
type PaginatedResponse struct {
Servers []model.ServerResponse `json:"servers"`
Metadata *Metadata `json:"metadata,omitempty"`
}
var response PaginatedResponse
switch cursor {
case "":
// First page
response = database.PaginatedResponse{
Data: []model.Server{server1},
Metadata: database.Metadata{
response = PaginatedResponse{
Servers: []model.ServerResponse{server1},
Metadata: &Metadata{
NextCursor: "next-cursor-1",
Count: 1,
},
}
case "next-cursor-1":
// Second page
response = database.PaginatedResponse{
Data: []model.Server{server2},
Metadata: database.Metadata{
response = PaginatedResponse{
Servers: []model.ServerResponse{server2},
Metadata: &Metadata{
Count: 1,
// No NextCursor means end of pagination
},
@ -172,17 +204,15 @@ func TestReadSeedFile_RegistryURL(t *testing.T) {
defer server.Close()
// Test reading from registry root URL (this should trigger pagination)
result, err := database.ReadSeedFile(context.Background(), server.URL)
result, err := database.ReadSeedFile(context.Background(), server.URL+"/v0/servers")
assert.NoError(t, err)
assert.Len(t, result, 2)
// Verify the servers were imported correctly
assert.Equal(t, "Test Server 1", result[0].Name)
assert.Equal(t, "Test Server 2", result[1].Name)
assert.Equal(t, "Test Server 1", result[0].ServerJSON.Name)
assert.Equal(t, "Test Server 2", result[1].ServerJSON.Name)
// Verify packages were included
assert.Len(t, result[0].Packages, 1)
assert.Equal(t, "test-package-1", result[0].Packages[0].Name)
assert.Len(t, result[1].Packages, 1)
assert.Equal(t, "test-package-2", result[1].Packages[0].Name)
}
// Verify metadata was extracted
assert.Equal(t, "server-1", result[0].RegistryMetadata.ID)
assert.Equal(t, "server-2", result[1].RegistryMetadata.ID)
}

View file

@ -3,7 +3,6 @@ package database
import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
@ -16,21 +15,32 @@ import (
// MemoryDB is an in-memory implementation of the Database interface
type MemoryDB struct {
entries map[string]*model.ServerDetail
entries map[string]*model.ServerRecord // maps registry metadata ID to ServerRecord
mu sync.RWMutex
}
// NewMemoryDB creates a new instance of the in-memory database
func NewMemoryDB(e map[string]*model.Server) *MemoryDB {
// Convert Server entries to ServerDetail entries
serverDetails := make(map[string]*model.ServerDetail)
for k, v := range e {
serverDetails[k] = &model.ServerDetail{
Server: *v,
func NewMemoryDB(e map[string]*model.ServerDetail) *MemoryDB {
// Convert ServerDetail entries to ServerRecord entries
serverRecords := make(map[string]*model.ServerRecord)
for registryID, serverDetail := range e {
// Create registry metadata
now := time.Now()
record := &model.ServerRecord{
ServerJSON: *serverDetail,
RegistryMetadata: model.RegistryMetadata{
ID: registryID,
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
},
PublisherExtensions: make(map[string]interface{}),
}
serverRecords[registryID] = record
}
return &MemoryDB{
entries: serverDetails,
entries: serverRecords,
}
}
@ -82,15 +92,13 @@ func compareSemanticVersions(version1, version2 string) int {
return 0
}
// List retrieves all MCPRegistry entries with optional filtering and pagination
//
//gocognit:ignore
// List retrieves ServerRecord entries with optional filtering and pagination
func (db *MemoryDB) List(
ctx context.Context,
filter map[string]any,
cursor string,
limit int,
) ([]*model.Server, string, error) {
) ([]*model.ServerRecord, string, error) {
if ctx.Err() != nil {
return nil, "", ctx.Err()
}
@ -101,16 +109,18 @@ func (db *MemoryDB) List(
db.mu.RLock()
defer db.mu.RUnlock()
// Convert all entries to a slice for pagination
var allEntries []*model.Server
// Convert all entries to a slice for pagination, filter by is_latest
var allEntries []*model.ServerRecord
for _, entry := range db.entries {
serverCopy := entry.Server
allEntries = append(allEntries, &serverCopy)
if entry.RegistryMetadata.IsLatest {
allEntries = append(allEntries, entry)
}
}
// Simple filtering implementation
var filteredEntries []*model.Server
var filteredEntries []*model.ServerRecord
for _, entry := range allEntries {
include := true
@ -118,22 +128,17 @@ func (db *MemoryDB) List(
for key, value := range filter {
switch key {
case "name":
if entry.Name != value.(string) {
include = false
}
case "repoUrl":
if entry.Repository.URL != value.(string) {
include = false
}
case "serverDetail.id":
if entry.ID != value.(string) {
if entry.ServerJSON.Name != value.(string) {
include = false
}
case "version":
if entry.VersionDetail.Version != value.(string) {
if entry.ServerJSON.VersionDetail.Version != value.(string) {
include = false
}
case "status":
if string(entry.ServerJSON.Status) != value.(string) {
include = false
}
// Add more filter options as needed
}
}
@ -142,43 +147,43 @@ func (db *MemoryDB) List(
}
}
// Find starting point for cursor-based pagination
// Sort filteredEntries by registry metadata ID for consistent pagination
sort.Slice(filteredEntries, func(i, j int) bool {
return filteredEntries[i].RegistryMetadata.ID < filteredEntries[j].RegistryMetadata.ID
})
// Find starting point for cursor-based pagination using registry metadata ID
startIdx := 0
if cursor != "" {
for i, entry := range filteredEntries {
if entry.ID == cursor {
if entry.RegistryMetadata.ID == cursor {
startIdx = i + 1 // Start after the cursor
break
}
}
}
// Sort filteredEntries by ID for consistent pagination
sort.Slice(filteredEntries, func(i, j int) bool {
return filteredEntries[i].ID < filteredEntries[j].ID
})
// Apply pagination
endIdx := min(startIdx+limit, len(filteredEntries))
var result []*model.Server
var result []*model.ServerRecord
if startIdx < len(filteredEntries) {
result = filteredEntries[startIdx:endIdx]
} else {
result = []*model.Server{}
result = []*model.ServerRecord{}
}
// Determine next cursor
// Determine next cursor using registry metadata ID
nextCursor := ""
if endIdx < len(filteredEntries) {
nextCursor = filteredEntries[endIdx-1].ID
nextCursor = filteredEntries[endIdx-1].RegistryMetadata.ID
}
return result, nextCursor, nil
}
// GetByID retrieves a single ServerDetail by its ID
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) {
// GetByID retrieves a single ServerRecord by its registry metadata ID
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -186,66 +191,84 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail
db.mu.RLock()
defer db.mu.RUnlock()
// Find entry by registry metadata ID
if entry, exists := db.entries[id]; exists {
// Return a copy of the ServerDetail
serverDetailCopy := *entry
return &serverDetailCopy, nil
// Return a copy of the ServerRecord
entryCopy := *entry
return &entryCopy, nil
}
return nil, ErrNotFound
}
// Publish adds a new ServerDetail to the database
func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetail) error {
// Publish adds a new server to the database with separated server.json and extensions
func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
if ctx.Err() != nil {
return ctx.Err()
return nil, ctx.Err()
}
// Extract name and version for validation
name := serverDetail.Name
if name == "" {
return nil, fmt.Errorf("name is required in server JSON")
}
version := serverDetail.VersionDetail.Version
if version == "" {
return nil, fmt.Errorf("version is required in version_detail")
}
db.mu.Lock()
defer db.mu.Unlock()
// check for name
if serverDetail.Name == "" {
return ErrInvalidInput
}
// check that the name and the version are unique
// Also check version ordering - don't allow publishing older versions after newer ones
var latestVersion string
// Check for existing entry with same name and compare versions
var existingRecord *model.ServerRecord
for _, entry := range db.entries {
if entry.Name == serverDetail.Name {
if entry.VersionDetail.Version == serverDetail.VersionDetail.Version {
return ErrAlreadyExists
}
// Track the latest version for this package name
if latestVersion == "" || compareSemanticVersions(entry.VersionDetail.Version, latestVersion) > 0 {
latestVersion = entry.VersionDetail.Version
}
if entry.RegistryMetadata.IsLatest && entry.ServerJSON.Name == name {
existingRecord = entry
break
}
}
// If we found existing versions, check if the new version is older than the latest
if latestVersion != "" && compareSemanticVersions(serverDetail.VersionDetail.Version, latestVersion) < 0 {
return ErrInvalidVersion
// Version comparison
if existingRecord != nil {
existingVersion := existingRecord.ServerJSON.VersionDetail.Version
if compareSemanticVersions(version, existingVersion) <= 0 {
return nil, fmt.Errorf("version must be greater than existing version %s", existingVersion)
}
}
// Validate repository URL
if serverDetail.Repository.URL == "" {
return ErrInvalidInput
return nil, ErrInvalidInput
}
// Always generate a new UUID for the ID
serverDetail.ID = uuid.New().String()
serverDetail.VersionDetail.IsLatest = true // Assume the new version is the latest
// Only set ReleaseDate if it's not already provided
if serverDetail.VersionDetail.ReleaseDate == "" {
serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
// Create new registry metadata
now := time.Now()
registryMetadata := model.RegistryMetadata{
ID: uuid.New().String(),
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
}
// Store a copy of the entire ServerDetail
serverDetailCopy := *serverDetail
db.entries[serverDetail.ID] = &serverDetailCopy
return nil
// Create server record
record := &model.ServerRecord{
ServerJSON: serverDetail,
RegistryMetadata: registryMetadata,
PublisherExtensions: publisherExtensions,
}
// Mark existing record as not latest
if existingRecord != nil {
existingRecord.RegistryMetadata.IsLatest = false
}
// Store the record using registry metadata ID
db.entries[registryMetadata.ID] = record
return record, nil
}
// ImportSeed imports initial data from a seed file into memory database
@ -253,39 +276,25 @@ func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error {
if ctx.Err() != nil {
return ctx.Err()
}
// Read the seed file
// This will need to be updated to work with the new ServerRecord format
// Read seed data using the shared ReadSeedFile function
seedData, err := ReadSeedFile(ctx, seedFilePath)
if err != nil {
return fmt.Errorf("failed to read seed file: %w", err)
}
log.Printf("Importing %d servers into memory database", len(seedData))
// Lock for concurrent access
db.mu.Lock()
defer db.mu.Unlock()
for i, server := range seedData {
if server.ID == "" || server.Name == "" {
log.Printf("Skipping server %d: ID or Name is empty", i+1)
continue
}
// Set default version information if missing
if server.VersionDetail.Version == "" {
server.VersionDetail.Version = "0.0.1-seed"
server.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
server.VersionDetail.IsLatest = true
}
// Store a copy of the server detail
serverDetailCopy := server
db.entries[server.ID] = &serverDetailCopy
log.Printf("[%d/%d] Imported server: %s", i+1, len(seedData), server.Name)
// Import each server
for _, record := range seedData {
// Use the registry metadata ID as the map key
db.entries[record.RegistryMetadata.ID] = record
}
log.Println("Memory database import completed successfully")
return nil
}
@ -302,4 +311,4 @@ func (db *MemoryDB) Connection() *ConnectionInfo {
IsConnected: true, // Memory DB is always connected
Raw: db.entries,
}
}
}

View file

@ -0,0 +1,62 @@
-- Add server_extensions table for registry metadata and publisher extensions
-- This migration implements the extension wrapper architecture
-- Create server_extensions table for registry/publisher metadata
CREATE TABLE server_extensions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
-- Registry metadata as structured columns
published_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
is_latest BOOLEAN NOT NULL DEFAULT true,
release_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Publisher extensions as flexible JSONB
publisher_extensions JSONB DEFAULT '{}'::jsonb,
-- Ensure one extension record per server
UNIQUE(server_id)
);
-- Indexes for performance
CREATE UNIQUE INDEX idx_server_extensions_server_id ON server_extensions(server_id);
CREATE INDEX idx_server_extensions_latest ON server_extensions(is_latest) WHERE is_latest = true;
CREATE INDEX idx_server_extensions_published_at ON server_extensions(published_at);
-- GIN index for publisher extensions JSONB field
CREATE INDEX idx_server_extensions_publisher_gin ON server_extensions USING GIN(publisher_extensions);
-- Migrate existing data from servers table to server_extensions table
INSERT INTO server_extensions (server_id, published_at, updated_at, is_latest, release_date, publisher_extensions)
SELECT
id,
created_at, -- Use created_at as published_at
updated_at,
is_latest,
release_date,
'{}'::jsonb -- Empty publisher extensions
FROM servers;
-- Remove registry metadata columns from servers table (they now live in server_extensions)
ALTER TABLE servers DROP COLUMN release_date;
ALTER TABLE servers DROP COLUMN is_latest;
-- Update trigger to also update server_extensions updated_at when servers are updated
CREATE OR REPLACE FUNCTION update_server_extensions_updated_at()
RETURNS TRIGGER AS $$
BEGIN
-- Update the corresponding server_extensions record
UPDATE server_extensions
SET updated_at = NOW()
WHERE server_id = NEW.id;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Trigger to automatically update server_extensions updated_at when servers change
CREATE TRIGGER update_server_extensions_on_server_update
AFTER UPDATE ON servers
FOR EACH ROW
EXECUTE FUNCTION update_server_extensions_updated_at();

View file

@ -41,13 +41,13 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro
}, nil
}
// List retrieves MCPRegistry entries with optional filtering and pagination
// List retrieves ServerRecord entries with optional filtering and pagination
func (db *PostgreSQL) List(
ctx context.Context,
filter map[string]any,
cursor string,
limit int,
) ([]*model.Server, string, error) {
) ([]*model.ServerRecord, string, error) {
if limit <= 0 {
limit = 10
}
@ -56,8 +56,8 @@ func (db *PostgreSQL) List(
return nil, "", ctx.Err()
}
// Build WHERE clause
whereClause := "WHERE is_latest = true"
// Build WHERE clause for server_extensions filtering
whereClause := "WHERE se.is_latest = true"
args := []any{}
argIndex := 1
@ -65,162 +65,225 @@ func (db *PostgreSQL) List(
for k, v := range filter {
switch k {
case "name":
whereClause += fmt.Sprintf(" AND name = $%d", argIndex)
whereClause += fmt.Sprintf(" AND s.name = $%d", argIndex)
args = append(args, v)
argIndex++
case "version":
whereClause += fmt.Sprintf(" AND version = $%d", argIndex)
whereClause += fmt.Sprintf(" AND s.version = $%d", argIndex)
args = append(args, v)
argIndex++
case "status":
whereClause += fmt.Sprintf(" AND status = $%d", argIndex)
whereClause += fmt.Sprintf(" AND s.status = $%d", argIndex)
args = append(args, v)
argIndex++
}
}
// Add cursor pagination
// Add cursor pagination using registry metadata ID
if cursor != "" {
if _, err := uuid.Parse(cursor); err != nil {
return nil, "", fmt.Errorf("invalid cursor format: %w", err)
}
whereClause += fmt.Sprintf(" AND id > $%d", argIndex)
whereClause += fmt.Sprintf(" AND se.id > $%d", argIndex)
args = append(args, cursor)
argIndex++
}
// Build query
// Build JOIN query between servers and server_extensions
query := fmt.Sprintf(`
SELECT id, name, description, status, repository, version, release_date, is_latest
FROM servers
SELECT
s.name, s.description, s.status, s.repository, s.version, s.packages, s.remotes,
se.id, se.published_at, se.updated_at, se.is_latest, se.release_date, se.publisher_extensions
FROM servers s
JOIN server_extensions se ON s.id = se.server_id
%s
ORDER BY id
ORDER BY se.id
LIMIT $%d
`, whereClause, argIndex)
args = append(args, limit)
rows, err := db.conn.Query(ctx, query, args...)
if err != nil {
return nil, "", fmt.Errorf("failed to query servers: %w", err)
return nil, "", fmt.Errorf("failed to query servers with extensions: %w", err)
}
defer rows.Close()
var results []*model.Server
var results []*model.ServerRecord
for rows.Next() {
var server model.Server
var repositoryJSON []byte
var releaseDate time.Time
var record model.ServerRecord
var repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte
var publishedAt, updatedAt, releaseDate time.Time
err := rows.Scan(
&server.ID,
&server.Name,
&server.Description,
&server.Status,
// Server fields
&record.ServerJSON.Name,
&record.ServerJSON.Description,
&record.ServerJSON.Status,
&repositoryJSON,
&server.VersionDetail.Version,
&record.ServerJSON.VersionDetail.Version,
&packagesJSON,
&remotesJSON,
// Registry metadata fields
&record.RegistryMetadata.ID,
&publishedAt,
&updatedAt,
&record.RegistryMetadata.IsLatest,
&releaseDate,
&server.VersionDetail.IsLatest,
&publisherExtensionsJSON,
)
if err != nil {
return nil, "", fmt.Errorf("failed to scan server row: %w", err)
return nil, "", fmt.Errorf("failed to scan server record row: %w", err)
}
// Parse repository JSON
if len(repositoryJSON) > 0 {
if err := json.Unmarshal(repositoryJSON, &server.Repository); err != nil {
return nil, "", fmt.Errorf("failed to unmarshal repository: %w", err)
}
// Parse JSON fields
if err := parseJSONFields(&record, repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON); err != nil {
return nil, "", err
}
server.VersionDetail.ReleaseDate = releaseDate.Format(time.RFC3339)
results = append(results, &server)
// Set registry metadata timestamps
record.RegistryMetadata.PublishedAt = publishedAt
record.RegistryMetadata.UpdatedAt = updatedAt
record.RegistryMetadata.ReleaseDate = releaseDate.Format(time.RFC3339)
results = append(results, &record)
}
if err := rows.Err(); err != nil {
return nil, "", fmt.Errorf("error iterating rows: %w", err)
}
// Determine next cursor
// Determine next cursor using registry metadata ID
nextCursor := ""
if len(results) > 0 && len(results) >= limit {
nextCursor = results[len(results)-1].ID
nextCursor = results[len(results)-1].RegistryMetadata.ID
}
return results, nextCursor, nil
}
// GetByID retrieves a single ServerDetail by its ID
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) {
// parseJSONFields parses JSON fields for a server record
func parseJSONFields(record *model.ServerRecord, repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte) error {
if len(repositoryJSON) > 0 {
if err := json.Unmarshal(repositoryJSON, &record.ServerJSON.Repository); err != nil {
return fmt.Errorf("failed to unmarshal repository: %w", err)
}
}
if len(packagesJSON) > 0 {
if err := json.Unmarshal(packagesJSON, &record.ServerJSON.Packages); err != nil {
return fmt.Errorf("failed to unmarshal packages: %w", err)
}
}
if len(remotesJSON) > 0 {
if err := json.Unmarshal(remotesJSON, &record.ServerJSON.Remotes); err != nil {
return fmt.Errorf("failed to unmarshal remotes: %w", err)
}
}
if len(publisherExtensionsJSON) > 0 {
if err := json.Unmarshal(publisherExtensionsJSON, &record.PublisherExtensions); err != nil {
return fmt.Errorf("failed to unmarshal publisher extensions: %w", err)
}
} else {
record.PublisherExtensions = make(map[string]interface{})
}
return nil
}
// GetByID retrieves a single ServerRecord by its registry metadata ID
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
query := `
SELECT id, name, description, status, repository, version, release_date, is_latest, packages, remotes
FROM servers
WHERE id = $1
SELECT
s.name, s.description, s.status, s.repository, s.version, s.packages, s.remotes,
se.id, se.published_at, se.updated_at, se.is_latest, se.release_date, se.publisher_extensions
FROM servers s
JOIN server_extensions se ON s.id = se.server_id
WHERE se.id = $1
`
var serverDetail model.ServerDetail
var repositoryJSON, packagesJSON, remotesJSON []byte
var releaseDate time.Time
var record model.ServerRecord
var repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte
var publishedAt, updatedAt, releaseDate time.Time
err := db.conn.QueryRow(ctx, query, id).Scan(
&serverDetail.ID,
&serverDetail.Name,
&serverDetail.Description,
&serverDetail.Status,
// Server fields
&record.ServerJSON.Name,
&record.ServerJSON.Description,
&record.ServerJSON.Status,
&repositoryJSON,
&serverDetail.VersionDetail.Version,
&releaseDate,
&serverDetail.VersionDetail.IsLatest,
&record.ServerJSON.VersionDetail.Version,
&packagesJSON,
&remotesJSON,
// Registry metadata fields
&record.RegistryMetadata.ID,
&publishedAt,
&updatedAt,
&record.RegistryMetadata.IsLatest,
&releaseDate,
&publisherExtensionsJSON,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("failed to get server by ID: %w", err)
return nil, fmt.Errorf("failed to get server record by ID: %w", err)
}
// Parse JSON fields
if len(repositoryJSON) > 0 {
if err := json.Unmarshal(repositoryJSON, &serverDetail.Repository); err != nil {
if err := json.Unmarshal(repositoryJSON, &record.ServerJSON.Repository); err != nil {
return nil, fmt.Errorf("failed to unmarshal repository: %w", err)
}
}
if len(packagesJSON) > 0 {
if err := json.Unmarshal(packagesJSON, &serverDetail.Packages); err != nil {
if err := json.Unmarshal(packagesJSON, &record.ServerJSON.Packages); err != nil {
return nil, fmt.Errorf("failed to unmarshal packages: %w", err)
}
}
if len(remotesJSON) > 0 {
if err := json.Unmarshal(remotesJSON, &serverDetail.Remotes); err != nil {
if err := json.Unmarshal(remotesJSON, &record.ServerJSON.Remotes); err != nil {
return nil, fmt.Errorf("failed to unmarshal remotes: %w", err)
}
}
serverDetail.VersionDetail.ReleaseDate = releaseDate.Format(time.RFC3339)
// Parse publisher extensions
if len(publisherExtensionsJSON) > 0 {
if err := json.Unmarshal(publisherExtensionsJSON, &record.PublisherExtensions); err != nil {
return nil, fmt.Errorf("failed to unmarshal publisher extensions: %w", err)
}
} else {
record.PublisherExtensions = make(map[string]interface{})
}
return &serverDetail, nil
// Set registry metadata timestamps
record.RegistryMetadata.PublishedAt = publishedAt
record.RegistryMetadata.UpdatedAt = updatedAt
record.RegistryMetadata.ReleaseDate = releaseDate.Format(time.RFC3339)
return &record, nil
}
// Publish adds a new ServerDetail to the database
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail *model.ServerDetail) error {
// Publish adds a new server to the database with separated server.json and extensions
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
if ctx.Err() != nil {
return ctx.Err()
return nil, ctx.Err()
}
tx, err := db.conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Printf("Failed to rollback transaction: %v", err)
}
}()
@ -228,26 +291,176 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail *model.ServerDet
// Check if there's an existing latest version for this server
var existingVersion string
checkQuery := `
SELECT version
FROM servers
WHERE name = $1 AND is_latest = true
SELECT s.version
FROM servers s
JOIN server_extensions se ON s.id = se.server_id
WHERE s.name = $1 AND se.is_latest = true
`
err = tx.QueryRow(ctx, checkQuery, serverDetail.Name).Scan(&existingVersion)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("failed to check existing version: %w", err)
return nil, fmt.Errorf("failed to check existing version: %w", err)
}
// Validate version ordering
if existingVersion != "" && serverDetail.VersionDetail.Version <= existingVersion {
return ErrInvalidVersion
return nil, ErrInvalidVersion
}
// Prepare JSON data
// Prepare JSON data for server table
repositoryJSON, err := json.Marshal(serverDetail.Repository)
if err != nil {
return fmt.Errorf("failed to marshal repository: %w", err)
return nil, fmt.Errorf("failed to marshal repository: %w", err)
}
packagesJSON, err := json.Marshal(serverDetail.Packages)
if err != nil {
return nil, fmt.Errorf("failed to marshal packages: %w", err)
}
remotesJSON, err := json.Marshal(serverDetail.Remotes)
if err != nil {
return nil, fmt.Errorf("failed to marshal remotes: %w", err)
}
publisherExtensionsJSON, err := json.Marshal(publisherExtensions)
if err != nil {
return nil, fmt.Errorf("failed to marshal publisher extensions: %w", err)
}
// Generate server ID and create registry metadata
serverID := uuid.New().String()
registryID := uuid.New().String()
now := time.Now()
// Update existing latest version to not be latest
if existingVersion != "" {
updateQuery := `
UPDATE server_extensions
SET is_latest = false
WHERE server_id IN (
SELECT s.id FROM servers s WHERE s.name = $1 AND server_extensions.server_id = s.id
)
AND is_latest = true
`
_, err = tx.Exec(ctx, updateQuery, serverDetail.Name)
if err != nil {
return nil, fmt.Errorf("failed to update existing latest version: %w", err)
}
}
// Insert new server record
insertServerQuery := `
INSERT INTO servers (id, name, description, status, repository, version, packages, remotes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err = tx.Exec(ctx, insertServerQuery,
serverID,
serverDetail.Name,
serverDetail.Description,
serverDetail.Status,
repositoryJSON,
serverDetail.VersionDetail.Version,
packagesJSON,
remotesJSON,
)
if err != nil {
return nil, fmt.Errorf("failed to insert server: %w", err)
}
// Insert server extensions record
insertExtensionsQuery := `
INSERT INTO server_extensions (id, server_id, published_at, updated_at, is_latest, release_date, publisher_extensions)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err = tx.Exec(ctx, insertExtensionsQuery,
registryID,
serverID,
now,
now,
true, // is_latest
now, // release_date
publisherExtensionsJSON,
)
if err != nil {
return nil, fmt.Errorf("failed to insert server extensions: %w", err)
}
// Commit transaction
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
// Create and return the ServerRecord
record := &model.ServerRecord{
ServerJSON: serverDetail,
RegistryMetadata: model.RegistryMetadata{
ID: registryID,
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
},
PublisherExtensions: publisherExtensions,
}
return record, nil
}
// ImportSeed imports initial data from a seed file into PostgreSQL
func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error {
// Read seed data using the shared ReadSeedFile function
seedData, err := ReadSeedFile(ctx, seedFilePath)
if err != nil {
return fmt.Errorf("failed to read seed file: %w", err)
}
// Start a transaction for batch import
tx, err := db.conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
log.Printf("Failed to rollback transaction: %v", rollbackErr)
}
}()
// Import each server
for _, record := range seedData {
// Convert record to the format expected by Publish
serverDetail := record.ServerJSON
publisherExtensions := record.PublisherExtensions
// Use the existing Publish logic but with specific ID from seed data
if err := db.publishWithTransaction(ctx, tx, serverDetail, publisherExtensions, &record.RegistryMetadata); err != nil {
return fmt.Errorf("failed to import server %s: %w", serverDetail.Name, err)
}
}
// Commit the transaction
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit seed import transaction: %w", err)
}
return nil
}
// publishWithTransaction handles publishing within an existing transaction, optionally with predefined metadata
func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, existingMetadata *model.RegistryMetadata) error {
var serverID string
var extensionID string
if existingMetadata != nil && existingMetadata.ID != "" {
// Use predefined IDs from seed data
serverID = existingMetadata.ID
extensionID = existingMetadata.ID // In seed data, these are the same
} else {
// Generate new UUIDs for normal publishing
serverID = uuid.New().String()
extensionID = uuid.New().String()
}
// Marshal packages and remotes to JSONB
packagesJSON, err := json.Marshal(serverDetail.Packages)
if err != nil {
return fmt.Errorf("failed to marshal packages: %w", err)
@ -258,143 +471,77 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail *model.ServerDet
return fmt.Errorf("failed to marshal remotes: %w", err)
}
// Generate ID and set metadata
if serverDetail.ID == "" {
serverDetail.ID = uuid.New().String()
}
serverDetail.VersionDetail.IsLatest = true
if serverDetail.VersionDetail.ReleaseDate == "" {
serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
}
releaseDate, err := time.Parse(time.RFC3339, serverDetail.VersionDetail.ReleaseDate)
repositoryJSON, err := json.Marshal(serverDetail.Repository)
if err != nil {
return fmt.Errorf("failed to parse release date: %w", err)
return fmt.Errorf("failed to marshal repository: %w", err)
}
// Update existing latest version to not be latest
if existingVersion != "" {
updateQuery := `
UPDATE servers
SET is_latest = false
WHERE name = $1 AND is_latest = true
`
_, err = tx.Exec(ctx, updateQuery, serverDetail.Name)
if err != nil {
return fmt.Errorf("failed to update existing latest version: %w", err)
}
publisherExtensionsJSON, err := json.Marshal(publisherExtensions)
if err != nil {
return fmt.Errorf("failed to marshal publisher extensions: %w", err)
}
// Insert new server version
insertQuery := `
INSERT INTO servers (id, name, description, status, repository, version, release_date, is_latest, packages, remotes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
_, err = tx.Exec(ctx, insertQuery,
serverDetail.ID,
// Insert or update server record
serverQuery := `
INSERT INTO servers (id, name, description, status, repository, version, packages, remotes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
ON CONFLICT (name, version)
DO UPDATE SET
description = EXCLUDED.description,
status = EXCLUDED.status,
repository = EXCLUDED.repository,
packages = EXCLUDED.packages,
remotes = EXCLUDED.remotes,
updated_at = NOW()
RETURNING id`
var returnedServerID string
err = tx.QueryRow(ctx, serverQuery,
serverID,
serverDetail.Name,
serverDetail.Description,
serverDetail.Status,
string(serverDetail.Status),
repositoryJSON,
serverDetail.VersionDetail.Version,
releaseDate,
serverDetail.VersionDetail.IsLatest,
packagesJSON,
remotesJSON,
).Scan(&returnedServerID)
if err != nil {
return fmt.Errorf("failed to insert/update server: %w", err)
}
// Insert or update server extensions
extensionQuery := `
INSERT INTO server_extensions (id, server_id, published_at, updated_at, is_latest, release_date, publisher_extensions)
VALUES ($1, $2, $3, NOW(), true, $4, $5)
ON CONFLICT (server_id)
DO UPDATE SET
updated_at = NOW(),
is_latest = true,
release_date = EXCLUDED.release_date,
publisher_extensions = EXCLUDED.publisher_extensions`
var publishedAt, releaseDate string
if existingMetadata != nil {
publishedAt = existingMetadata.PublishedAt.Format(time.RFC3339)
releaseDate = existingMetadata.ReleaseDate
} else {
now := time.Now().Format(time.RFC3339)
publishedAt = now
releaseDate = now
}
_, err = tx.Exec(ctx, extensionQuery,
extensionID,
returnedServerID,
publishedAt,
releaseDate,
publisherExtensionsJSON,
)
if err != nil {
return fmt.Errorf("failed to insert server: %w", err)
return fmt.Errorf("failed to insert/update server extensions: %w", err)
}
return tx.Commit(ctx)
}
// ImportSeed imports initial data from a seed file into PostgreSQL
func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error {
servers, err := ReadSeedFile(ctx, seedFilePath)
if err != nil {
return fmt.Errorf("failed to read seed file: %w", err)
}
log.Printf("Importing %d servers into PostgreSQL", len(servers))
for i, server := range servers {
if server.ID == "" || server.Name == "" {
log.Printf("Skipping server %d: ID or Name is empty", i+1)
continue
}
if server.VersionDetail.Version == "" {
server.VersionDetail.Version = "0.0.1-seed"
server.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
server.VersionDetail.IsLatest = true
}
// Prepare JSON data
repositoryJSON, err := json.Marshal(server.Repository)
if err != nil {
log.Printf("Error marshaling repository for server %s: %v", server.ID, err)
continue
}
packagesJSON, err := json.Marshal(server.Packages)
if err != nil {
log.Printf("Error marshaling packages for server %s: %v", server.ID, err)
continue
}
remotesJSON, err := json.Marshal(server.Remotes)
if err != nil {
log.Printf("Error marshaling remotes for server %s: %v", server.ID, err)
continue
}
releaseDate, err := time.Parse(time.RFC3339, server.VersionDetail.ReleaseDate)
if err != nil {
log.Printf("Error parsing release date for server %s: %v", server.ID, err)
releaseDate = time.Now()
}
// Use UPSERT (INSERT ... ON CONFLICT DO UPDATE)
upsertQuery := `
INSERT INTO servers (id, name, description, status, repository, version, release_date, is_latest, packages, remotes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
status = EXCLUDED.status,
repository = EXCLUDED.repository,
version = EXCLUDED.version,
release_date = EXCLUDED.release_date,
is_latest = EXCLUDED.is_latest,
packages = EXCLUDED.packages,
remotes = EXCLUDED.remotes,
updated_at = NOW()
`
_, err = db.conn.Exec(ctx, upsertQuery,
server.ID,
server.Name,
server.Description,
server.Status,
repositoryJSON,
server.VersionDetail.Version,
releaseDate,
server.VersionDetail.IsLatest,
packagesJSON,
remotesJSON,
)
if err != nil {
log.Printf("Error importing server %s: %v", server.ID, err)
continue
}
log.Printf("[%d/%d] Imported server: %s", i+1, len(servers), server.Name)
}
log.Println("PostgreSQL database import completed successfully")
return nil
}

View file

@ -1,5 +1,11 @@
package model
import (
"encoding/json"
"fmt"
"time"
)
// AuthMethod represents the authentication method used
type AuthMethod string
@ -26,10 +32,6 @@ const (
ServerStatusDeprecated ServerStatus = "deprecated"
)
// PublishRequest represents a request to publish a server to the registry
type PublishRequest struct {
ServerDetail `json:",inline"`
}
// Repository represents a source code repository as defined in the spec
type Repository struct {
@ -38,12 +40,6 @@ type Repository struct {
ID string `json:"id,omitempty" bson:"id,omitempty"`
}
// ServerList represents the response for listing servers as defined in the spec
type ServerList struct {
Servers []Server `json:"servers" bson:"servers"`
Next string `json:"next,omitempty" bson:"next,omitempty"`
TotalCount int `json:"total_count" bson:"total_count"`
}
// create an enum for Format
type Format string
@ -107,26 +103,163 @@ type Remote struct {
Headers []KeyValueInput `json:"headers,omitempty" bson:"headers,omitempty"`
}
// VersionDetail represents the version details of a server
// VersionDetail represents the version details of a server (pure MCP spec, no registry metadata)
type VersionDetail struct {
Version string `json:"version" bson:"version"`
ReleaseDate string `json:"release_date,omitempty" bson:"release_date"`
IsLatest bool `json:"is_latest,omitempty" bson:"is_latest"`
Version string `json:"version" bson:"version"`
}
// Server represents a basic server information as defined in the spec
type Server struct {
ID string `json:"id,omitempty" bson:"id"`
// ServerDetail represents complete server information as defined in the MCP spec (pure, no registry metadata)
type ServerDetail struct {
Name string `json:"name" bson:"name"`
Description string `json:"description" bson:"description"`
Status ServerStatus `json:"status,omitempty" bson:"status,omitempty"`
Repository Repository `json:"repository,omitempty" bson:"repository"`
VersionDetail VersionDetail `json:"version_detail" bson:"version_detail"`
Packages []Package `json:"packages,omitempty" bson:"packages,omitempty"`
Remotes []Remote `json:"remotes,omitempty" bson:"remotes,omitempty"`
}
// ServerDetail represents detailed server information as defined in the spec
type ServerDetail struct {
Server `json:",inline" bson:",inline"`
Packages []Package `json:"packages,omitempty" bson:"packages,omitempty"`
Remotes []Remote `json:"remotes,omitempty" bson:"remotes,omitempty"`
// RegistryMetadata represents registry-generated metadata
type RegistryMetadata struct {
ID string `json:"id" bson:"_id"`
PublishedAt time.Time `json:"published_at" bson:"published_at"`
UpdatedAt time.Time `json:"updated_at,omitempty" bson:"updated_at,omitempty"`
IsLatest bool `json:"is_latest" bson:"is_latest"`
ReleaseDate string `json:"release_date" bson:"release_date"`
}
// ServerRecord represents the complete storage model that separates server.json from registry metadata
type ServerRecord struct {
ServerJSON ServerDetail `json:"server" bson:"server"` // Pure MCP server.json
RegistryMetadata RegistryMetadata `json:"registry_metadata" bson:"registry_metadata"` // Registry-generated data
PublisherExtensions map[string]interface{} `json:"publisher_extensions" bson:"publisher_extensions"` // x-publisher extensions
}
// ServerResponse represents the API response format with wrapper and extensions
type ServerResponse struct {
Server ServerDetail `json:"server"`
XIOModelContextProtocolRegistry interface{} `json:"x-io.modelcontextprotocol.registry,omitempty"`
XPublisher interface{} `json:"x-publisher,omitempty"`
}
// ServerListResponse represents the paginated server list response
type ServerListResponse struct {
Servers []ServerResponse `json:"servers"`
Metadata *Metadata `json:"metadata,omitempty"`
}
// PublishRequest represents the API request format for publishing servers
type PublishRequest struct {
Server ServerDetail `json:"server"`
XPublisher interface{} `json:"x-publisher,omitempty"`
}
// Metadata represents pagination metadata
type Metadata struct {
NextCursor string `json:"next_cursor,omitempty"`
Count int `json:"count,omitempty"`
Total int `json:"total,omitempty"`
}
// Helper functions
// ValidatePublisherExtensions validates that publisher extensions are within size limits
func ValidatePublisherExtensions(req PublishRequest) error {
const maxExtensionSize = 4 * 1024 // 4KB limit
// Check size limit for x-publisher extension
if req.XPublisher != nil {
extensionsJSON, err := json.Marshal(req.XPublisher)
if err != nil {
return fmt.Errorf("failed to marshal x-publisher extension: %w", err)
}
if len(extensionsJSON) > maxExtensionSize {
return fmt.Errorf("x-publisher extension exceeds 4KB limit (%d bytes)", len(extensionsJSON))
}
}
return nil
}
// ValidatePublishRequestExtensions validates that only allowed extension fields are present
func ValidatePublishRequestExtensions(requestData []byte) error {
// Parse the raw JSON to check for unknown fields
var rawRequest map[string]interface{}
if err := json.Unmarshal(requestData, &rawRequest); err != nil {
return fmt.Errorf("failed to parse request JSON: %w", err)
}
// Define allowed top-level fields
allowedFields := map[string]bool{
"server": true,
"x-publisher": true,
}
// Check for any disallowed fields
var invalidFields []string
for field := range rawRequest {
if !allowedFields[field] {
invalidFields = append(invalidFields, field)
}
}
if len(invalidFields) > 0 {
return fmt.Errorf("invalid extension fields: %v. Only 'server' and 'x-publisher' fields are allowed", invalidFields)
}
return nil
}
// ExtractPublisherExtensions extracts publisher extensions from a PublishRequest
func ExtractPublisherExtensions(req PublishRequest) map[string]interface{} {
publisherExtensions := make(map[string]interface{})
if req.XPublisher != nil {
// Cast to map and copy fields directly, avoiding double nesting
if publisherMap, ok := req.XPublisher.(map[string]interface{}); ok {
for k, v := range publisherMap {
publisherExtensions[k] = v
}
}
}
return publisherExtensions
}
// CreateRegistryExtensions generates the x-io.modelcontextprotocol.registry extension from registry metadata
func (rm *RegistryMetadata) CreateRegistryExtensions() map[string]interface{} {
return map[string]interface{}{
"x-io.modelcontextprotocol.registry": map[string]interface{}{
"id": rm.ID,
"published_at": rm.PublishedAt,
"updated_at": rm.UpdatedAt,
"is_latest": rm.IsLatest,
"release_date": rm.ReleaseDate,
},
}
}
// ParseServerName extracts the server name from a ServerDetail for validation purposes
func ParseServerName(serverDetail ServerDetail) (string, error) {
name := serverDetail.Name
if name == "" {
return "", fmt.Errorf("server name is required and must be a string")
}
return name, nil
}
// ToServerResponse converts a ServerRecord to API response format
func (sr *ServerRecord) ToServerResponse() ServerResponse {
response := ServerResponse{
Server: sr.ServerJSON,
}
// Add registry metadata extension
response.XIOModelContextProtocolRegistry = sr.RegistryMetadata.CreateRegistryExtensions()["x-io.modelcontextprotocol.registry"]
// Add publisher extensions directly
if len(sr.PublisherExtensions) > 0 {
response.XPublisher = sr.PublisherExtensions
}
return response
}

View file

@ -0,0 +1,265 @@
//nolint:testpackage
package model
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractPublisherExtensions(t *testing.T) {
tests := []struct {
name string
request PublishRequest
expected map[string]interface{}
}{
{
name: "nil publisher extensions",
request: PublishRequest{
Server: ServerDetail{Name: "test"},
},
expected: map[string]interface{}{},
},
{
name: "empty publisher extensions",
request: PublishRequest{
Server: ServerDetail{Name: "test"},
XPublisher: map[string]interface{}{},
},
expected: map[string]interface{}{},
},
{
name: "simple publisher extensions",
request: PublishRequest{
Server: ServerDetail{Name: "test"},
XPublisher: map[string]interface{}{
"build_info": map[string]interface{}{
"version": "1.2.3",
"commit": "abc123",
},
"publisher_metadata": "test-publisher",
},
},
expected: map[string]interface{}{
"build_info": map[string]interface{}{
"version": "1.2.3",
"commit": "abc123",
},
"publisher_metadata": "test-publisher",
},
},
{
name: "nested publisher extensions",
request: PublishRequest{
Server: ServerDetail{Name: "test"},
XPublisher: map[string]interface{}{
"ci": map[string]interface{}{
"pipeline": map[string]interface{}{
"id": "12345",
"branch": "main",
},
"artifacts": []string{"binary", "docs"},
},
},
},
expected: map[string]interface{}{
"ci": map[string]interface{}{
"pipeline": map[string]interface{}{
"id": "12345",
"branch": "main",
},
"artifacts": []string{"binary", "docs"},
},
},
},
{
name: "non-map publisher extensions (should be ignored)",
request: PublishRequest{
Server: ServerDetail{Name: "test"},
XPublisher: "invalid-string-data",
},
expected: map[string]interface{}{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractPublisherExtensions(tt.request)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExtractPublisherExtensions_DoesNotDoubleNest(t *testing.T) {
// This test specifically verifies the bug we fixed - no double nesting
request := PublishRequest{
Server: ServerDetail{Name: "test"},
XPublisher: map[string]interface{}{
"tool": "publisher-cli",
"version": "1.0.0",
},
}
result := ExtractPublisherExtensions(request)
// Verify we get the data directly, not wrapped in another "x-publisher" key
assert.Equal(t, "publisher-cli", result["tool"])
assert.Equal(t, "1.0.0", result["version"])
// Verify we don't have double nesting
assert.NotContains(t, result, "x-publisher")
}
func TestRegistryMetadata_CreateRegistryExtensions(t *testing.T) {
publishedTime := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC)
updatedTime := time.Date(2023, 12, 1, 11, 0, 0, 0, time.UTC)
metadata := RegistryMetadata{
ID: "test-id-123",
PublishedAt: publishedTime,
UpdatedAt: updatedTime,
IsLatest: true,
ReleaseDate: "2023-12-01T10:30:00Z",
}
result := metadata.CreateRegistryExtensions()
expected := map[string]interface{}{
"x-io.modelcontextprotocol.registry": map[string]interface{}{
"id": "test-id-123",
"published_at": publishedTime,
"updated_at": updatedTime,
"is_latest": true,
"release_date": "2023-12-01T10:30:00Z",
},
}
assert.Equal(t, expected, result)
}
func TestServerResponse_JSONSerialization(t *testing.T) {
// Test that ServerResponse properly serializes to extension wrapper format
publishedTime := time.Date(2023, 12, 1, 10, 30, 0, 0, time.UTC)
response := ServerResponse{
Server: ServerDetail{
Name: "test-server",
Description: "A test server",
Repository: Repository{
URL: "https://github.com/test/server",
Source: "github",
ID: "test/server",
},
VersionDetail: VersionDetail{
Version: "1.0.0",
},
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "registry-id-123",
"published_at": publishedTime.Format(time.RFC3339),
"is_latest": true,
},
XPublisher: map[string]interface{}{
"build_tool": "ci-pipeline",
"commit": "abc123def",
},
}
// Serialize to JSON
jsonData, err := json.Marshal(response)
require.NoError(t, err)
// Parse back to verify structure
var parsed map[string]interface{}
err = json.Unmarshal(jsonData, &parsed)
require.NoError(t, err)
// Verify three-layer structure exists
assert.Contains(t, parsed, "server")
assert.Contains(t, parsed, "x-io.modelcontextprotocol.registry")
assert.Contains(t, parsed, "x-publisher")
// Verify server data
serverData := parsed["server"].(map[string]interface{})
assert.Equal(t, "test-server", serverData["name"])
// Verify registry metadata
registryData := parsed["x-io.modelcontextprotocol.registry"].(map[string]interface{})
assert.Equal(t, "registry-id-123", registryData["id"])
// Verify publisher extensions
publisherData := parsed["x-publisher"].(map[string]interface{})
assert.Equal(t, "ci-pipeline", publisherData["build_tool"])
assert.Equal(t, "abc123def", publisherData["commit"])
}
func TestPublishRequest_WithPublisherExtensions(t *testing.T) {
// Test that PublishRequest properly deserializes publisher extensions
jsonData := `{
"server": {
"name": "test-server",
"description": "Test server",
"version_detail": {
"version": "1.0.0"
}
},
"x-publisher": {
"tool": "publisher-cli",
"metadata": {
"build_date": "2023-12-01",
"commit": "abc123"
}
}
}`
var request PublishRequest
err := json.Unmarshal([]byte(jsonData), &request)
require.NoError(t, err)
// Verify server data
assert.Equal(t, "test-server", request.Server.Name)
assert.Equal(t, "Test server", request.Server.Description)
// Verify publisher extensions
require.NotNil(t, request.XPublisher)
publisherMap := request.XPublisher.(map[string]interface{})
assert.Equal(t, "publisher-cli", publisherMap["tool"])
metadata := publisherMap["metadata"].(map[string]interface{})
assert.Equal(t, "2023-12-01", metadata["build_date"])
assert.Equal(t, "abc123", metadata["commit"])
}
func TestServerResponse_EmptyExtensions(t *testing.T) {
// Test behavior with empty/nil extensions
response := ServerResponse{
Server: ServerDetail{
Name: "minimal-server",
Description: "Minimal test",
},
XIOModelContextProtocolRegistry: map[string]interface{}{
"id": "min-id",
},
XPublisher: nil,
}
jsonData, err := json.Marshal(response)
require.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(jsonData, &parsed)
require.NoError(t, err)
// Should still have the structure, even with nil publisher extensions
assert.Contains(t, parsed, "server")
assert.Contains(t, parsed, "x-io.modelcontextprotocol.registry")
// x-publisher should be null/nil in JSON when empty
publisherValue, exists := parsed["x-publisher"]
if exists {
assert.Nil(t, publisherValue)
}
}

View file

@ -18,10 +18,9 @@ type fakeRegistryService struct {
//
//nolint:ireturn // Factory function intentionally returns interface for dependency injection
func NewFakeRegistryService() RegistryService {
// Sample registry entries with updated model structure
registries := []*model.Server{
// Sample registry entries with updated model structure using ServerDetail
serverDetails := []*model.ServerDetail{
{
ID: uuid.New().String(),
Name: "bluegreen/mcp-server",
Description: "A dummy MCP registry for testing",
Repository: model.Repository{
@ -30,13 +29,10 @@ func NewFakeRegistryService() RegistryService {
ID: "example/mcp-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
ReleaseDate: time.Now().Format(time.RFC3339),
IsLatest: true,
Version: "1.0.0",
},
},
{
ID: uuid.New().String(),
Name: "orangepurple/mcp-server",
Description: "Another dummy MCP registry for testing",
Repository: model.Repository{
@ -45,13 +41,10 @@ func NewFakeRegistryService() RegistryService {
ID: "example/mcp-2",
},
VersionDetail: model.VersionDetail{
Version: "0.9.0",
ReleaseDate: time.Now().Format(time.RFC3339),
IsLatest: false,
Version: "0.9.0",
},
},
{
ID: uuid.New().String(),
Name: "greenyellow/mcp-server",
Description: "Yet another dummy MCP registry for testing",
Repository: model.Repository{
@ -60,70 +53,92 @@ func NewFakeRegistryService() RegistryService {
ID: "example/mcp-3",
},
VersionDetail: model.VersionDetail{
Version: "0.9.5",
ReleaseDate: time.Now().Format(time.RFC3339),
IsLatest: false,
Version: "0.9.5",
},
},
}
// Create a new in-memory database
registryMap := make(map[string]*model.Server)
for _, entry := range registries {
registryMap[entry.ID] = entry
// Create a new in-memory database using registry metadata IDs
serverDetailMap := make(map[string]*model.ServerDetail)
for _, entry := range serverDetails {
registryID := uuid.New().String() // Generate registry metadata ID
serverDetailMap[registryID] = entry
}
memDB := database.NewMemoryDB(registryMap)
memDB := database.NewMemoryDB(serverDetailMap)
return &fakeRegistryService{
db: memDB,
}
}
// List retrieves MCPRegistry entries with optional filtering and pagination
func (s *fakeRegistryService) List(cursor string, limit int) ([]model.Server, string, error) {
// List retrieves servers with extension wrapper format
func (s *fakeRegistryService) List(cursor string, limit int) ([]model.ServerResponse, string, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's List method with no filters to get all entries
entries, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
// Use the database's List method to get ServerRecord entries
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
if err != nil {
return nil, "", err
}
// Convert from []*model.Server to []model.Server
result := make([]model.Server, len(entries))
for i, entry := range entries {
result[i] = *entry
// Convert ServerRecord to ServerResponse format
result := make([]model.ServerResponse, len(serverRecords))
for i, record := range serverRecords {
result[i] = record.ToServerResponse()
}
return result, nextCursor, nil
}
// GetByID retrieves a specific server detail by its ID
func (s *fakeRegistryService) GetByID(id string) (*model.ServerDetail, error) {
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
func (s *fakeRegistryService) GetByID(id string) (*model.ServerResponse, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's GetByID method to retrieve the server detail
serverDetail, err := s.db.GetByID(ctx, id)
// Use the database's GetByID method to retrieve the server record
serverRecord, err := s.db.GetByID(ctx, id)
if err != nil {
return nil, err
}
return serverDetail, nil
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}
// Publish adds a new server detail to the in-memory database
func (s *fakeRegistryService) Publish(serverDetail *model.ServerDetail) error {
// Publish publishes a server with separated extensions
func (s *fakeRegistryService) Publish(req model.PublishRequest) (*model.ServerResponse, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's Publish method to add the server detail
return s.db.Publish(ctx, serverDetail)
// Validate the request
if err := model.ValidatePublisherExtensions(req); err != nil {
return nil, err
}
// Validate server name exists
if _, err := model.ParseServerName(req.Server); err != nil {
return nil, err
}
// Extract publisher extensions from request
publisherExtensions := model.ExtractPublisherExtensions(req)
// Publish to database
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions)
if err != nil {
return nil, err
}
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}
// Close closes the in-memory database connection
func (s *fakeRegistryService) Close() error {
return s.db.Close()
}
}

View file

@ -22,29 +22,8 @@ func NewRegistryServiceWithDB(db database.Database) RegistryService {
}
}
// GetAll returns all registry entries
func (s *registryServiceImpl) GetAll() ([]model.Server, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's List method with no filters to get all entries
entries, _, err := s.db.List(ctx, nil, "", 30)
if err != nil {
return nil, err
}
// Convert from []*model.Server to []model.Server
result := make([]model.Server, len(entries))
for i, entry := range entries {
result[i] = *entry
}
return result, nil
}
// List returns registry entries with cursor-based pagination
func (s *registryServiceImpl) List(cursor string, limit int) ([]model.Server, string, error) {
// List returns registry entries with cursor-based pagination in extension wrapper format
func (s *registryServiceImpl) List(cursor string, limit int) ([]model.ServerResponse, string, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -55,49 +34,63 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]model.Server, st
}
// Use the database's List method with pagination
entries, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
if err != nil {
return nil, "", err
}
// Convert from []*model.Server to []model.Server
result := make([]model.Server, len(entries))
for i, entry := range entries {
result[i] = *entry
// Convert ServerRecord to ServerResponse format
result := make([]model.ServerResponse, len(serverRecords))
for i, record := range serverRecords {
result[i] = record.ToServerResponse()
}
return result, nextCursor, nil
}
// GetByID retrieves a specific server detail by its ID
func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) {
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
func (s *registryServiceImpl) GetByID(id string) (*model.ServerResponse, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's GetByID method to retrieve the server detail
serverDetail, err := s.db.GetByID(ctx, id)
// Use the database's GetByID method to retrieve the server record
serverRecord, err := s.db.GetByID(ctx, id)
if err != nil {
return nil, err
}
return serverDetail, nil
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}
// Publish adds a new server detail to the registry
func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error {
// Publish publishes a server with separated extensions
func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerResponse, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if serverDetail == nil {
return database.ErrInvalidInput
// Validate the request
if err := model.ValidatePublisherExtensions(req); err != nil {
return nil, err
}
err := s.db.Publish(ctx, serverDetail)
// Validate server name exists
if _, err := model.ParseServerName(req.Server); err != nil {
return nil, err
}
// Extract publisher extensions from request
publisherExtensions := model.ExtractPublisherExtensions(req)
// Publish to database
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions)
if err != nil {
return err
return nil, err
}
return nil
}
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}

Some files were not shown because too many files have changed in this diff Show more