diff --git a/docs/server-json/examples.md b/docs/server-json/examples.md index 5cba5cf..e38d092 100644 --- a/docs/server-json/examples.md +++ b/docs/server-json/examples.md @@ -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: diff --git a/docs/server-json/repository_references.md b/docs/server-json/repository_references.md index 6e608c4..abb48c1 100644 --- a/docs/server-json/repository_references.md +++ b/docs/server-json/repository_references.md @@ -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 ` 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): diff --git a/docs/server-json/server.schema.json b/docs/server-json/server.schema.json index 84195bc..333d8e3 100644 --- a/docs/server-json/server.schema.json +++ b/docs/server-json/server.schema.json @@ -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 } diff --git a/docs/server-registry-api/openapi.yaml b/docs/server-registry-api/openapi.yaml index 6792c75..d62d90b 100644 --- a/docs/server-registry-api/openapi.yaml +++ b/docs/server-registry-api/openapi.yaml @@ -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 diff --git a/internal/validators/constants.go b/internal/validators/constants.go index ad19c7b..fa5392c 100644 --- a/internal/validators/constants.go +++ b/internal/validators/constants.go @@ -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") diff --git a/internal/validators/utils.go b/internal/validators/utils.go index 61b122f..52c4b00 100644 --- a/internal/validators/utils.go +++ b/internal/validators/utils.go @@ -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 diff --git a/internal/validators/validators.go b/internal/validators/validators.go index d6c2a6a..4ce0869 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -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 } diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index e9c71bf..f878cb8 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -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{ diff --git a/pkg/model/types.go b/pkg/model/types.go index b396ba8..b7cc97b 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -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 diff --git a/tools/validate-examples/main.go b/tools/validate-examples/main.go index 0af0c42..a4edd21 100644 --- a/tools/validate-examples/main.go +++ b/tools/validate-examples/main.go @@ -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() {