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:
adam jones 2025-10-07 13:30:32 +01:00 • committed by GitHub
commit 66993e9121
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 329 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -38,3 +38,7 @@ const (
SourceGitHub RepositorySource = "github"
SourceGitLab RepositorySource = "gitlab"
)
const (
SchemeHTTPS = "https"
)

View file

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

View file

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

View file

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

View file

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