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:
parent
bbbad1af1f
commit
b58836c48e
4 changed files with 350 additions and 52 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue