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:
parent
ebaf4c9c99
commit
8d7e471fd3
34 changed files with 19980 additions and 15589 deletions
|
|
@ -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
|
||||
|
|
|
|||
29220
data/seed.json
29220
data/seed.json
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
62
internal/database/migrations/002_add_server_extensions.sql
Normal file
62
internal/database/migrations/002_add_server_extensions.sql
Normal 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();
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
265
internal/model/model_test.go
Normal file
265
internal/model/model_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue