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
7 changed files with 181 additions and 4 deletions

View file

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

View file

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

View file

@ -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"
}
}
},

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{

View file

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

View file

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