Adds an optional `subfolder` field to the Repository object in server.json schema to support MCP servers located within subdirectories of monorepo structures. ## Motivation and Context Addresses issue #360 . The current MCP registry schema doesn't explicitly define how to reference servers that are located within a subdirectory of a Git repository (a "monorepo" structure). This is a common pattern (e.g., [microsoft/mcp](https://github.com/microsoft/mcp) & [awslabs/mcp](https://github.com/awslabs/mcp). ## How Has This Been Tested? - **Validator tests**: Added 6 new test cases covering valid subfolders and security validation (path traversal, absolute paths, invalid characters, etc.) - **Schema validation**: All 11 examples in `examples.md` validate successfully against both `server.schema.json` and `registry-schema.json` - **End-to-end validation**: Publisher tool (`mcp-publisher init`) works correctly and generates valid templates without subfolder - **Backward compatibility**: Existing server.json files without subfolder continue to work unchanged ## Breaking Changes **None.** This is a fully backward-compatible change: - The `subfolder` field is optional (uses `omitempty` JSON tag) - Existing server.json files work unchanged - Publisher tools generate valid templates without subfolder by default - All existing validation and schemas remain compatible ## 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 ### Implementation Details **Schema Changes:** - Added `subfolder` property to Repository definition in `server.schema.json` and `openapi.yaml` - Field is optional with description and example (`"src/everything"`) **Go Model:** - Added `Subfolder string` field to Repository struct with `json:"subfolder,omitempty"` tag - Maintains backward compatibility through omitempty behavior **Security-Focused Validation:** - Validates subfolder is a clean relative path (no leading `/`) - Prevents path traversal attacks (no `..` sequences) - Blocks malformed paths (no trailing `/`, empty segments, invalid characters) - Uses regex validation for allowed characters: `^[a-zA-Z0-9\-_./]+$` **Documentation:** - Added monorepo example to `examples.md` demonstrating subfolder usage - Updated `repository_references.md` with concise subfolder documentation - Maintained consistency across all documentation with same example path **Testing:** - 6 new integration tests in validator covering security and format validation - Updated example count validation tool for new example - All existing tests continue to pass ### Design Decisions 1. **Optional by design**: Most servers don't need subfolder (single-repo pattern is common) 2. **Clean separation**: Repository URL stays clean, subfolder is separate metadata 3. **Security first**: Comprehensive validation prevents common path-based attacks 4. **Publisher tool unchanged**: Default templates don't include subfolder to avoid confusion for typical use cases This enables publishers with monorepo structures to clearly specify server locations while maintaining simplicity for the common single-repository case.
185 lines
4.9 KiB
Go
185 lines
4.9 KiB
Go
package validators
|
|
|
|
import (
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
// Regular expressions for validating repository URLs
|
|
// These regex patterns ensure the URL is in the format of a valid GitHub or GitLab repository
|
|
// For example: // - GitHub: https://github.com/user/repo
|
|
githubURLRegex = regexp.MustCompile(`^https?://(www\.)?github\.com/[\w.-]+/[\w.-]+/?$`)
|
|
gitlabURLRegex = regexp.MustCompile(`^https?://(www\.)?gitlab\.com/[\w.-]+/[\w.-]+/?$`)
|
|
)
|
|
|
|
// IsValidRepositoryURL checks if the given URL is valid for the specified repository source
|
|
func IsValidRepositoryURL(source RepositorySource, url string) bool {
|
|
switch source {
|
|
case SourceGitHub:
|
|
return githubURLRegex.MatchString(url)
|
|
case SourceGitLab:
|
|
return gitlabURLRegex.MatchString(url)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasNoSpaces checks if a string contains no spaces
|
|
func HasNoSpaces(s string) bool {
|
|
return !strings.Contains(s, " ")
|
|
}
|
|
|
|
// extractTemplateVariables extracts template variables from a URL string
|
|
// e.g., "http://{host}:{port}/mcp" returns ["host", "port"]
|
|
func extractTemplateVariables(url string) []string {
|
|
re := regexp.MustCompile(`\{([^}]+)\}`)
|
|
matches := re.FindAllStringSubmatch(url, -1)
|
|
|
|
var variables []string
|
|
for _, match := range matches {
|
|
if len(match) > 1 {
|
|
variables = append(variables, match[1])
|
|
}
|
|
}
|
|
return variables
|
|
}
|
|
|
|
// replaceTemplateVariables replaces template variables with placeholder values for URL validation
|
|
func replaceTemplateVariables(rawURL string) string {
|
|
// Replace common template variables with valid placeholder values for parsing
|
|
templateReplacements := map[string]string{
|
|
"{host}": "example.com",
|
|
"{port}": "8080",
|
|
"{path}": "api",
|
|
"{protocol}": "http",
|
|
"{scheme}": "http",
|
|
}
|
|
|
|
result := rawURL
|
|
for placeholder, replacement := range templateReplacements {
|
|
result = strings.ReplaceAll(result, placeholder, replacement)
|
|
}
|
|
|
|
// Handle any remaining {variable} patterns with generic placeholder
|
|
re := regexp.MustCompile(`\{[^}]+\}`)
|
|
result = re.ReplaceAllString(result, "placeholder")
|
|
|
|
return result
|
|
}
|
|
|
|
// IsValidURL checks if a URL is in valid format (basic structure validation)
|
|
func IsValidURL(rawURL string) bool {
|
|
// Replace template variables with placeholders for parsing
|
|
testURL := replaceTemplateVariables(rawURL)
|
|
|
|
// Parse the URL
|
|
u, err := url.Parse(testURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Check if scheme is present (http or https)
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return false
|
|
}
|
|
|
|
if u.Host == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// IsValidSubfolderPath checks if a subfolder path is valid
|
|
func IsValidSubfolderPath(path string) bool {
|
|
// Empty path is valid (subfolder is optional)
|
|
if path == "" {
|
|
return true
|
|
}
|
|
|
|
// Must not start with / (must be relative)
|
|
if strings.HasPrefix(path, "/") {
|
|
return false
|
|
}
|
|
|
|
// Must not end with / (clean path format)
|
|
if strings.HasSuffix(path, "/") {
|
|
return false
|
|
}
|
|
|
|
// Check for valid path characters (alphanumeric, dash, underscore, dot, forward slash)
|
|
validPathRegex := regexp.MustCompile(`^[a-zA-Z0-9\-_./]+$`)
|
|
if !validPathRegex.MatchString(path) {
|
|
return false
|
|
}
|
|
|
|
// Check that path segments are valid
|
|
segments := strings.Split(path, "/")
|
|
for _, segment := range segments {
|
|
// Disallow empty segments ("//"), current dir ("."), and parent dir ("..")
|
|
if segment == "" || segment == "." || segment == ".." {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// IsValidRemoteURL checks if a URL is valid for remotes (stricter than packages - no localhost allowed)
|
|
func IsValidRemoteURL(rawURL string) bool {
|
|
// First check basic URL structure
|
|
if !IsValidURL(rawURL) {
|
|
return false
|
|
}
|
|
|
|
// Parse the URL to check for localhost restriction
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Reject localhost URLs for remotes (security/production concerns)
|
|
hostname := u.Hostname()
|
|
if hostname == "localhost" || hostname == "127.0.0.1" || strings.HasSuffix(hostname, ".localhost") {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// IsValidTemplatedURL validates a URL with template variables against available variables
|
|
// For packages: validates that template variables reference package arguments or environment variables
|
|
// For remotes: disallows template variables entirely
|
|
func IsValidTemplatedURL(rawURL string, availableVariables []string, allowTemplates bool) bool {
|
|
// First check basic URL structure
|
|
if !IsValidURL(rawURL) {
|
|
return false
|
|
}
|
|
|
|
// Extract template variables from URL
|
|
templateVars := extractTemplateVariables(rawURL)
|
|
|
|
// If no templates are found, it's a valid static URL
|
|
if len(templateVars) == 0 {
|
|
return true
|
|
}
|
|
|
|
// If templates are not allowed (e.g., for remotes), reject URLs with templates
|
|
if !allowTemplates {
|
|
return false
|
|
}
|
|
|
|
// Validate that all template variables are available
|
|
availableSet := make(map[string]bool)
|
|
for _, v := range availableVariables {
|
|
availableSet[v] = true
|
|
}
|
|
|
|
for _, templateVar := range templateVars {
|
|
if !availableSet[templateVar] {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|