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
|
type: string
|
||||||
example: "1.0.2"
|
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."
|
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:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@ The official registry has some more restrictions on top of this. See the [offici
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.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",
|
"description": "MCP server for Brave Search API integration",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
|
"website_url": "https://anonymous.modelcontextprotocol.io/examples",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "https://github.com/modelcontextprotocol/servers",
|
"url": "https://github.com/modelcontextprotocol/servers",
|
||||||
"source": "github"
|
"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
|
### Deprecated Server Example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,12 @@
|
||||||
"maxLength": 255,
|
"maxLength": 255,
|
||||||
"example": "1.0.2",
|
"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."
|
"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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate website URL if provided
|
||||||
|
if err := validateWebsiteURL(serverJSON.WebsiteURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Validate all packages (basic field validation)
|
// Validate all packages (basic field validation)
|
||||||
// Detailed package validation (including registry checks) is done during publish
|
// Detailed package validation (including registry checks) is done during publish
|
||||||
for _, pkg := range serverJSON.Packages {
|
for _, pkg := range serverJSON.Packages {
|
||||||
|
|
@ -44,6 +49,11 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate reverse-DNS namespace matching for website URL
|
||||||
|
if err := validateWebsiteURLNamespaceMatch(*serverJSON); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +77,31 @@ func validateRepository(obj *model.Repository) error {
|
||||||
return nil
|
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 {
|
func validatePackageField(obj *model.Package) error {
|
||||||
if !HasNoSpaces(obj.Identifier) {
|
if !HasNoSpaces(obj.Identifier) {
|
||||||
return ErrPackageNameHasSpaces
|
return ErrPackageNameHasSpaces
|
||||||
|
|
@ -319,6 +354,21 @@ func validateRemoteNamespaceMatch(serverJSON apiv0.ServerJSON) error {
|
||||||
return nil
|
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
|
// validateRemoteURLMatchesNamespace checks if a remote URL's hostname matches the publisher domain from the namespace
|
||||||
func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error {
|
func validateRemoteURLMatchesNamespace(remoteURL, namespace string) error {
|
||||||
// Parse the URL to extract the hostname
|
// Parse the URL to extract the hostname
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ func TestValidate(t *testing.T) {
|
||||||
Source: "github",
|
Source: "github",
|
||||||
ID: "owner/repo",
|
ID: "owner/repo",
|
||||||
},
|
},
|
||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
|
WebsiteURL: "https://example.com/docs",
|
||||||
Packages: []model.Package{
|
Packages: []model.Package{
|
||||||
{
|
{
|
||||||
Identifier: "test-package",
|
Identifier: "test-package",
|
||||||
|
|
@ -170,6 +171,104 @@ func TestValidate(t *testing.T) {
|
||||||
},
|
},
|
||||||
expectedError: validators.ErrInvalidSubfolderPath.Error(),
|
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",
|
name: "package with spaces in name",
|
||||||
serverDetail: apiv0.ServerJSON{
|
serverDetail: apiv0.ServerJSON{
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ type ServerJSON struct {
|
||||||
Description string `json:"description" minLength:"1" maxLength:"100"`
|
Description string `json:"description" minLength:"1" maxLength:"100"`
|
||||||
Status model.Status `json:"status,omitempty" minLength:"1"`
|
Status model.Status `json:"status,omitempty" minLength:"1"`
|
||||||
Repository model.Repository `json:"repository,omitempty"`
|
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"`
|
Packages []model.Package `json:"packages,omitempty"`
|
||||||
Remotes []model.Transport `json:"remotes,omitempty"`
|
Remotes []model.Transport `json:"remotes,omitempty"`
|
||||||
Meta *ServerMeta `json:"_meta,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
|
// 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
|
// 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.
|
// check prevents accidental formatting changes from causing examples to be skipped during validation.
|
||||||
expectedExampleCount = 12
|
expectedExampleCount = 13
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue