diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 540d6f4..745cbbd 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -233,6 +233,11 @@ components: type: string example: "1.0.2" description: "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification." + website_url: + type: string + format: uri + description: "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements." + example: "https://modelcontextprotocol.io/examples" created_at: type: string format: date-time diff --git a/docs/reference/server-json/generic-server-json.md b/docs/reference/server-json/generic-server-json.md index d6d3ac5..8a97cb7 100644 --- a/docs/reference/server-json/generic-server-json.md +++ b/docs/reference/server-json/generic-server-json.md @@ -23,9 +23,10 @@ The official registry has some more restrictions on top of this. See the [offici ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", - "name": "io.modelcontextprotocol/brave-search", + "name": "io.modelcontextprotocol.anonymous/brave-search", "description": "MCP server for Brave Search API integration", "status": "active", + "website_url": "https://anonymous.modelcontextprotocol.io/examples", "repository": { "url": "https://github.com/modelcontextprotocol/servers", "source": "github" @@ -654,6 +655,21 @@ Some CLI tools bundle an MCP server, without a standalone MCP package or a publi } ``` +### Server with Custom Installation Path + +For MCP servers that follow a custom installation path or are embedded in applications without standalone packages, use the `website_url` field to direct users to setup documentation: + +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", + "name": "io.modelcontextprotocol.anonymous/embedded-mcp", + "description": "MCP server embedded in a Desktop app", + "status": "active", + "website_url": "https://anonymous.modelcontextprotocol.io/embedded-mcp-guide", + "version": "0.1.0" +} +``` + ### Deprecated Server Example ```json diff --git a/docs/reference/server-json/server.schema.json b/docs/reference/server-json/server.schema.json index ea0c39f..e602abc 100644 --- a/docs/reference/server-json/server.schema.json +++ b/docs/reference/server-json/server.schema.json @@ -73,6 +73,12 @@ "maxLength": 255, "example": "1.0.2", "description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably." + }, + "website_url": { + "type": "string", + "format": "uri", + "description": "Optional URL to the server's homepage, documentation, or project website. This provides a central link for users to learn more about the server. Particularly useful when the server has custom installation instructions or setup requirements.", + "example": "https://modelcontextprotocol.io/examples" } } }, diff --git a/internal/validators/validators.go b/internal/validators/validators.go index d4a07f0..3aecd70 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -24,6 +24,11 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { return err } + // Validate website URL if provided + if err := validateWebsiteURL(serverJSON.WebsiteURL); err != nil { + return err + } + // Validate all packages (basic field validation) // Detailed package validation (including registry checks) is done during publish for _, pkg := range serverJSON.Packages { @@ -44,6 +49,11 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error { return err } + // Validate reverse-DNS namespace matching for website URL + if err := validateWebsiteURLNamespaceMatch(*serverJSON); err != nil { + return err + } + return nil } @@ -67,6 +77,31 @@ func validateRepository(obj *model.Repository) error { return nil } +func validateWebsiteURL(websiteURL string) error { + // Skip validation if website URL is not provided (optional field) + if websiteURL == "" { + return nil + } + + // Parse the URL to ensure it's valid + parsedURL, err := url.Parse(websiteURL) + if err != nil { + return fmt.Errorf("invalid website URL: %w", err) + } + + // Ensure it's an absolute URL with valid scheme + if !parsedURL.IsAbs() { + return fmt.Errorf("website URL must be absolute (include scheme): %s", websiteURL) + } + + // Only allow HTTP/HTTPS schemes for security + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("website URL must use http or https scheme: %s", websiteURL) + } + + return nil +} + func validatePackageField(obj *model.Package) error { if !HasNoSpaces(obj.Identifier) { return ErrPackageNameHasSpaces @@ -319,6 +354,21 @@ func validateRemoteNamespaceMatch(serverJSON apiv0.ServerJSON) error { return nil } +// validateWebsiteURLNamespaceMatch validates that website URL matches the reverse-DNS namespace +func validateWebsiteURLNamespaceMatch(serverJSON apiv0.ServerJSON) error { + // Skip validation if website URL is not provided + if serverJSON.WebsiteURL == "" { + return nil + } + + namespace := serverJSON.Name + if err := validateRemoteURLMatchesNamespace(serverJSON.WebsiteURL, namespace); err != nil { + return fmt.Errorf("website URL %s does not match namespace %s: %w", serverJSON.WebsiteURL, namespace, err) + } + + return nil +} + // validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error { // Parse the URL to extract the hostname diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index 5a3d5e2..74e5dcf 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -27,7 +27,8 @@ func TestValidate(t *testing.T) { Source: "github", ID: "owner/repo", }, - Version: "1.0.0", + Version: "1.0.0", + WebsiteURL: "https://example.com/docs", Packages: []model.Package{ { Identifier: "test-package", @@ -170,6 +171,104 @@ func TestValidate(t *testing.T) { }, expectedError: validators.ErrInvalidSubfolderPath.Error(), }, + { + name: "server with valid website URL", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "https://example.com/docs", + }, + expectedError: "", + }, + { + name: "server with invalid website URL - no scheme", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "example.com/docs", + }, + expectedError: "website URL must be absolute (include scheme): example.com/docs", + }, + { + name: "server with invalid website URL - invalid scheme", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "ftp://example.com/docs", + }, + expectedError: "website URL must use http or https scheme: ftp://example.com/docs", + }, + { + name: "server with malformed website URL", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "ht tp://example.com/docs", + }, + expectedError: "invalid website URL:", + }, + { + name: "server with website URL that matches namespace domain", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "https://example.com/docs", + }, + expectedError: "", + }, + { + name: "server with website URL subdomain that matches namespace", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "https://docs.example.com/mcp", + }, + expectedError: "", + }, + { + name: "server with website URL that does not match namespace", + serverDetail: apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/owner/repo", + Source: "github", + }, + Version: "1.0.0", + WebsiteURL: "https://different.com/docs", + }, + expectedError: "website URL https://different.com/docs does not match namespace com.example/test-server", + }, { name: "package with spaces in name", serverDetail: apiv0.ServerJSON{ diff --git a/pkg/api/v0/types.go b/pkg/api/v0/types.go index e2cfce0..700221d 100644 --- a/pkg/api/v0/types.go +++ b/pkg/api/v0/types.go @@ -33,7 +33,8 @@ type ServerJSON struct { Description string `json:"description" minLength:"1" maxLength:"100"` Status model.Status `json:"status,omitempty" minLength:"1"` Repository model.Repository `json:"repository,omitempty"` - Version string `json:"version"` + Version string `json:"version"` + WebsiteURL string `json:"website_url,omitempty"` Packages []model.Package `json:"packages,omitempty"` Remotes []model.Transport `json:"remotes,omitempty"` Meta *ServerMeta `json:"_meta,omitempty"` diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index 73fa1e8..a16587d 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -23,7 +23,7 @@ const ( // expectedExampleCount is the number of JSON examples we expect to find in generic-server-json.md // IMPORTANT: Only change this count if you have intentionally added or removed examples. This // check prevents accidental formatting changes from causing examples to be skipped during validation. - expectedExampleCount = 12 + expectedExampleCount = 13 ) func main() {