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:
parent
d0d8630925
commit
17eb56d4c7
7 changed files with 181 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue