Skip to content

Conversation

tadasant
Copy link
Member

@tadasant tadasant commented Sep 29, 2025

[Draft built largely with iterating with Claude Code; intended as a proof of concept, including this PR description, will clean up and review line by line later]

Summary

This PR adds template variable support for remote transport URLs, enabling multi-tenant deployments with configurable endpoints while maintaining full backwards compatibility.

Proper design - Uses clean LocalTransport and RemoteTransport separation with proper inheritance/composition.

Problem

The current schema promises URL templating with {curly_braces} for StreamableHttpTransport but provides no mechanism for Remote contexts to define what those variables should resolve to. This makes 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

Clean separation with proper inheritance:

  • LocalTransport: StdioTransport | StreamableHttpTransport | SseTransport

    • Used in Package context (non-breaking rename of existing behavior)
    • Variables in {curly_braces} reference parent arguments/environment variables
  • RemoteTransport: (StreamableHttpTransport | SseTransport) + variables

    • Extends base transports via allOf composition - no duplication!
    • Adds variables object for URL templating
    • Only supports streamable-http and sse (stdio not valid for remotes)

Key Changes

Schema Updates

  • LocalTransport: Clean union of existing three transport types for Package context
  • RemoteTransport: Extends StreamableHttp/Sse with variables via allOf composition
  • Package: Now uses LocalTransport reference (cleaner, non-breaking)
  • Remotes: Uses RemoteTransport with proper inheritance

Go Types

  • Keep base Transport struct unchanged for Package context
  • RemoteTransport struct handles both cases with Variables field
  • No code duplication - schemas properly reused

Variable Definition

The Remote context extends base transports cleanly:

{
  "type": "streamable-http", 
  "url": "https://api.example.github.io/mcp/{tenant_id}",
  "headers": [...],  // inherited from StreamableHttpTransport
  "variables": {     // added by RemoteTransport
    "tenant_id": {
      "description": "Tenant identifier", 
      "isRequired": true,
      "choices": ["us-cell1", "emea-cell1"]
    }
  }
}

Benefits

  • No breaking changes: Package functionality completely unchanged, just renamed for clarity
  • Proper inheritance: RemoteTransport extends base transports, no duplication
  • Type safety: RemoteTransport can't use stdio (enforced at schema level)
  • DRY design: Reuses StreamableHttp/Sse definitions via composition
  • Clear separation: LocalTransport vs RemoteTransport naming
  • Rich variables: Full Input capabilities (description, choices, isRequired, isSecret, default, format)

Test Plan

  • Validate schema changes against existing examples - all pass
  • Test backwards compatibility with existing server.json files - unchanged
  • Verify new templating example validates correctly
  • All lint/validation checks pass
  • Schema inheritance works correctly
  • Test with real multi-tenant deployment scenario

Architecture

Perfect separation with inheritance:

  • Package + LocalTransport: {port} references --port argument or PORT env var from parent Package
  • Remote + RemoteTransport: {tenant_id} references variables.tenant_id object, inherits all base transport properties
  • Code reuse: StreamableHttp/Sse definitions shared via allOf, not duplicated

🤖 Generated with Claude Code

tadasant and others added 5 commits September 29, 2025 08:47
Enable URL templating for StreamableHttpTransport and SseTransport by adding
'arguments' and 'environmentVariables' arrays. This allows remote servers
to support multi-tenant deployments with configurable endpoints.

Key changes:
- Add optional 'arguments' array to both transport types for valueHint-based templating
- Add optional 'environmentVariables' array for environment-based templating
- Update URL descriptions to clarify template variable resolution behavior
- Add comprehensive multi-tenant example demonstrating the new functionality
- Maintain backwards compatibility - existing configs continue to work

This addresses the common use case where remote MCP servers are deployed
across multiple tenants/regions, each requiring different endpoint URLs.
Variables in {curly_braces} now properly resolve from the defined arrays.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Focus the example on just demonstrating the core templating functionality
rather than complex multi-transport/multi-variable scenarios. The minimal
example clearly shows how {tenant_host} resolves from environmentVariables.
…emplating

Arguments make more sense for remote URL configuration since clients are
configuring connection parameters, not running processes with environment
variables. The valueHint 'tenant_host' maps to {tenant_host} in the URL.
Replace the complex arguments/environmentVariables approach with a clean
'variables' array that directly maps variable names to {curly_braces} in URLs.

Key changes:
- Replace 'arguments' and 'environmentVariables' with single 'variables' array
- Update Transport struct to include Variables field
- Fix Go validators to support template variables in remote URLs
- Update example to use path-based templating for better namespace validation
- All validation and schema checks now pass

This provides a much cleaner API: variables array contains KeyValueInput
objects where the 'name' field maps directly to {variable_name} in the URL.

Example:
{
  "url": "https://api.example.github.io/mcp/{tenant_id}",
  "variables": [{"name": "tenant_id", "description": "Tenant ID", "isRequired": true}]
}

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Replace array-based variables with an object where keys are variable names
and values are Input definitions. This provides a much cleaner API and
enables rich client UIs.

Key improvements:
- Variables as object: {"tenant_id": {"description": "...", "isRequired": true}}
- No duplicate 'name' field - variable name is the object key
- Full Input capabilities: choices, isSecret, default, format, etc.
- Consistent with existing InputWithVariables pattern
- Client-friendly for building rich UIs (dropdowns, validation, etc.)

Example:
{
  "url": "https://api.example.github.io/mcp/{tenant_id}",
  "variables": {
    "tenant_id": {
      "description": "Tenant identifier",
      "isRequired": true,
      "choices": ["us-cell1", "emea-cell1"]
    }
  }
}

All validation and tests pass. Schema, Go types, validators, and example
have been updated to support the new structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
"url": {
"type": "string",
"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.",
"description": "URL template for the streamable-http transport. Variables in {curly_braces} reference variable names from the 'variables' object. If variables are not provided, {curly_braces} should be treated as literal text. After variable substitution, this should produce a valid URI.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea with the references initially here is that a server that is launched with stdio and has, for example, a --port argument could automatically reference the value of that argument in the transport. I hypothesize the vast majority of locally-launched servers are going to have an address detereministic from their command line inputs and (from a client PoV) asking the user to fill in the port in the url separately from a port in the package_arguments is just confusing.

Copy link
Contributor

@connor4312 connor4312 Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also,

All existing server.json files remain valid

this is not the case -- this change is breaking for any packages that were already published with the original reference behavior (not sure if there are any, this was only in a recent addition)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right. Thanks for the reminder that was the original reason we didn't include a separate field here.

The learning we've had since is that this doesn't work for the usage of StreamableHttpTransport and SseTransport within Remote.

Will think more on how we can properly combine (or separate out) the use cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

definitely -- I suggest perhaps combining the variables with an allOf in just the Remote case and adding some description in the property.

Copy link
Member Author

@tadasant tadasant Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also re -

this is not the case -- this change is breaking for any packages that were already published with the original reference behavior (not sure if there are any, this was only in a recent addition)

That's my bad - I had forgotten this original use case inside Package and thus thought the reference to arguments/envvars was just a typo. This is all clearer now that you've reminded me :) Thanks again

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feeling pretty good about the direction now: https://github.com/modelcontextprotocol/registry/pull/570/files

Not ready to land this, but intent for me here was to explore whether we can add this functionality without breaking changes. I think the answer is yes, so might punt this for a few days.

Ensure that template variable validation doesn't bypass the existing
IsValidRemoteURL check that prevents localhost URLs in remote transports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@tadasant tadasant force-pushed the tadasant/support-remotes-in-json branch from 73c2f75 to 622a257 Compare September 29, 2025 17:02
tadasant and others added 10 commits September 29, 2025 10:07
Only keep the core functional changes for URL templating feature:
- Schema updates for variables object
- Go type definitions
- Validation logic
- Documentation example
- Test count update

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Address breaking change feedback by creating separate transport types:
- Package transports: reference parent arguments/environment variables
- Remote transports: define their own variables object

Key changes:
- Revert base Transport schema - keep original Package behavior unchanged
- Add RemoteStreamableHttpTransport and RemoteSseTransport via allOf pattern
- Create separate RemoteTransport Go type with Variables field
- Update API types to use RemoteTransport for remotes array
- Fix validation logic for both contexts
- Update test files to use correct types

This maintains backwards compatibility for Package context while
enabling the needed templating functionality for Remote context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Replace the weird RemoteStreamableHttpTransport and RemoteSseTransport
with a single clean RemoteTransport type that supports both streamable-http
and sse with a discriminated union approach.

Benefits:
- Much more DRY - no repetition between transport types
- Clear separation: Package transports vs RemoteTransport
- Cleaner schema - single RemoteTransport definition
- Simplified Go types - RemoteTransport handles both cases

This addresses feedback about the previous design being repetitive
and confusing with dual-purpose transport definitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Create clean separation with proper inheritance/composition:

- **LocalTransport**: Union of StdioTransport | StreamableHttpTransport | SseTransport
  - Used in Package context for local/package transports
  - Non-breaking rename of existing concepts

- **RemoteTransport**: Extends StreamableHttpTransport | SseTransport + variables
  - Reuses base transport definitions via allOf composition
  - Adds variables object for URL templating
  - Only supports streamable-http and sse (no stdio for remotes)

Benefits:
- ✅ Proper code reuse - shares StreamableHttp/Sse definitions
- ✅ Clear naming - LocalTransport vs RemoteTransport
- ✅ Non-breaking - just renames Package transport concept
- ✅ DRY design - no duplication between transport types
- ✅ Type safety - RemoteTransport can't use stdio

This addresses feedback about wanting proper inheritance and clearer
separation between local vs remote transport contexts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Add example showing LocalTransport templating with Package arguments
to clarify the difference between Local vs Remote templating contexts:

- Local: {port} references --port argument or valueHint from Package
- Remote: {tenant_id} references variables object in RemoteTransport

This helps users understand when to use Package arguments vs Remote
variables for their URL templating needs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
The base StreamableHttpTransport and SseTransport descriptions were
Package-specific but got inherited by RemoteTransport via allOf,
causing incorrect documentation for the Remote context.

Changes:
- Update base URL descriptions to explain both Package and Remote contexts
- Remove redundant URL overrides in RemoteTransport since base is now generic
- Ensures correct documentation regardless of inheritance context

Now the descriptions correctly explain:
- Package context: Variables reference parent arguments/environment variables
- Remote context: Variables reference transport's 'variables' object

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
- Add Variables field back to base Transport struct for compatibility
- Revert API types to use []model.Transport for remotes
- Keep schema separation with LocalTransport and RemoteTransport
- This maintains schema benefits while fixing validation issue
The Remote Server with URL Templating example was failing namespace validation
because the URL domain didn't match the reverse-DNS namespace. Fixed by ensuring
the namespace `io.modelcontextprotocol.anonymous/multi-tenant-server` matches
the URL `https://anonymous.modelcontextprotocol.io/mcp/{tenant_id}`.

This ensures the example passes integration tests where remote URLs must match
the publisher domain extracted from the namespace.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@BobDickinson
Copy link
Contributor

I somehow missed this PR and created #576 (which should get closed if this PR is adopted). FWIW, I've reviewed this PR and am a fan of the approach (including adding variables to the remote transports for URI substitution).

@domdomegg
Copy link
Member

I'm happy with this approach 👍. Might just need a rebase and minor code cleanup

@BobDickinson
Copy link
Contributor

BobDickinson commented Oct 3, 2025

OK, I really like this PR and don't want to slow it down. That being said, in going through existing registry entries with an eye toward making them take full advantage of the configuration support offered, I ran across a case that might impact this.

The general use case for this feature is configurable ports for the package transport url (so we configure the port we want the server to run on, and then have the transport config pick up that same port from the runtime config). But what about the case where the port is not an argument or env var, but is another part of the config? For example (from a published server):

Current

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
  "name": "io.github.SamYuan1990/i18n-agent-action",
  "description": "An i18n github action for language translate",
  "version": "mcp",
  "packages": [
    {
      "registryType": "oci",
      "registryBaseUrl": "https://ghcr.io",
      "identifier": "SamYuan1990/i18n-agent-action",
      "version": "mcp",
      "runtimeHint": "docker",
      "transport": {
        "type": "sse",
        "url": "https://example.com:8080/sse"
      },
      "runtimeArguments": [
        {
          "description": "Port mapping from host to container",
          "value": "8080:8080",
          "type": "named",
          "name": "-p"
        }
      ]
    }
  ]
}

If we wanted to parameterize the port so we can change it and have it be reflected in the docker command and the transport url, we would do something like this:

Parameterized

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
  "name": "io.github.SamYuan1990/i18n-agent-action",
  "description": "An i18n github action for language translate",
  "version": "mcp",
  "packages": [
    {
      "registryType": "oci",
      "registryBaseUrl": "https://ghcr.io",
      "identifier": "SamYuan1990/i18n-agent-action",
      "version": "mcp",
      "runtimeHint": "docker",
      "transport": {
        "type": "sse",
        "url": "https://example.com:{host_port}/sse"
      },
      "runtimeArguments": [
        {
          "description": "Port mapping from host to container",
          "value": "{host_port}:8080",
          "type": "named",
          "name": "-p",
          "variables": {
            "host_port": {
              "description": "The host (local) port",
              "isRequired": true,
              "format": "number",
              "default": "8080"
            }
          }
        }
      ]
    }
  ]
}

For docker we have to parameterize (templatize) the port (-p) argument with a variable to make the host port configurable. With the current resolution logic, there would be no way to map the token in the url to that (because there is no corresponding arg or env var representing just the host port).

I would argue that the mapping logic is already a little "spicy" since you could theoretically have runtime args, package args, and env vars with conflicting names (so you already need to specify the resolution logic / priority for deterministic resolution). My recommendation is that the guidance for token substitution be amended to say that after checking the runtime args, package args, and env vars (in that order) that you then fall back to checking for a matching variable under each of those things (in the same order). That would support the exact case above and keep the syntax clean.

I also considered a syntax like {p.host_port} indicating look in the first matching config element (arg/env) named "p", then use it's variable called "host_port". It's more specific, but I'm not sure that's worth the complexity for what it solves (directing to a specific variable when there might be multiple config elements with the same variable name - and since the variable names are only internal and are under the publisher's control, it's their own fault if they're ambiguous IMO).

@connor4312
Copy link
Contributor

Good callout.

I also considered a syntax like {p.host_port} indicating look in the first matching config element (arg/env) named "p", then use it's variable called "host_port".

I think I'd prefer this to the blind sub-variable matching. Though it would actually be {-p.host_port} in your example as -p is the name of the argument.

@BobDickinson
Copy link
Contributor

I could certainly live with the directed (dot) references.

I can't explain exactly why, but {-p.host_port} and even {--port} hurt my eyes, even though they are technically correct. It's complicated by the fact that many publishers aren't using the -- in the argument name (even though the docs are very clear about it), making the tokens look prettier, but causing the generated args to be wrong. I have a lax mode of config generation that works around this (both in token matching and config generation), but I'm not happy about it. I have a validator proposal coming soon (with schema validation and a linter to catch logic errors / misconfigurations, including things like missing dashes in arg names).

But back to the point: I'd advocate that we specify that it's OK to omit the leading dashes for named arg matching in the url token just to make it not hurt my eyes, but maybe that's not a good enough reason ;)

@BobDickinson
Copy link
Contributor

IMO, both StreamableHttpTransport and SseTransport url should have:

  "pattern": "^https?://[^\\s]+$"

So as to at least encourage url-like values (versus free form strings)

Currently StreamableHttpTransport has no pattern or format, and SseTransport has:

  "format": "uri"

which will prevent tokens in SSE (the intent to allow tokens in SSE seems clear, so I assume this is just an oversight).

@tadasant
Copy link
Member Author

tadasant commented Oct 9, 2025

Sorry I've been slow to follow up here - planning to get into it tomorrow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants