registry/internal/validators/utils.go
Tadas Antanavicius b58836c48e
feat: Add LocalTransport/RemoteTransport with URL template variables (#570)
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>
2025-12-11 09:17:32 -08:00

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
}