feat(schema): allow template variables in transport URL field (#910)
Authored by Claude Code Opus 4.5, steered/reviewed by me.
## Summary
- Updated the URL regex pattern in `StreamableHttpTransport` and
`SseTransport` to allow template variables like `{baseUrl}` as an
alternative to requiring the `https://` prefix
- This enables more flexible URL configuration in the `remotes` section
of server.json
## Changes
- Modified the `url` field pattern from `^https?://[^\s]+$` to
`^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$`
- Updated both `openapi.yaml` and the derived `server.schema.json`
- Added clarifying documentation that URL must start with `http://`,
`https://`, or a template variable
## Regex breakdown
```
^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$
```
| Part | Meaning |
|------|---------|
| `^` | Start of string |
| `(` | Begin alternation group |
| `https?://[^\s]+` | `http://` or `https://` followed by non-whitespace
|
| `\|` | OR |
| `\{[a-zA-Z_][a-zA-Z0-9_]*\}` | Template variable: `{` + valid
identifier + `}` |
| `[^\s]*` | Optional path/query after template variable |
| `)` | End alternation group |
| `$` | End of string |
## Test cases
**Should match ✓**
- `https://api.example.com/mcp`
- `http://localhost:8080/sse`
- `https://example.com/path?query=value`
- `{baseUrl}`
- `{baseUrl}/mcp`
- `{server_url}/api/v1`
- `{API_ENDPOINT}`
**Should NOT match ✗**
- `ftp://example.com` — wrong protocol
- `example.com` — missing protocol or variable
- `/relative/path` — relative paths not allowed
- `{invalid-name}/path` — hyphens not valid in variable names
- `{123invalid}` — variables can't start with numbers
---------
Co-authored-by: Agent Orchestrator <agent@ao.pulsemcp.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Radoslav Dimitrov <radoslav@stacklok.com>
This commit is contained in:
parent
541a6c61fa
commit
70a27717fb
4 changed files with 137 additions and 9 deletions
|
|
@ -685,9 +685,9 @@ components:
|
|||
example: "streamable-http"
|
||||
url:
|
||||
type: string
|
||||
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."
|
||||
description: "URL template for the streamable-http transport. Must start with http://, https://, or a template variable (e.g., {baseUrl}). 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."
|
||||
example: "https://api.example.com/mcp"
|
||||
pattern: "^https?://[^\\s]+$"
|
||||
pattern: "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$"
|
||||
headers:
|
||||
type: array
|
||||
description: HTTP headers to include
|
||||
|
|
@ -707,9 +707,9 @@ components:
|
|||
example: "sse"
|
||||
url:
|
||||
type: string
|
||||
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."
|
||||
description: "Server-Sent Events endpoint URL template. Must start with http://, https://, or a template variable (e.g., {baseUrl}). 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."
|
||||
example: "https://mcp-fs.example.com/sse"
|
||||
pattern: "^https?://[^\\s]+$"
|
||||
pattern: "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$"
|
||||
headers:
|
||||
type: array
|
||||
description: HTTP headers to include
|
||||
|
|
|
|||
|
|
@ -8,7 +8,27 @@ This section tracks changes that are in development and not yet released. The dr
|
|||
|
||||
### Changed
|
||||
|
||||
- No changes yet.
|
||||
#### Transport URL Pattern Now Accepts Template Variables
|
||||
|
||||
The `url` field in `StreamableHttpTransport` and `SseTransport` now accepts URLs that start with a template variable (e.g., `{baseUrl}`), in addition to the existing `http://` and `https://` prefixes.
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"remotes": [{
|
||||
"type": "streamable-http",
|
||||
"url": "{baseUrl}/mcp",
|
||||
"variables": {
|
||||
"baseUrl": {
|
||||
"description": "Base URL for the MCP server",
|
||||
"isRequired": true
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Migration:** No changes required. Existing servers continue to work unchanged.
|
||||
|
||||
### Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -511,9 +511,9 @@
|
|||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"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.",
|
||||
"description": "Server-Sent Events endpoint URL template. Must start with http://, https://, or a template variable (e.g., {baseUrl}). 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.",
|
||||
"example": "https://mcp-fs.example.com/sse",
|
||||
"pattern": "^https?://[^\\s]+$",
|
||||
"pattern": "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
|
@ -557,9 +557,9 @@
|
|||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"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.",
|
||||
"description": "URL template for the streamable-http transport. Must start with http://, https://, or a template variable (e.g., {baseUrl}). 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.",
|
||||
"example": "https://api.example.com/mcp",
|
||||
"pattern": "^https?://[^\\s]+$",
|
||||
"pattern": "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
108
internal/validators/schema_regex_test.go
Normal file
108
internal/validators/schema_regex_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package validators_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const serverSchemaPath = "../../docs/reference/server-json/server.schema.json"
|
||||
|
||||
// schemaHelper provides utilities for extracting values from the JSON schema
|
||||
type schemaHelper struct {
|
||||
t *testing.T
|
||||
schema map[string]interface{}
|
||||
}
|
||||
|
||||
func loadSchema(t *testing.T) *schemaHelper {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(serverSchemaPath)
|
||||
require.NoError(t, err, "Failed to read schema file")
|
||||
|
||||
var schema map[string]interface{}
|
||||
err = json.Unmarshal(data, &schema)
|
||||
require.NoError(t, err, "Failed to parse schema JSON")
|
||||
|
||||
return &schemaHelper{t: t, schema: schema}
|
||||
}
|
||||
|
||||
// getDefinition returns a definition from the schema by name
|
||||
func (s *schemaHelper) getDefinition(name string) map[string]interface{} {
|
||||
s.t.Helper()
|
||||
definitions := s.schema["definitions"].(map[string]interface{})
|
||||
def, ok := definitions[name].(map[string]interface{})
|
||||
require.True(s.t, ok, "Definition %q not found in schema", name)
|
||||
return def
|
||||
}
|
||||
|
||||
// getPropertyPattern extracts a regex pattern from a definition's property
|
||||
func (s *schemaHelper) getPropertyPattern(definitionName, propertyName string) string {
|
||||
s.t.Helper()
|
||||
def := s.getDefinition(definitionName)
|
||||
props := def["properties"].(map[string]interface{})
|
||||
prop, ok := props[propertyName].(map[string]interface{})
|
||||
require.True(s.t, ok, "Property %q not found in %s", propertyName, definitionName)
|
||||
pattern, ok := prop["pattern"].(string)
|
||||
require.True(s.t, ok, "Pattern not found for %s.%s", definitionName, propertyName)
|
||||
return pattern
|
||||
}
|
||||
|
||||
// TestTransportURLPattern validates the URL pattern used by StreamableHttpTransport and SseTransport.
|
||||
// URLs must start with http://, https://, or a template variable like {baseUrl}.
|
||||
func TestTransportURLPattern(t *testing.T) {
|
||||
schema := loadSchema(t)
|
||||
|
||||
streamablePattern := schema.getPropertyPattern("StreamableHttpTransport", "url")
|
||||
ssePattern := schema.getPropertyPattern("SseTransport", "url")
|
||||
|
||||
// Verify both transport types use the same pattern
|
||||
assert.Equal(t, streamablePattern, ssePattern,
|
||||
"StreamableHttpTransport and SseTransport should use identical URL patterns")
|
||||
|
||||
t.Logf("Pattern: %s", streamablePattern)
|
||||
|
||||
re, err := regexp.Compile(streamablePattern)
|
||||
require.NoError(t, err, "Pattern should be valid regex")
|
||||
|
||||
// Test cases that SHOULD match
|
||||
validCases := []string{
|
||||
// Standard URLs
|
||||
"https://api.example.com/mcp",
|
||||
"http://localhost:8080/sse",
|
||||
"https://example.com/path?query=value",
|
||||
"https://api.example.com/v1/mcp",
|
||||
// Template variables
|
||||
"{baseUrl}",
|
||||
"{baseUrl}/mcp",
|
||||
"{server_url}/api/v1",
|
||||
"{API_ENDPOINT}",
|
||||
"{a}",
|
||||
"{_private}/endpoint",
|
||||
}
|
||||
|
||||
for _, tc := range validCases {
|
||||
assert.True(t, re.MatchString(tc), "Expected %q to match pattern", tc)
|
||||
}
|
||||
|
||||
// Test cases that should NOT match
|
||||
invalidCases := []string{
|
||||
"ftp://example.com", // wrong protocol
|
||||
"example.com", // missing protocol or variable
|
||||
"/relative/path", // relative path
|
||||
"{invalid-name}/path", // hyphen in variable name
|
||||
"{123invalid}", // variable starts with number
|
||||
"", // empty string
|
||||
"mailto:test@example.com", // wrong protocol
|
||||
"file:///path/to/file", // wrong protocol
|
||||
"{}/empty", // empty variable name
|
||||
"{{nested}}/path", // nested braces
|
||||
}
|
||||
|
||||
for _, tc := range invalidCases {
|
||||
assert.False(t, re.MatchString(tc), "Expected %q to NOT match pattern", tc)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue