Add optional subfolder field to Repository schema (#363)
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.
This commit is contained in:
parent
e0e51eae37
commit
0ffcd36047
10 changed files with 195 additions and 5 deletions
|
|
@ -46,6 +46,47 @@
|
|||
}
|
||||
```
|
||||
|
||||
## Server in a Monorepo with Subfolder
|
||||
|
||||
For MCP servers located within a subdirectory of a larger repository (monorepo structure), use the `subfolder` field to specify the relative path:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
||||
"name": "io.modelcontextprotocol/everything",
|
||||
"description": "MCP server that exercises all the features of the MCP protocol",
|
||||
"status": "active",
|
||||
"repository": {
|
||||
"url": "https://github.com/modelcontextprotocol/servers",
|
||||
"source": "github",
|
||||
"subfolder": "src/everything"
|
||||
},
|
||||
"version_detail": {
|
||||
"version": "0.6.2"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registry_type": "npm",
|
||||
"registry_base_url": "https://registry.npmjs.org",
|
||||
"identifier": "@modelcontextprotocol/everything",
|
||||
"version": "0.6.2",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
],
|
||||
"_meta": {
|
||||
"publisher": {
|
||||
"tool": "npm-publisher",
|
||||
"version": "1.0.1",
|
||||
"build_info": {
|
||||
"timestamp": "2023-12-01T10:30:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Constant (fixed) arguments needed to start the MCP server
|
||||
|
||||
Suppose your MCP server application requires a `mcp start` CLI arguments to start in MCP server mode. Express these as positional arguments like this:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ Consumers of the `server.json` metadata MAY use the `source` property to determi
|
|||
|
||||
The `url` property MAY be used to browse the source code. Some source forges, such as GitHub, support `git clone <url>` on the URL, which also works for web browsing. For the purposes of the Official MCP Registry, the URL MUST be accessible in a web browser.
|
||||
|
||||
The optional `subfolder` property MAY be used to specify a relative path from the repository root to the location of the MCP server within a monorepo structure. The value MUST be a clean relative path.
|
||||
|
||||
The `id` property is owned and determined by the source forge, such as GitHub. This value SHOULD be stable across repository renames and, if applicable on the source forge, MAY be used to detect repository resurrection attacks. If a repository is renamed, the `id` value SHOULD remain constant. If the repository is deleted and then recreated later, the `id` value SHOULD change.
|
||||
|
||||
Determining the `id` is specific to the source forge. For GitHub, the following [GitHub CLI](https://cli.github.com/) command MAY be used (works for both public and private repositories):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
"id": {
|
||||
"type": "string",
|
||||
"example": "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9"
|
||||
},
|
||||
"subfolder": {
|
||||
"type": "string",
|
||||
"description": "Optional relative path from repository root to the server location within a monorepo or nested package structure",
|
||||
"example": "src/everything"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -448,7 +453,7 @@
|
|||
"additionalProperties": true
|
||||
},
|
||||
"io.modelcontextprotocol.registry": {
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"description": "Official MCP registry metadata (read-only, added by registry)",
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,10 @@ components:
|
|||
id:
|
||||
type: string
|
||||
example: "b94b5f7e-c7c6-d760-2c78-a5e9b8a5b8c9"
|
||||
subfolder:
|
||||
type: string
|
||||
description: "Optional relative path from repository root to the server location within a monorepo structure"
|
||||
example: "src/everything"
|
||||
|
||||
Server:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import "errors"
|
|||
var (
|
||||
// Repository validation errors
|
||||
ErrInvalidRepositoryURL = errors.New("invalid repository URL")
|
||||
ErrInvalidSubfolderPath = errors.New("invalid subfolder path")
|
||||
|
||||
// Package validation errors
|
||||
ErrPackageNameHasSpaces = errors.New("package name cannot contain spaces")
|
||||
|
|
|
|||
|
|
@ -90,6 +90,41 @@ func IsValidURL(rawURL string) bool {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ func validateRepository(obj *model.Repository) error {
|
|||
return fmt.Errorf("%w: %s", ErrInvalidRepositoryURL, obj.URL)
|
||||
}
|
||||
|
||||
// validate subfolder if present
|
||||
if obj.Subfolder != "" && !IsValidSubfolderPath(obj.Subfolder) {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidSubfolderPath, obj.Subfolder)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,102 @@ func TestValidate(t *testing.T) {
|
|||
},
|
||||
expectedError: validators.ErrInvalidRepositoryURL.Error(),
|
||||
},
|
||||
{
|
||||
name: "server with valid repository subfolder",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
Subfolder: "servers/my-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "server with repository subfolder containing path traversal",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
Subfolder: "../parent/folder",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
expectedError: validators.ErrInvalidSubfolderPath.Error(),
|
||||
},
|
||||
{
|
||||
name: "server with repository subfolder starting with slash",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
Subfolder: "/absolute/path",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
expectedError: validators.ErrInvalidSubfolderPath.Error(),
|
||||
},
|
||||
{
|
||||
name: "server with repository subfolder ending with slash",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
Subfolder: "servers/my-server/",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
expectedError: validators.ErrInvalidSubfolderPath.Error(),
|
||||
},
|
||||
{
|
||||
name: "server with repository subfolder containing invalid characters",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
Subfolder: "servers/my server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
expectedError: validators.ErrInvalidSubfolderPath.Error(),
|
||||
},
|
||||
{
|
||||
name: "server with repository subfolder containing empty segments",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
Subfolder: "servers//my-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
expectedError: validators.ErrInvalidSubfolderPath.Error(),
|
||||
},
|
||||
{
|
||||
name: "package with spaces in name",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
|
|
|
|||
|
|
@ -35,9 +35,10 @@ type Package struct {
|
|||
|
||||
// Repository represents a source code repository as defined in the spec
|
||||
type Repository struct {
|
||||
URL string `json:"url"`
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Subfolder string `json:"subfolder,omitempty"`
|
||||
}
|
||||
|
||||
// Format represents the input format type
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const (
|
|||
// IMPORTANT: Only change this count if you have intentionally added or removed examples
|
||||
// from the examples.md file. This check prevents accidental formatting changes from
|
||||
// causing examples to be skipped during validation.
|
||||
expectedExampleCount = 10
|
||||
expectedExampleCount = 11
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue