Add icon support to server.json schema (#620)
Implements icon support based on the Model Context Protocol specification, allowing servers to specify display icons with optional size, MIME type, and theme variants. ## Changes - Add `Icon` type to `pkg/model/types.go` with struct tags for automatic API validation - Add `icons` field to `ServerJSON` in `pkg/api/v0/types.go` - Add icon validation in `internal/validators/validators.go`: - Only HTTPS URLs allowed (no HTTP or data URIs) - Maximum URL length of 255 characters - Validates absolute URLs with proper scheme - Update JSON schema and OpenAPI documentation with Icon definitions - Add comprehensive test cases for icon validation - Add example icon to seed.json (Airtable server) - Change `websiteUrl` validation to HTTPS-only for consistency ## Implementation Details The implementation uses a two-layer validation architecture: 1. **Huma framework** validates struct tags (`required`, `maxLength`, `enum`, `pattern`) and returns 422 for violations 2. **Custom validators** handle business logic (HTTPS scheme requirement, absolute URL requirement) and return 400 for violations This approach leverages Huma's automatic validation while keeping business-specific rules in the validator layer. ## Icon Field Specification - `src` (required): HTTPS URL to icon resource, max 255 characters - `mimeType` (optional): One of `image/png`, `image/jpeg`, `image/jpg`, `image/svg+xml`, `image/webp` - `sizes` (optional): Array of size strings in "WxH" format or "any" for scalable formats - `theme` (optional): Either "light" or "dark" to indicate intended background Multiple icons can be provided (e.g., for different themes or sizes).
This commit is contained in:
parent
4f453ff2bd
commit
66993e9121
8 changed files with 329 additions and 4 deletions
|
|
@ -8,6 +8,13 @@
|
|||
"source": "github"
|
||||
},
|
||||
"version": "1.7.2",
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://airtable.com/images/favicon/favicon-32x32.png",
|
||||
"mimeType": "image/png",
|
||||
"sizes": ["32x32"]
|
||||
}
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
|
|
|
|||
|
|
@ -457,6 +457,37 @@ components:
|
|||
items:
|
||||
$ref: '#/components/schemas/KeyValueInput'
|
||||
|
||||
Icon:
|
||||
type: object
|
||||
description: An optionally-sized icon that can be displayed in a user interface
|
||||
required:
|
||||
- src
|
||||
properties:
|
||||
src:
|
||||
type: string
|
||||
format: uri
|
||||
description: "A standard URI pointing to an icon resource. Must be an HTTPS URL. Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the server or a trusted domain. Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain executable JavaScript."
|
||||
example: "https://example.com/icon.png"
|
||||
maxLength: 255
|
||||
mimeType:
|
||||
type: string
|
||||
description: "Optional MIME type override if the source MIME type is missing or generic. Must be one of: image/png, image/jpeg, image/jpg, image/svg+xml, image/webp."
|
||||
enum: [image/png, image/jpeg, image/jpg, image/svg+xml, image/webp]
|
||||
example: "image/png"
|
||||
sizes:
|
||||
type: array
|
||||
description: "Optional array of strings that specify sizes at which the icon can be used. Each string should be in WxH format (e.g., '48x48', '96x96') or 'any' for scalable formats like SVG. If not provided, the client should assume that the icon can be used at any size."
|
||||
items:
|
||||
type: string
|
||||
pattern: "^(\\d+x\\d+|any)$"
|
||||
examples:
|
||||
- ["48x48", "96x96"]
|
||||
- ["any"]
|
||||
theme:
|
||||
type: string
|
||||
description: "Optional specifier for the theme this icon is designed for. 'light' indicates the icon is designed to be used with a light background, and 'dark' indicates the icon is designed to be used with a dark background. If not provided, the client should assume the icon can be used with any theme."
|
||||
enum: [light, dark]
|
||||
|
||||
ServerDetail:
|
||||
description: Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.
|
||||
type: object
|
||||
|
|
@ -488,6 +519,11 @@ components:
|
|||
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"
|
||||
icons:
|
||||
type: array
|
||||
description: "Optional set of sized icons that the client can display in a user interface. Clients that support rendering icons MUST support at least the following MIME types: image/png and image/jpeg (safe, universal compatibility). Clients SHOULD also support: image/svg+xml (scalable but requires security precautions) and image/webp (modern, efficient format)."
|
||||
items:
|
||||
$ref: '#/components/schemas/Icon'
|
||||
$schema:
|
||||
type: string
|
||||
format: uri
|
||||
|
|
|
|||
|
|
@ -376,6 +376,51 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Icon": {
|
||||
"type": "object",
|
||||
"description": "An optionally-sized icon that can be displayed in a user interface.",
|
||||
"required": [
|
||||
"src"
|
||||
],
|
||||
"properties": {
|
||||
"src": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "A standard URI pointing to an icon resource. Must be an HTTPS URL. Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the server or a trusted domain. Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain executable JavaScript.",
|
||||
"example": "https://example.com/icon.png",
|
||||
"maxLength": 255
|
||||
},
|
||||
"mimeType": {
|
||||
"type": "string",
|
||||
"description": "Optional MIME type override if the source MIME type is missing or generic. Must be one of: image/png, image/jpeg, image/jpg, image/svg+xml, image/webp.",
|
||||
"enum": [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/svg+xml",
|
||||
"image/webp"
|
||||
],
|
||||
"example": "image/png"
|
||||
},
|
||||
"sizes": {
|
||||
"type": "array",
|
||||
"description": "Optional array of strings that specify sizes at which the icon can be used. Each string should be in WxH format (e.g., '48x48', '96x96') or 'any' for scalable formats like SVG. If not provided, the client should assume that the icon can be used at any size.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+x\\d+|any)$",
|
||||
"examples": ["48x48", "96x96", "any"]
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "Optional specifier for the theme this icon is designed for. 'light' indicates the icon is designed to be used with a light background, and 'dark' indicates the icon is designed to be used with a dark background. If not provided, the client should assume the icon can be used with any theme.",
|
||||
"enum": [
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerDetail": {
|
||||
"description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.",
|
||||
"type": "object",
|
||||
|
|
@ -423,6 +468,13 @@
|
|||
"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"
|
||||
},
|
||||
"icons": {
|
||||
"type": "array",
|
||||
"description": "Optional set of sized icons that the client can display in a user interface. Clients that support rendering icons MUST support at least the following MIME types: image/png and image/jpeg (safe, universal compatibility). Clients SHOULD also support: image/svg+xml (scalable but requires security precautions) and image/webp (modern, efficient format).",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Icon"
|
||||
}
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
|
|
|
|||
|
|
@ -38,3 +38,7 @@ const (
|
|||
SourceGitHub RepositorySource = "github"
|
||||
SourceGitLab RepositorySource = "gitlab"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemeHTTPS = "https"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@ func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Validate icons if provided
|
||||
if err := validateIcons(serverJSON.Icons); 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 {
|
||||
|
|
@ -153,9 +158,9 @@ func validateWebsiteURL(websiteURL string) error {
|
|||
return fmt.Errorf("websiteUrl must be absolute (include scheme): %s", websiteURL)
|
||||
}
|
||||
|
||||
// Only allow HTTP/HTTPS schemes for security
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return fmt.Errorf("websiteUrl must use http or https scheme: %s", websiteURL)
|
||||
// Only allow HTTPS scheme for security
|
||||
if parsedURL.Scheme != SchemeHTTPS {
|
||||
return fmt.Errorf("websiteUrl must use https scheme: %s", websiteURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -175,6 +180,42 @@ func validateTitle(title string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateIcons(icons []model.Icon) error {
|
||||
// Skip validation if no icons are provided (optional field)
|
||||
if len(icons) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate each icon
|
||||
for i, icon := range icons {
|
||||
if err := validateIcon(&icon); err != nil {
|
||||
return fmt.Errorf("invalid icon at index %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateIcon(icon *model.Icon) error {
|
||||
// Parse the URL to ensure it's valid
|
||||
parsedURL, err := url.Parse(icon.Src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid icon src URL: %w", err)
|
||||
}
|
||||
|
||||
// Ensure it's an absolute URL
|
||||
if !parsedURL.IsAbs() {
|
||||
return fmt.Errorf("icon src must be an absolute URL (include scheme): %s", icon.Src)
|
||||
}
|
||||
|
||||
// Only allow HTTPS scheme for security (no HTTP or data: URIs)
|
||||
if parsedURL.Scheme != SchemeHTTPS {
|
||||
return fmt.Errorf("icon src must use https scheme (got %s): %s", parsedURL.Scheme, icon.Src)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePackageField(obj *model.Package) error {
|
||||
if !HasNoSpaces(obj.Identifier) {
|
||||
return ErrPackageNameHasSpaces
|
||||
|
|
|
|||
|
|
@ -452,7 +452,7 @@ func TestValidate(t *testing.T) {
|
|||
Version: "1.0.0",
|
||||
WebsiteURL: "ftp://example.com/docs",
|
||||
},
|
||||
expectedError: "websiteUrl must use http or https scheme: ftp://example.com/docs",
|
||||
expectedError: "websiteUrl must use https scheme: ftp://example.com/docs",
|
||||
},
|
||||
{
|
||||
name: "server with malformed websiteUrl",
|
||||
|
|
@ -1851,6 +1851,170 @@ func TestValidateTitle(t *testing.T) {
|
|||
},
|
||||
expectedError: "title cannot be only whitespace",
|
||||
},
|
||||
// Icon validation tests
|
||||
{
|
||||
name: "Accepts valid icon with HTTPS URL",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "https://example.com/icon.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Accepts icon with all optional fields",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "https://example.com/icon.png",
|
||||
MimeType: stringPtr("image/png"),
|
||||
Sizes: []string{"48x48", "96x96"},
|
||||
Theme: stringPtr("light"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Accepts icon with 'any' size for SVG",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "https://example.com/icon.svg",
|
||||
MimeType: stringPtr("image/svg+xml"),
|
||||
Sizes: []string{"any"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Accepts icon with dark theme",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "https://example.com/icon-dark.png",
|
||||
Theme: stringPtr("dark"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Accepts multiple icons",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "https://example.com/icon-light.png",
|
||||
Theme: stringPtr("light"),
|
||||
},
|
||||
{
|
||||
Src: "https://example.com/icon-dark.png",
|
||||
Theme: stringPtr("dark"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Rejects icon with HTTP URL (not HTTPS)",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "http://example.com/icon.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "icon src must use https scheme",
|
||||
},
|
||||
{
|
||||
name: "Rejects icon with data URI",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "icon src must use https scheme",
|
||||
},
|
||||
{
|
||||
name: "Rejects icon with relative URL",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Schema: model.CurrentSchemaURL,
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Icons: []model.Icon{
|
||||
{
|
||||
Src: "/icon.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "icon src must be an absolute URL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -1865,3 +2029,8 @@ func TestValidateTitle(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for creating string pointers in tests
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ type ServerJSON struct {
|
|||
Repository model.Repository `json:"repository,omitempty"`
|
||||
Version string `json:"version"`
|
||||
WebsiteURL string `json:"websiteUrl,omitempty"`
|
||||
Icons []model.Icon `json:"icons,omitempty"`
|
||||
Packages []model.Package `json:"packages,omitempty"`
|
||||
Remotes []model.Transport `json:"remotes,omitempty"`
|
||||
Meta *ServerMeta `json:"_meta,omitempty"`
|
||||
|
|
|
|||
|
|
@ -90,3 +90,18 @@ type Argument struct {
|
|||
IsRepeated bool `json:"isRepeated,omitempty"`
|
||||
ValueHint string `json:"valueHint,omitempty"`
|
||||
}
|
||||
|
||||
// Icon represents an optionally-sized icon that can be displayed in a user interface
|
||||
type Icon struct {
|
||||
// Src is a standard URI pointing to an icon resource (HTTPS URL only for registry)
|
||||
Src string `json:"src" required:"true" format:"uri" maxLength:"255"`
|
||||
// MimeType is an optional MIME type override if the source MIME type is missing or generic
|
||||
MimeType *string `json:"mimeType,omitempty" enum:"image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"`
|
||||
// Sizes is an optional array of strings that specify sizes at which the icon can be used
|
||||
// Each string should be in WxH format (e.g., "48x48", "96x96") or "any" for scalable formats
|
||||
Sizes []string `json:"sizes,omitempty" pattern:"^(\\d+x\\d+|any)$"`
|
||||
// Theme is an optional specifier for the theme this icon is designed for
|
||||
// "light" indicates the icon is designed for light backgrounds
|
||||
// "dark" indicates the icon is designed for dark backgrounds
|
||||
Theme *string `json:"theme,omitempty" enum:"light,dark"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue