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>
This commit is contained in:
Tadas Antanavicius 2025-12-11 09:17:32 -08:00 • committed by GitHub
commit b58836c48e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 350 additions and 52 deletions

View file

@ -13,8 +13,9 @@ var (
ErrReservedVersionString = errors.New("version string 'latest' is reserved and cannot be used")
ErrVersionLooksLikeRange = errors.New("version must be a specific version, not a range")
// Remote validation errors
ErrInvalidRemoteURL = errors.New("invalid remote URL")
// Transport validation errors
ErrInvalidPackageTransportURL = errors.New("invalid package transport URL")
ErrInvalidRemoteURL = errors.New("invalid remote URL")
// Registry validation errors
ErrUnsupportedRegistryBaseURL = errors.New("unsupported registry base URL")

View file

@ -61,7 +61,13 @@ func replaceTemplateVariables(rawURL string) string {
result = strings.ReplaceAll(result, placeholder, replacement)
}
// Handle any remaining {variable} patterns with generic placeholder
// 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")
@ -132,8 +138,11 @@ func IsValidRemoteURL(rawURL string) bool {
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(rawURL)
u, err := url.Parse(testURL)
if err != nil {
return false
}
@ -153,8 +162,8 @@ func IsValidRemoteURL(rawURL string) bool {
// 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 {
// 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
@ -168,11 +177,6 @@ func IsValidTemplatedURL(rawURL string, availableVariables []string, allowTempla
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 {

View file

@ -363,6 +363,20 @@ func collectAvailableVariables(pkg *model.Package) []string {
return variables
}
// collectRemoteTransportVariables extracts available variable names from a remote transport
func collectRemoteTransportVariables(transport *model.Transport) []string {
var variables []string
// Add variable names from the Variables map
for variableName := range transport.Variables {
if variableName != "" {
variables = append(variables, variableName)
}
}
return variables
}
// validatePackageTransport validates a package's transport with templating support
func validatePackageTransport(transport *model.Transport, availableVariables []string) error {
// Validate transport type is supported
@ -379,14 +393,14 @@ func validatePackageTransport(transport *model.Transport, availableVariables []s
return fmt.Errorf("url is required for %s transport type", transport.Type)
}
// Validate URL format with template variable support
if !IsValidTemplatedURL(transport.URL, availableVariables, true) {
if !IsValidTemplatedURL(transport.URL, availableVariables) {
// Check if it's a template variable issue or basic URL issue
templateVars := extractTemplateVariables(transport.URL)
if len(templateVars) > 0 {
return fmt.Errorf("%w: template variables in URL %s reference undefined variables. Available variables: %v",
ErrInvalidRemoteURL, transport.URL, availableVariables)
ErrInvalidPackageTransportURL, transport.URL, availableVariables)
}
return fmt.Errorf("%w: %s", ErrInvalidRemoteURL, transport.URL)
return fmt.Errorf("%w: %s", ErrInvalidPackageTransportURL, transport.URL)
}
return nil
default:
@ -394,7 +408,7 @@ func validatePackageTransport(transport *model.Transport, availableVariables []s
}
}
// validateRemoteTransport validates a remote transport (no templating allowed)
// validateRemoteTransport validates a remote transport with optional templating
func validateRemoteTransport(obj *model.Transport) error {
// Validate transport type is supported - remotes only support streamable-http and sse
switch obj.Type {
@ -403,7 +417,22 @@ func validateRemoteTransport(obj *model.Transport) error {
if obj.URL == "" {
return fmt.Errorf("url is required for %s transport type", obj.Type)
}
// Validate URL format (no templates allowed for remotes, no localhost)
// Collect available variables from the transport's Variables field
availableVariables := collectRemoteTransportVariables(obj)
// Validate URL format with template variable support
if !IsValidTemplatedURL(obj.URL, availableVariables) {
// Check if it's a template variable issue or basic URL issue
templateVars := extractTemplateVariables(obj.URL)
if len(templateVars) > 0 {
return fmt.Errorf("%w: template variables in URL %s reference undefined variables. Available variables: %v",
ErrInvalidRemoteURL, obj.URL, availableVariables)
}
return fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL)
}
// Additional check: reject localhost URLs for remotes (like the old IsValidRemoteURL did)
if !IsValidRemoteURL(obj.URL) {
return fmt.Errorf("%w: %s", ErrInvalidRemoteURL, obj.URL)
}

View file

@ -1598,6 +1598,138 @@ func TestValidate_TransportValidation(t *testing.T) {
},
expectedError: "invalid remote URL",
},
// Remote transport variable tests
{
name: "remote transport with URL variables - valid",
serverDetail: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
Remotes: []model.Transport{
{
Type: "streamable-http",
URL: "https://example.com/mcp/{tenant_id}",
Variables: map[string]model.Input{
"tenant_id": {
Description: "Tenant identifier",
IsRequired: true,
},
},
},
},
},
expectedError: "",
},
{
name: "remote transport with multiple URL variables - valid",
serverDetail: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
Remotes: []model.Transport{
{
Type: "streamable-http",
URL: "https://{region}.example.com/mcp/{tenant_id}",
Variables: map[string]model.Input{
"region": {
Description: "Server region",
Choices: []string{"us-east-1", "eu-west-1"},
},
"tenant_id": {
Description: "Tenant identifier",
IsRequired: true,
},
},
},
},
},
expectedError: "",
},
{
name: "remote transport with undefined URL variable - invalid",
serverDetail: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
Remotes: []model.Transport{
{
Type: "streamable-http",
URL: "https://example.com/mcp/{tenant_id}",
// Missing variables definition
},
},
},
expectedError: "template variables in URL",
},
{
name: "remote transport with missing variable in URL - invalid",
serverDetail: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
Remotes: []model.Transport{
{
Type: "streamable-http",
URL: "https://example.com/mcp/{tenant_id}/{region}",
Variables: map[string]model.Input{
"tenant_id": {
Description: "Tenant identifier",
},
// Missing "region" variable
},
},
},
},
expectedError: "template variables in URL",
},
{
name: "remote transport SSE with URL variables - valid",
serverDetail: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
Remotes: []model.Transport{
{
Type: "sse",
URL: "https://events.example.com/mcp/{api_key}",
Variables: map[string]model.Input{
"api_key": {
Description: "API key for authentication",
IsRequired: true,
IsSecret: true,
},
},
},
},
},
expectedError: "",
},
{
name: "remote transport with variables but no template in URL - valid",
serverDetail: apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
Remotes: []model.Transport{
{
Type: "streamable-http",
URL: "https://example.com/mcp",
Variables: map[string]model.Input{
"unused_var": {
Description: "This variable is defined but not used",
},
},
},
},
},
expectedError: "", // Valid - variables can be defined but not used
},
}
for _, tt := range tests {