Co-authored with Claude Code, but I've closely reviewed/modified/vetted
all changes; ready for final review.
## Summary
This PR adds LocalTransport and RemoteTransport separation with URL
template variable support for remote servers, enabling e.g. multi-tenant
deployments with configurable endpoints.
**Key additions:**
- LocalTransport (for Packages) and RemoteTransport (for Remotes)
- URL template variables for remote transports (e.g., `{tenant_id}` in
URLs)
- Pattern validation ensuring URLs are URL-like while allowing templates
- Full test coverage via Go validator tests and documentation examples
## ⚠️ server.json Update
This PR updates the `server.json` schema. A new schema version will be
cut after this lands.
## Problem
The current schema describes URL templating with `{curly_braces}` for
transports but provides no mechanism for Remote contexts to define what
those variables should resolve to, making the feature unusable for
remote servers.
Referenced issue: #394
- Users need multi-tenant remote servers with different endpoints per
deployment
- Each tenant/region has its own URL (e.g., `us-cell1.example.com`,
`emea-cell1.example.com`)
- Current schema doesn't support parameterized remote URLs
## Solution
### Schema Architecture
**LocalTransport** (for Package context):
```typescript
LocalTransport = StdioTransport | StreamableHttpTransport | SseTransport
```
- Used in Package context (non-breaking rename of existing behavior)
- Variables in `{curly_braces}` reference parent Package
arguments/environment variables
- Supports all three transport types including stdio
**RemoteTransport** (for Remote context):
```typescript
RemoteTransport = (StreamableHttpTransport | SseTransport) + { variables: object }
```
- Extends StreamableHttp/Sse via `allOf` composition - no duplication!
- Adds `variables` object for URL templating
- Only supports streamable-http and sse (stdio not valid for remotes)
### Key Changes
**1. Schema Updates** (`openapi.yaml`, `server.schema.json`):
- Added `LocalTransport` union type for Package context
- Added `RemoteTransport` with `allOf` composition extending base
transports
- Updated Package to reference `LocalTransport`
- Updated remotes to reference `RemoteTransport`
- Added URL pattern validation: `^https?://[^\\s]+$` to both
StreamableHttp and SSE
- Removed `format: uri` from SSE (was blocking template variables)
**2. Go Types** (`pkg/model/types.go`):
- Base `Transport` struct unchanged for Package context
- `RemoteTransport` struct for remotes with `Variables` field
- Both work with existing validation infrastructure
**3. Validators** (`internal/validators/`):
- `validateRemoteTransport()` validates Remote-specific constraints
- `collectRemoteTransportVariables()` extracts available variables
- `IsValidTemplatedURL()` validates template variables reference defined
variables
- `IsValidRemoteURL()` handles template variable substitution before
localhost check
- 6 new test cases for remote transport variable validation
**4. Documentation Examples** (`generic-server-json.md`):
- Remote Server with URL Templating (StreamableHttp with `{tenant_id}`)
- SSE Remote with URL Templating (SSE with `{tenant_id}`)
- Local Server with URL Templating (Package with `{port}`)
- All examples validate via existing `validate-examples` tool
## Example: Remote Transport with Variables
```json
{
"remotes": [
{
"type": "streamable-http",
"url": "https://api.example.com/mcp/{tenant_id}/{region}",
"variables": {
"tenant_id": {
"description": "Tenant identifier",
"isRequired": true,
"choices": ["us-cell1", "emea-cell1", "apac-cell1"]
},
"region": {
"description": "Deployment region",
"isRequired": true
}
}
}
]
}
```
Clients configure the variables, and `{tenant_id}` and `{region}` get
replaced to connect to tenant-specific endpoints like
`https://api.example.com/mcp/us-cell1/east`.
## Architecture Details
**Context-based variable resolution:**
- **Package + LocalTransport**: `{port}` references `--port` argument or
`PORT` env var from parent Package
- **Remote + RemoteTransport**: `{tenant_id}` references
`variables.tenant_id` defined in the transport itself
- **Code reuse**: StreamableHttp/Sse definitions shared via `allOf`, not
duplicated
- **Validation**: Template variables validated against available
variables for each context
---
## Follow-on Work
Follow-on work will be done to adopt the feedback in
https://github.com/modelcontextprotocol/registry/pull/570#issuecomment-3367417636
regarding variable naming/prioritzation/scoping conventions, but should
not have any impact on the shape being introduced here (just
validations).
---------
Co-authored-by: yuna0x0 <yuna@yuna0x0.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Connor Peet <connor@peet.io>
193 lines
5.3 KiB
Go
193 lines
5.3 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 context-appropriate placeholders
|
|
// If the variable is in a port position (after a colon in the host), use a numeric placeholder
|
|
// Pattern: :/{variable} or :{variable}/ or :{variable} at end
|
|
portRe := regexp.MustCompile(`:(\{[^}]+\})(/|$)`)
|
|
result = portRe.ReplaceAllString(result, ":8080$2")
|
|
|
|
// Replace any other 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
|
|
}
|
|
|
|
// Replace template variables with placeholders before parsing for localhost check
|
|
testURL := replaceTemplateVariables(rawURL)
|
|
|
|
// Parse the URL to check for localhost restriction
|
|
u, err := url.Parse(testURL)
|
|
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
|
|
}
|
|
|
|
if u.Scheme != "https" {
|
|
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: validates that template variables reference the transport's variables map
|
|
func IsValidTemplatedURL(rawURL string, availableVariables []string) 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
|
|
}
|
|
|
|
// 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
|
|
}
|