Skip to content

Commit b58836c

Browse files
tadasantyuna0x0claudeconnor4312
authored
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 #570 (comment) regarding variable naming/prioritzation/scoping conventions, but should not have any impact on the shape being introduced here (just validations). --------- Co-authored-by: yuna0x0 <[email protected]> Co-authored-by: Claude <[email protected]> Co-authored-by: Connor Peet <[email protected]>
1 parent bbbad1a commit b58836c

File tree

9 files changed

+350
-52
lines changed

9 files changed

+350
-52
lines changed

docs/reference/api/openapi.yaml

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -440,10 +440,7 @@ components:
440440
description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.
441441
examples: [npx, uvx, docker, dnx]
442442
transport:
443-
anyOf:
444-
- $ref: '#/components/schemas/StdioTransport'
445-
- $ref: '#/components/schemas/StreamableHttpTransport'
446-
- $ref: '#/components/schemas/SseTransport'
443+
$ref: '#/components/schemas/LocalTransport'
447444
description: Transport protocol configuration for the package
448445
runtimeArguments:
449446
type: array
@@ -599,8 +596,9 @@ components:
599596
example: "streamable-http"
600597
url:
601598
type: string
602-
description: URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.
599+
description: "URL template for the streamable-http transport. Variables in {curly_braces} are resolved based on context: In Package context, they reference argument valueHints, argument names, or environment variable names from the parent Package. In Remote context, they reference variables from the transport's 'variables' object. After variable substitution, this should produce a valid URI."
603600
example: "https://api.example.com/mcp"
601+
pattern: "^https?://[^\\s]+$"
604602
headers:
605603
type: array
606604
description: HTTP headers to include
@@ -620,15 +618,36 @@ components:
620618
example: "sse"
621619
url:
622620
type: string
623-
format: uri
624-
description: Server-Sent Events endpoint URL
621+
description: "Server-Sent Events endpoint URL template. Variables in {curly_braces} are resolved based on context: In Package context, they reference argument valueHints, argument names, or environment variable names from the parent Package. In Remote context, they reference variables from the transport's 'variables' object. After variable substitution, this should produce a valid URI."
625622
example: "https://mcp-fs.example.com/sse"
623+
pattern: "^https?://[^\\s]+$"
626624
headers:
627625
type: array
628626
description: HTTP headers to include
629627
items:
630628
$ref: '#/components/schemas/KeyValueInput'
631629

630+
LocalTransport:
631+
anyOf:
632+
- $ref: '#/components/schemas/StdioTransport'
633+
- $ref: '#/components/schemas/StreamableHttpTransport'
634+
- $ref: '#/components/schemas/SseTransport'
635+
description: Transport protocol configuration for local/package context
636+
637+
RemoteTransport:
638+
allOf:
639+
- anyOf:
640+
- $ref: '#/components/schemas/StreamableHttpTransport'
641+
- $ref: '#/components/schemas/SseTransport'
642+
- type: object
643+
properties:
644+
variables:
645+
type: object
646+
description: "Configuration variables that can be referenced in URL template {curly_braces}. The key is the variable name, and the value defines the variable properties."
647+
additionalProperties:
648+
$ref: '#/components/schemas/Input'
649+
description: Transport protocol configuration for remote context - extends StreamableHttpTransport or SseTransport with variables
650+
632651
Icon:
633652
type: object
634653
description: An optionally-sized icon that can be displayed in a user interface.
@@ -717,9 +736,7 @@ components:
717736
remotes:
718737
type: array
719738
items:
720-
anyOf:
721-
- $ref: '#/components/schemas/StreamableHttpTransport'
722-
- $ref: '#/components/schemas/SseTransport'
739+
$ref: '#/components/schemas/RemoteTransport'
723740
_meta:
724741
type: object
725742
description: "Extension metadata using reverse DNS namespacing for vendor-specific data"

docs/reference/server-json/generic-server-json.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,3 +688,90 @@ For MCP servers that follow a custom installation path or are embedded in applic
688688
}
689689
```
690690

691+
692+
### Remote Server with URL Templating
693+
694+
This example demonstrates URL templating for remote servers, useful for multi-tenant deployments where each instance has its own endpoint. Unlike Package transports (which reference parent arguments/environment variables), Remote transports define their own variables:
695+
696+
```json
697+
{
698+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
699+
"name": "io.modelcontextprotocol.anonymous/multi-tenant-server",
700+
"description": "MCP server with configurable remote endpoint",
701+
"title": "Multi-Tenant Server",
702+
"version": "1.0.0",
703+
"remotes": [
704+
{
705+
"type": "streamable-http",
706+
"url": "https://anonymous.modelcontextprotocol.io/mcp/{tenant_id}",
707+
"variables": {
708+
"tenant_id": {
709+
"description": "Tenant identifier (e.g., 'us-cell1', 'emea-cell1')",
710+
"isRequired": true
711+
}
712+
}
713+
}
714+
]
715+
}
716+
```
717+
718+
Clients configure the tenant identifier, and the `{tenant_id}` variable in the URL gets replaced with the provided variable value to connect to the appropriate tenant endpoint (e.g., `https://anonymous.modelcontextprotocol.io/mcp/us-cell1` or `https://anonymous.modelcontextprotocol.io/mcp/emea-cell1`).
719+
720+
The same URL templating works with SSE transport:
721+
722+
```json
723+
{
724+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
725+
"name": "io.modelcontextprotocol.anonymous/events-server",
726+
"description": "MCP server using SSE with tenant-specific endpoints",
727+
"version": "1.0.0",
728+
"remotes": [
729+
{
730+
"type": "sse",
731+
"url": "https://events.anonymous.modelcontextprotocol.io/sse/{tenant_id}",
732+
"variables": {
733+
"tenant_id": {
734+
"description": "Tenant identifier",
735+
"isRequired": true
736+
}
737+
}
738+
}
739+
]
740+
}
741+
```
742+
743+
### Local Server with URL Templating
744+
745+
This example demonstrates URL templating for local/package servers, where variables reference parent Package arguments or environment variables:
746+
747+
```json
748+
{
749+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
750+
"name": "io.github.example/configurable-server",
751+
"description": "Local MCP server with configurable port",
752+
"title": "Configurable Server",
753+
"version": "1.0.0",
754+
"packages": [
755+
{
756+
"registryType": "npm",
757+
"registryBaseUrl": "https://registry.npmjs.org",
758+
"identifier": "@example/mcp-server",
759+
"version": "1.0.0",
760+
"transport": {
761+
"type": "streamable-http",
762+
"url": "http://localhost:{--port}/mcp"
763+
},
764+
"packageArguments": [
765+
{
766+
"type": "named",
767+
"name": "--port",
768+
"description": "Port for the server to listen on",
769+
"default": "3000"
770+
}
771+
]
772+
}
773+
]
774+
}
775+
```
776+
777+
The `{--port}` variable in the URL references the `--port` argument `name` from packageArguments. For positional arguments, an argument with the `valueHint` of `port` could similarly be referenced as `{port}`. When the package runs with `--port 8080`, the URL becomes `http://localhost:8080/mcp`.

docs/reference/server-json/server.schema.json

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@
156156
}
157157
]
158158
},
159+
"LocalTransport": {
160+
"anyOf": [
161+
{
162+
"$ref": "#/definitions/StdioTransport"
163+
},
164+
{
165+
"$ref": "#/definitions/StreamableHttpTransport"
166+
},
167+
{
168+
"$ref": "#/definitions/SseTransport"
169+
}
170+
],
171+
"description": "Transport protocol configuration for local/package context"
172+
},
159173
"NamedArgument": {
160174
"allOf": [
161175
{
@@ -262,17 +276,7 @@
262276
"type": "string"
263277
},
264278
"transport": {
265-
"anyOf": [
266-
{
267-
"$ref": "#/definitions/StdioTransport"
268-
},
269-
{
270-
"$ref": "#/definitions/StreamableHttpTransport"
271-
},
272-
{
273-
"$ref": "#/definitions/SseTransport"
274-
}
275-
],
279+
"$ref": "#/definitions/LocalTransport",
276280
"description": "Transport protocol configuration for the package"
277281
},
278282
"version": {
@@ -337,6 +341,33 @@
337341
],
338342
"description": "A positional input is a value inserted verbatim into the command line."
339343
},
344+
"RemoteTransport": {
345+
"allOf": [
346+
{
347+
"anyOf": [
348+
{
349+
"$ref": "#/definitions/StreamableHttpTransport"
350+
},
351+
{
352+
"$ref": "#/definitions/SseTransport"
353+
}
354+
]
355+
},
356+
{
357+
"properties": {
358+
"variables": {
359+
"additionalProperties": {
360+
"$ref": "#/definitions/Input"
361+
},
362+
"description": "Configuration variables that can be referenced in URL template {curly_braces}. The key is the variable name, and the value defines the variable properties.",
363+
"type": "object"
364+
}
365+
},
366+
"type": "object"
367+
}
368+
],
369+
"description": "Transport protocol configuration for remote context - extends StreamableHttpTransport or SseTransport with variables"
370+
},
340371
"Repository": {
341372
"description": "Repository metadata for the MCP server source code. Enables users and security experts to inspect the code, improving transparency.",
342373
"properties": {
@@ -427,14 +458,7 @@
427458
},
428459
"remotes": {
429460
"items": {
430-
"anyOf": [
431-
{
432-
"$ref": "#/definitions/StreamableHttpTransport"
433-
},
434-
{
435-
"$ref": "#/definitions/SseTransport"
436-
}
437-
]
461+
"$ref": "#/definitions/RemoteTransport"
438462
},
439463
"type": "array"
440464
},
@@ -487,9 +511,9 @@
487511
"type": "string"
488512
},
489513
"url": {
490-
"description": "Server-Sent Events endpoint URL",
514+
"description": "Server-Sent Events endpoint URL template. Variables in {curly_braces} are resolved based on context: In Package context, they reference argument valueHints, argument names, or environment variable names from the parent Package. In Remote context, they reference variables from the transport's 'variables' object. After variable substitution, this should produce a valid URI.",
491515
"example": "https://mcp-fs.example.com/sse",
492-
"format": "uri",
516+
"pattern": "^https?://[^\\s]+$",
493517
"type": "string"
494518
}
495519
},
@@ -533,8 +557,9 @@
533557
"type": "string"
534558
},
535559
"url": {
536-
"description": "URL template for the streamable-http transport. Variables in {curly_braces} reference argument valueHints, argument names, or environment variable names. After variable substitution, this should produce a valid URI.",
560+
"description": "URL template for the streamable-http transport. Variables in {curly_braces} are resolved based on context: In Package context, they reference argument valueHints, argument names, or environment variable names from the parent Package. In Remote context, they reference variables from the transport's 'variables' object. After variable substitution, this should produce a valid URI.",
537561
"example": "https://api.example.com/mcp",
562+
"pattern": "^https?://[^\\s]+$",
538563
"type": "string"
539564
}
540565
},

internal/validators/constants.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ var (
1313
ErrReservedVersionString = errors.New("version string 'latest' is reserved and cannot be used")
1414
ErrVersionLooksLikeRange = errors.New("version must be a specific version, not a range")
1515

16-
// Remote validation errors
17-
ErrInvalidRemoteURL = errors.New("invalid remote URL")
16+
// Transport validation errors
17+
ErrInvalidPackageTransportURL = errors.New("invalid package transport URL")
18+
ErrInvalidRemoteURL = errors.New("invalid remote URL")
1819

1920
// Registry validation errors
2021
ErrUnsupportedRegistryBaseURL = errors.New("unsupported registry base URL")

internal/validators/utils.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@ func replaceTemplateVariables(rawURL string) string {
6161
result = strings.ReplaceAll(result, placeholder, replacement)
6262
}
6363

64-
// Handle any remaining {variable} patterns with generic placeholder
64+
// Handle any remaining {variable} patterns with context-appropriate placeholders
65+
// If the variable is in a port position (after a colon in the host), use a numeric placeholder
66+
// Pattern: :/{variable} or :{variable}/ or :{variable} at end
67+
portRe := regexp.MustCompile(`:(\{[^}]+\})(/|$)`)
68+
result = portRe.ReplaceAllString(result, ":8080$2")
69+
70+
// Replace any other remaining {variable} patterns with generic placeholder
6571
re := regexp.MustCompile(`\{[^}]+\}`)
6672
result = re.ReplaceAllString(result, "placeholder")
6773

@@ -132,8 +138,11 @@ func IsValidRemoteURL(rawURL string) bool {
132138
return false
133139
}
134140

141+
// Replace template variables with placeholders before parsing for localhost check
142+
testURL := replaceTemplateVariables(rawURL)
143+
135144
// Parse the URL to check for localhost restriction
136-
u, err := url.Parse(rawURL)
145+
u, err := url.Parse(testURL)
137146
if err != nil {
138147
return false
139148
}
@@ -153,8 +162,8 @@ func IsValidRemoteURL(rawURL string) bool {
153162

154163
// IsValidTemplatedURL validates a URL with template variables against available variables
155164
// For packages: validates that template variables reference package arguments or environment variables
156-
// For remotes: disallows template variables entirely
157-
func IsValidTemplatedURL(rawURL string, availableVariables []string, allowTemplates bool) bool {
165+
// For remotes: validates that template variables reference the transport's variables map
166+
func IsValidTemplatedURL(rawURL string, availableVariables []string) bool {
158167
// First check basic URL structure
159168
if !IsValidURL(rawURL) {
160169
return false
@@ -168,11 +177,6 @@ func IsValidTemplatedURL(rawURL string, availableVariables []string, allowTempla
168177
return true
169178
}
170179

171-
// If templates are not allowed (e.g., for remotes), reject URLs with templates
172-
if !allowTemplates {
173-
return false
174-
}
175-
176180
// Validate that all template variables are available
177181
availableSet := make(map[string]bool)
178182
for _, v := range availableVariables {

0 commit comments

Comments
 (0)