feat: Add website_url field for MCP servers (#422)

Adds optional website_url field to the server.json schema allowing
publishers to provide a 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.

Issue: #183 
PR: #130 
Discussion: #274 

## Motivation and Context
This change addresses the need identified in #130, #183 and #274 for a
standardized way to direct users to an official landing page similar to
`homepage` used in NPM
([example](https://www.npmjs.com/package/@modelcontextprotocol/sdk)). It
is also serves as a viable option for MCP Servers that don't follow
typical package manager patterns (e.g., embedded in a Desktop app).

## How Has This Been Tested?
- Schema validation: All 13 example server.json files validate
successfully against the updated schema
- Go validator tests: Added additional test cases covering URL format
validation and namespace matching
- Run all checks: Updated example files to work with anonymous
publishing and verified `make check` passes

## Breaking Changes
None. The website_url field is optional and backward compatible with
existing server.json files.

## Types of changes
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update

## Checklist
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context
Security considerations:
- Website URL validation enforces that the domain matches the
publisher's reverse-DNS namespace
- HTTP/HTTPS protocols are required (no file:// or other schemes)
- Validation is consistent with existing remote URL validation patterns
This commit is contained in:
Kalle Bylin 2025-09-11 03:29:46 +02:00 • committed by GitHub
commit 17eb56d4c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 181 additions and 4 deletions

View file

@ -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

View file

@ -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{