Skip to content

[WIP] feat(mcp): serve multiple MCP servers from one router on separate paths - #3151

Draft
asoorm wants to merge 18 commits into
mainfrom
ahmet/router-624-multi-collection-mcp
Draft

[WIP] feat(mcp): serve multiple MCP servers from one router on separate paths#3151
asoorm wants to merge 18 commits into
mainfrom
ahmet/router-624-multi-collection-mcp

Conversation

@asoorm

@asoorm asoorm commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Configure and run multiple named MCP servers, each with its own path and settings.
    • MCP servers now share a common HTTP endpoint while remaining independently reloadable.
    • Added per-server session, storage, discovery, OAuth, metadata, and routing options.
    • Added path validation and clearer OAuth metadata support for multi-server deployments.
  • Bug Fixes

    • Failures affecting one MCP server no longer prevent healthy servers from reloading.
  • Documentation

    • Documented the new mcp.servers configuration and migration guidance.
    • Marked legacy single-server options as deprecated and clarified OAuth security requirements.

Closes ROUTER-624.

Motivation

As a platform team, I want to give different agents different tool sets without running a
router per tool set. A support agent needs curated read-only operations, an internal agent
needs write access, a partner agent needs a small public set. Today each of those needs its
own router deployment, which costs money and drifts in configuration.

Changes

The router now mounts several MCP servers on one listener, each on its own path, each with
its own operation collection and its own OAuth policy.

  • New mcp.servers map, keyed by server name. Each entry sets path, storage.provider_id,
    an optional base_url, and the existing per-server MCP options.
  • New Host type owns the MCP listener and its mux. GraphQLSchemaServer no longer owns an
    http.Server; it registers its own routes at its own mount path.
  • OAuth discovery is now per server. The RFC 9728 metadata path and the published resource
    identifier both derive from the mount path, so a server on /billing/mcp publishes
    https://billing.example.com/billing/mcp rather than a shared /mcp.
  • Config validation rejects duplicate paths, malformed paths, and unknown provider ids before
    any server is mounted, because http.ServeMux panics on a duplicate pattern.
  • An unreadable operations collection now degrades that one server to its built-in tools
    instead of failing the whole router.

Breaking behaviour, please read

MCP reload can no longer fail the router. Previously an unreadable operations directory
failed router startup and every config reload. Now the affected server serves built-in tools
only and logs an error. Alert on the error log rather than on startup failure. This applies to
existing single-server deployments too.

The top-level mcp options are deprecated. When mcp.servers has entries the router
ignores all of them and logs a warning naming each one you set. It never merges the two forms.
With no entries the deprecated options build one server on /mcp, so existing configs keep
working unchanged.

Migrating changes the advertised server identity. graph_name feeds the Name field in
MCP serverInfo as wundergraph-cosmo-<kebab-case graph_name>, and in the map form
graph_name defaults to the map key. Set graph_name explicitly to keep the old name. Some
MCP clients store trust against it.

Limitations

  • All servers share one listener and the router's global CORS. Per-server listeners are future
    work, which is why a mount path of / is rejected: on a shared mux Go treats it as a
    subtree pattern that swallows every other server's requests.
  • A load balancer may change the host but must preserve the path.
  • The mcp.servers map is YAML only. Environment variables cannot address map entries.
  • Servers front the federated graph only. Standalone upstream GraphQL targets, per-server
    header allowlists, and token exchange are out of scope.

Config example

mcp:
  enabled: true
  server:
    listen_addr: localhost:5025
  servers:
    support:
      enabled: true
      path: /mcp/support
      storage:
        provider_id: support-ops
      exclude_mutations: true
    billing:
      enabled: true
      path: /billing/mcp
      base_url: https://billing.example.com
      storage:
        provider_id: billing-ops
      oauth:
        enabled: true
        authorization_server_url: https://auth.example.com

Set oauth.jwks[].audiences to each server's resource identifier. Without it the router
accepts a token minted for one server on every other server.

Test plan

cd router
go build ./...
go test -race -count=1 ./core ./pkg/mcpserver ./pkg/config

Manual check with two servers configured as above:

# each server exposes only its own collection
curl -s localhost:5025/mcp/support -X POST -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# per-path OAuth metadata, derived from the mount path
curl -s https://billing.example.com/.well-known/oauth-protected-resource/billing/mcp

# an unauthenticated call returns a challenge pointing at that same URL
curl -si localhost:5025/billing/mcp -X POST -d '{}' | grep -i www-authenticate

Reviewer notes

  • MCPServerEntry.Stateless is a *bool on purpose. It cuts against the repo convention that
    booleans default to false via the zero value, because the top-level default is true and a
    plain bool cannot tell "unset" from "explicitly false". Without the pointer the two config
    forms silently disagree on session behaviour. There is a comment saying so.
  • mcp.server.base_url stays a global default that per-server entries override. It is not
    deprecated. Audience isolation still holds, because the mount path is part of every resource
    identifier and validation guarantees distinct mount paths.

Follow-ups, deliberately not in this PR

  • No regression test pins the WWW-Authenticate metadata URL for a non-default mount path.
    Consistency currently rests on both call sites using the same helper.
  • Host.Start has no double-call guard and returns nil on a bind failure. Pre-existing shape,
    single caller today.
  • envDefault tags on JWKSConfiguration are unreachable for every jwks entry repo-wide,
    because env.Parse runs before yaml.Unmarshal and the list is empty at parse time. The
    docs claim defaults that never apply, in three places. Separate issue to follow.

Checklist

  • I have discussed my proposed changes in an issue and have received approval to proceed.
  • I have followed the coding standards of the project.
  • Tests or benchmarks have been added or updated.
  • If applicable, I have provided instructions for reviewers for manually validating my changes.
  • Documentation has been updated.
  • I have read the Contributors Guide.

@mintlify

mintlify Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
wundergraphinc 🟢 Ready View Preview Aug 10, 2026, 8:12 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

MCP configuration now supports multiple named servers mounted on distinct paths. A shared host manages routing, CORS, reloads, and shutdown. Deprecated top-level settings remain supported through a synthetic single-server configuration.

Changes

MCP multi-server support

Layer / File(s) Summary
Configuration contracts and schema
router/pkg/config/...
Adds named MCP server entries, per-server session and OAuth settings, shared schema definitions, path requirements, and parsing tests.
Path-aware MCP server lifecycle
router/pkg/mcpserver/paths.go, router/pkg/mcpserver/server.go, router/pkg/mcpserver/*_test.go
Adds mount-path validation, path-aware OAuth metadata, caller-managed route registration, and server cleanup.
Shared host and validation
router/pkg/mcpserver/host.go, router/pkg/mcpserver/validation.go, router/pkg/mcpserver/*_test.go
Adds shared HTTP serving, duplicate-path validation, reload isolation, CORS handling, and graceful shutdown.
Router startup and compatibility integration
router/core/router.go, router/core/router_config.go, router/core/graph_server.go, router/core/router_test.go
Builds and starts named servers, preserves deprecated configuration, applies per-server defaults, reports ignored options, and manages host shutdown.
Configuration and OAuth documentation
docs-website/router/...
Documents named-server setup, migration, routing, defaults, sessions, CORS, and OAuth audience isolation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • wundergraph/cosmo#2636: Earlier MCP OAuth work extended here with per-server OAuth configuration and lifecycle handling.
  • wundergraph/cosmo#3087: Configurable MCP mount paths and OAuth metadata routing are reused and extended.
  • wundergraph/cosmo#3148: MCP OAuth metadata and JWKS audience handling are extended to named server entries.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: serving multiple MCP servers from one router on separate paths.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Router-nonroot image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-be452693548373356d3b659435e14e479cd6125d-nonroot

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.59184% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.61%. Comparing base (64eaf60) to head (89ac4f5).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
router/core/router.go 74.21% 23 Missing and 18 partials ⚠️
router/pkg/mcpserver/host.go 80.55% 9 Missing and 5 partials ⚠️
router/pkg/mcpserver/server.go 86.36% 2 Missing and 1 partial ⚠️
router/core/graph_server.go 33.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3151      +/-   ##
==========================================
+ Coverage   62.37%   62.61%   +0.24%     
==========================================
  Files         262      266       +4     
  Lines       31003    31217     +214     
==========================================
+ Hits        19337    19546     +209     
+ Misses      10158    10143      -15     
- Partials     1508     1528      +20     
Files with missing lines Coverage Δ
router/core/router_config.go 93.97% <ø> (ø)
router/pkg/config/config.go 83.00% <ø> (ø)
router/pkg/mcpserver/paths.go 100.00% <100.00%> (ø)
router/pkg/mcpserver/validation.go 100.00% <100.00%> (ø)
router/core/graph_server.go 85.76% <33.33%> (ø)
router/pkg/mcpserver/server.go 75.80% <86.36%> (+5.25%) ⬆️
router/pkg/mcpserver/host.go 80.55% <80.55%> (ø)
router/core/router.go 70.94% <74.21%> (-0.04%) ⬇️

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
router/pkg/config/config.schema.json (1)

2741-2772: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Require non-empty per-entry oauth.jwks when OAuth is enabled.

base_url correctly falls back to mcp.server.base_url, so do not require it per entry. The Go server rejects an entry with empty oauth.jwks, but schema validation should report this configuration error earlier.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/config/config.schema.json` around lines 2741 - 2772, Update the
per-entry schema under the servers additionalProperties object so that enabling
oauth requires a non-empty oauth.jwks value, matching the Go server validation.
Preserve base_url as optional because it inherits from mcp.server.base_url, and
use the existing mcp_oauth definition or conditional schema mechanisms rather
than requiring base_url per entry.
router/pkg/mcpserver/host.go (1)

102-108: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Disable the fixed write deadline for stateful MCP streams

GraphQLSchemaServer.Serve used the same timeout values, but v1.7.0 stateful mode keeps standalone SSE GET responses open. A 30-second WriteTimeout can reject later events on these streams. Set WriteTimeout: 0 or clear the deadline for streaming requests. Use explicit request or session contexts for stream lifetime; IdleTimeout does not limit an active response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/mcpserver/host.go` around lines 102 - 108, Update the http.Server
initialization in the host server setup to disable the fixed write deadline by
setting WriteTimeout to zero, preserving the existing read and idle timeouts.
Ensure stateful MCP standalone SSE streams use their request or session context
for lifetime management rather than relying on the HTTP server write timeout.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-website/router/configuration.mdx`:
- Around line 332-337: Update the deprecation notice in the Info block before
the single-server options table to explicitly exclude mcp.enabled,
mcp.server.listen_addr, and mcp.server.base_url from the deprecated options.
Keep the migration guidance for the remaining single-server options and align
the wording with the MCP Configuration page.

In `@docs-website/router/mcp/oauth/configuration.mdx`:
- Around line 127-131: The OAuth configuration examples must use the full
resource identifier including the default /mcp path. Update the resource
response and oauth.jwks[].audiences example values from the host-only URL to
https://mcp.example.com/mcp, while preserving the surrounding configuration and
explanatory text.

In `@router/core/router_test.go`:
- Around line 576-599: Update
TestWarnIgnoredDeprecatedMCPOptionsSkipsUntouchedGraphName so cfg includes
another deprecated MCP option that causes warnIgnoredDeprecatedMCPOptions to
emit a warning. Assert that a warning entry is present, then verify its
ignored_options field contains the other option but excludes mcp.graph_name,
ensuring the graph-name assertion always executes.

In `@router/pkg/config/config.go`:
- Line 1382: Update the MCP server conversion involving MCPServerEntry before
constructing authentication.JWKSConfig: apply the default 1m RefreshInterval and
RefreshUnknownKID values, then copy the configured AllowedUse into the resulting
OAuth configuration. Ensure these resolved values are used for mcp.servers
entries despite MCPServerEntry lacking env tags.

In `@router/pkg/mcpserver/paths_test.go`:
- Line 21: Update the “interior double slash” case in ValidateMountPath tests to
expect validation failure for /a//b, ensuring the test asserts an error rather
than accepting the path.

---

Nitpick comments:
In `@router/pkg/config/config.schema.json`:
- Around line 2741-2772: Update the per-entry schema under the servers
additionalProperties object so that enabling oauth requires a non-empty
oauth.jwks value, matching the Go server validation. Preserve base_url as
optional because it inherits from mcp.server.base_url, and use the existing
mcp_oauth definition or conditional schema mechanisms rather than requiring
base_url per entry.

In `@router/pkg/mcpserver/host.go`:
- Around line 102-108: Update the http.Server initialization in the host server
setup to disable the fixed write deadline by setting WriteTimeout to zero,
preserving the existing read and idle timeouts. Ensure stateful MCP standalone
SSE streams use their request or session context for lifetime management rather
than relying on the HTTP server write timeout.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 47a8d347-4ad6-4304-9b9d-ea1c05cb32bc

📥 Commits

Reviewing files that changed from the base of the PR and between f8f0a76 and 89ac4f5.

📒 Files selected for processing (20)
  • docs-website/router/configuration.mdx
  • docs-website/router/mcp/configuration.mdx
  • docs-website/router/mcp/oauth/configuration.mdx
  • router/core/graph_server.go
  • router/core/router.go
  • router/core/router_config.go
  • router/core/router_test.go
  • router/pkg/config/config.go
  • router/pkg/config/config.schema.json
  • router/pkg/config/config_test.go
  • router/pkg/config/testdata/config_defaults.json
  • router/pkg/config/testdata/config_full.json
  • router/pkg/mcpserver/host.go
  • router/pkg/mcpserver/host_test.go
  • router/pkg/mcpserver/paths.go
  • router/pkg/mcpserver/paths_test.go
  • router/pkg/mcpserver/server.go
  • router/pkg/mcpserver/server_test.go
  • router/pkg/mcpserver/validation.go
  • router/pkg/mcpserver/validation_test.go

Comment on lines +332 to +337
<Info>
This table documents the deprecated, single-server options. Use `mcp.servers` to run one or more MCP servers from
one router. See [MCP Configuration](/router/mcp/configuration#running-multiple-mcp-servers) for the full
reference, including the migration path from these options.
</Info>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the scope of the deprecation notice.

The block marks the whole table as deprecated. The table includes mcp.enabled (Line 340) and mcp.server.listen_addr (Line 341). docs-website/router/mcp/configuration.mdx Lines 249-250 state that mcp.server.listen_addr and mcp.server.base_url are not deprecated, and Lines 121-122 state that mcp.enabled still controls all servers. Name the exceptions here so the two pages agree.

📝 Proposed wording fix
 <Info>
-  This table documents the deprecated, single-server options. Use `mcp.servers` to run one or more MCP servers from
-  one router. See [MCP Configuration](/router/mcp/configuration#running-multiple-mcp-servers) for the full
-  reference, including the migration path from these options.
+  Most options in this table are deprecated single-server options. `mcp.enabled` and `mcp.server.listen_addr`
+  remain current. They control all MCP servers and the shared listener. Use `mcp.servers` to run one or more MCP
+  servers from one router. See [MCP Configuration](/router/mcp/configuration#running-multiple-mcp-servers) for the
+  full reference, including the migration path from these options.
 </Info>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Info>
This table documents the deprecated, single-server options. Use `mcp.servers` to run one or more MCP servers from
one router. See [MCP Configuration](/router/mcp/configuration#running-multiple-mcp-servers) for the full
reference, including the migration path from these options.
</Info>
<Info>
Most options in this table are deprecated single-server options. `mcp.enabled` and `mcp.server.listen_addr`
remain current. They control all MCP servers and the shared listener. Use `mcp.servers` to run one or more MCP
servers from one router. See [MCP Configuration](/router/mcp/configuration#running-multiple-mcp-servers) for the
full reference, including the migration path from these options.
</Info>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-website/router/configuration.mdx` around lines 332 - 337, Update the
deprecation notice in the Info block before the single-server options table to
explicitly exclude mcp.enabled, mcp.server.listen_addr, and mcp.server.base_url
from the deprecated options. Keep the migration guidance for the remaining
single-server options and align the wording with the MCP Configuration page.

Comment on lines +127 to +131
<Info>
This example uses `mcp.server.base_url` and the default `/mcp` path from the deprecated single-server form. With
`mcp.servers`, each server publishes its own metadata at its own `base_url` and `path`. See
[Multiple Servers with OAuth Behind a Load Balancer](/router/mcp/configuration#multiple-servers-with-oauth-behind-a-load-balancer).
</Info>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how the resource identifier and metadata path are derived from base_url and mount path.
set -uo pipefail

fd -t f 'paths.go|paths_test.go|server.go' router/pkg/mcpserver --exec rg -n -C 6 'oauth-protected-resource|Resource|base_url|BaseURL|MountPath'

Repository: wundergraph/cosmo

Length of output: 13681


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- OAuth configuration example ---'
sed -n '110,155p' docs-website/router/mcp/oauth/configuration.mdx

printf '%s\n' '--- Multiple-server configuration reference ---'
sed -n '220,245p' docs-website/router/mcp/configuration.mdx

printf '%s\n' '--- base_url and path references in the OAuth documentation ---'
rg -n -C 3 'base_url|oauth\.jwks|audiences|resource' docs-website/router/mcp/oauth/configuration.mdx docs-website/router/mcp/configuration.mdx

Repository: wundergraph/cosmo

Length of output: 29131


Include /mcp in the resource identifier examples. The server emits https://mcp.example.com/mcp for the default /mcp mount path. Update the resource response and oauth.jwks[].audiences values to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-website/router/mcp/oauth/configuration.mdx` around lines 127 - 131, The
OAuth configuration examples must use the full resource identifier including the
default /mcp path. Update the resource response and oauth.jwks[].audiences
example values from the host-only URL to https://mcp.example.com/mcp, while
preserving the surrounding configuration and explanatory text.

Comment on lines +576 to +599
func TestWarnIgnoredDeprecatedMCPOptionsSkipsUntouchedGraphName(t *testing.T) {
t.Parallel()

cfg := config.MCPConfiguration{
Enabled: true,
GraphName: defaultMCPGraphName,
Servers: map[string]config.MCPServerEntry{
"support": {Enabled: true, Path: "/mcp/support"},
},
}

obsCore, logs := observer.New(zapcore.WarnLevel)
logger := zap.New(obsCore)

warnIgnoredDeprecatedMCPOptions(cfg, logger)

for _, entry := range logs.All() {
for _, field := range entry.Context {
if field.Key == "ignored_options" {
require.NotContains(t, field.Interface, "mcp.graph_name")
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The graph_name assertion never executes.

The config sets only GraphName: defaultMCPGraphName and Servers. warnIgnoredDeprecatedMCPOptions therefore builds an empty ignored slice and returns before it logs. logs.All() is empty, both loops iterate zero times, and require.NotContains never runs. The test passes even if the defaultMCPGraphName comparison is removed.

Set one other deprecated option so a warning is emitted, then assert the warning exists and excludes mcp.graph_name.

💚 Proposed fix to make the assertion effective
 	cfg := config.MCPConfiguration{
 		Enabled:   true,
 		GraphName: defaultMCPGraphName,
+		// Force a warning so the graph_name assertion below is reachable.
+		ExcludeMutations: true,
 		Servers: map[string]config.MCPServerEntry{
 			"support": {Enabled: true, Path: "/mcp/support"},
 		},
 	}
 
 	obsCore, logs := observer.New(zapcore.WarnLevel)
 	logger := zap.New(obsCore)
 
 	warnIgnoredDeprecatedMCPOptions(cfg, logger)
 
-	for _, entry := range logs.All() {
-		for _, field := range entry.Context {
-			if field.Key == "ignored_options" {
-				require.NotContains(t, field.Interface, "mcp.graph_name")
-			}
-		}
-	}
+	entries := logs.All()
+	require.Len(t, entries, 1)
+
+	var ignored []string
+	for _, field := range entries[0].Context {
+		if field.Key == "ignored_options" {
+			ignored = field.Interface.([]string)
+		}
+	}
+	require.Contains(t, ignored, "mcp.exclude_mutations")
+	require.NotContains(t, ignored, "mcp.graph_name")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestWarnIgnoredDeprecatedMCPOptionsSkipsUntouchedGraphName(t *testing.T) {
t.Parallel()
cfg := config.MCPConfiguration{
Enabled: true,
GraphName: defaultMCPGraphName,
Servers: map[string]config.MCPServerEntry{
"support": {Enabled: true, Path: "/mcp/support"},
},
}
obsCore, logs := observer.New(zapcore.WarnLevel)
logger := zap.New(obsCore)
warnIgnoredDeprecatedMCPOptions(cfg, logger)
for _, entry := range logs.All() {
for _, field := range entry.Context {
if field.Key == "ignored_options" {
require.NotContains(t, field.Interface, "mcp.graph_name")
}
}
}
}
func TestWarnIgnoredDeprecatedMCPOptionsSkipsUntouchedGraphName(t *testing.T) {
t.Parallel()
cfg := config.MCPConfiguration{
Enabled: true,
GraphName: defaultMCPGraphName,
// Force a warning so the graph_name assertion below is reachable.
ExcludeMutations: true,
Servers: map[string]config.MCPServerEntry{
"support": {Enabled: true, Path: "/mcp/support"},
},
}
obsCore, logs := observer.New(zapcore.WarnLevel)
logger := zap.New(obsCore)
warnIgnoredDeprecatedMCPOptions(cfg, logger)
entries := logs.All()
require.Len(t, entries, 1)
var ignored []string
for _, field := range entries[0].Context {
if field.Key == "ignored_options" {
ignored = field.Interface.([]string)
}
}
require.Contains(t, ignored, "mcp.exclude_mutations")
require.NotContains(t, ignored, "mcp.graph_name")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/core/router_test.go` around lines 576 - 599, Update
TestWarnIgnoredDeprecatedMCPOptionsSkipsUntouchedGraphName so cfg includes
another deprecated MCP option that causes warnIgnoredDeprecatedMCPOptions to
emit a warning. Assert that a warning entry is present, then verify its
ignored_options field contains the other option but excludes mcp.graph_name,
ensuring the graph-name assertion always executes.

ExposeSchema bool `yaml:"expose_schema"`
OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix"`
Session MCPServerSessionConfig `yaml:"session,omitempty"`
OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: List envDefault-bearing fields in the MCP OAuth config subtree and the router-side resolvers for mcp.servers entries.
set -euo pipefail

fd -t f 'config.go' router/pkg/config --exec rg -n 'MCPOAuthConfiguration|MCPJWKSConfiguration|JWKS(Configuration)? struct|envDefault' {} \; | rg -n 'MCP|JWKS' || true

echo '--- router-side resolvers ---'
rg -n 'resolveMCPServer|deprecatedServerEntry|MCPServerEntry' router/core --type=go -C3

Repository: wundergraph/cosmo

Length of output: 19257


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- config declarations ---'
sed -n '650,730p;1335,1450p' router/pkg/config/config.go

echo '--- all OAuth/JWKS references and default-related code ---'
rg -n 'MCPOAuthConfiguration|JWKSConfiguration|JWKS|Refresh|Allowed.*Use|MaxScopeCombinations|envDefault' --glob '*.go' router mcp 2>/dev/null | head -n 300

echo '--- OAuth consumer implementation ---'
rg -l 'WithOAuth|MaxScopeCombinations|RefreshInterval|AllowedKeyUse|JWKS' --glob '*.go' . | head -n 100

Repository: wundergraph/cosmo

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- MCP OAuth and JWKS tags only ---'
rg -n -A25 -B3 '^type (JWKSConfiguration|RefreshUnknownKID|MCPOAuthConfiguration|MCPOAuthScopesConfiguration) struct' router/pkg/config/config.go

echo '--- MCP server OAuth conversion ---'
sed -n '225,275p;435,465p;525,555p' router/pkg/mcpserver/server.go

echo '--- authentication JWKS defaults and use handling ---'
sed -n '40,155p' router/pkg/authentication/jwks_token_decoder.go

echo '--- config loading and env.Parse/YAML merge ---'
rg -n -A8 -B8 'env\.Parse|yaml|LoadConfig' router/pkg/config --glob '*.go' | head -n 240

Repository: wundergraph/cosmo

Length of output: 28892


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- complete MCP OAuth JWKS mapping ---'
sed -n '245,275p' router/pkg/mcpserver/server.go
sed -n '300,330p' router/core/supervisor_instance.go

echo '--- allowed-use conversion and enforcement ---'
rg -n -A35 -B8 'func toJwksetUseType|allowedUse|AllowedUse' router/pkg/authentication --glob '*.go'

echo '--- jwkset dependency version and available source ---'
rg -n 'github.com/.*/jwkset|jwkset' go.mod go.sum router/go.mod router/go.sum 2>/dev/null || true
go env GOPATH GOMODCACHE 2>/dev/null || true
find "$(go env GOMODCACHE 2>/dev/null || echo /nonexistent)" -path '*jwkset*' -type f 2>/dev/null | head -n 30

Repository: wundergraph/cosmo

Length of output: 24686


🏁 Script executed:

#!/bin/bash
set -euo pipefail

mod=/home/jailuser/go/pkg/mod/github.com/!micah!parks/jwkset@v0.11.0

echo '--- jwkset refresh option handling ---'
rg -n -A12 -B12 'RefreshInterval|RefreshUnknownKID|RateLimitWaitMax|NewStorageFromHTTP|NewHTTPClient' "$mod" --glob '*.go' | head -n 260

echo '--- repository allowed_use examples and documentation ---'
rg -n -A8 -B8 'allowed_use|refresh_unknown_kid|refresh_interval' --glob '*.yaml' --glob '*.yml' --glob '*.md' --glob '*.go' . | head -n 260

Repository: wundergraph/cosmo

Length of output: 41798


Resolve JWKS defaults for mcp.servers entries.

Because MCPServerEntry has no env tags, resolve RefreshInterval to 1m and the RefreshUnknownKID defaults before constructing authentication.JWKSConfig. Also copy AllowedUse; the MCP conversion currently ignores configured allowed_use.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/config/config.go` at line 1382, Update the MCP server conversion
involving MCPServerEntry before constructing authentication.JWKSConfig: apply
the default 1m RefreshInterval and RefreshUnknownKID values, then copy the
configured AllowedUse into the resulting OAuth configuration. Ensure these
resolved values are used for mcp.servers entries despite MCPServerEntry lacking
env tags.

{name: "nested", path: "/billing/mcp"},
{name: "root", path: "/", wantErr: "shared listener"},
{name: "single character", path: "/a"},
{name: "interior double slash", path: "/a//b"},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the multiplexer type and registration used for MCP mount paths.
set -euo pipefail

fd -t f 'host.go' router/pkg/mcpserver --exec cat -n {} \;

echo '--- route registration ---'
rg -n --type=go -C4 'Handle\(|HandleFunc\(|ServeMux' router/pkg/mcpserver

Repository: wundergraph/cosmo

Length of output: 12334


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- mount-path validation and tests ---'
cat -n router/pkg/mcpserver/paths.go
cat -n router/pkg/mcpserver/paths_test.go

echo '--- declared Go version ---'
rg -n '^(go|toolchain) ' go.mod router/go.mod 2>/dev/null || true

echo '--- standalone ServeMux probe ---'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
cat >"$tmpdir/go.mod" <<'EOF'
module muxprobe

go 1.22
EOF
cat >"$tmpdir/main.go" <<'EOF'
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.Handle("/a//b", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "handler")
	}))

	for _, path := range []string{"/a//b", "/a/b", "/a//b/" } {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		handler, pattern := mux.Handler(req)
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		fmt.Printf("path=%q pattern=%q status=%d location=%q body=%q request_path=%q\n",
			path, pattern, rec.Code, rec.Header().Get("Location"), rec.Body.String(), req.URL.Path)
	}
}
EOF
(cd "$tmpdir" && go run .)

Repository: wundergraph/cosmo

Length of output: 6047


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Go runtime ---'
go version

echo '--- standalone ServeMux probe with cgo disabled ---'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
cat >"$tmpdir/go.mod" <<'EOF'
module muxprobe

go 1.25
EOF
cat >"$tmpdir/main.go" <<'EOF'
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.Handle("/a//b", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "handler")
	}))

	for _, path := range []string{"/a//b", "/a/b", "/a//b/"} {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		handler, pattern := mux.Handler(req)
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		fmt.Printf("path=%q pattern=%q status=%d location=%q body=%q request_path=%q\n",
			path, pattern, rec.Code, rec.Header().Get("Location"), rec.Body.String(), req.URL.Path)
	}
}
EOF
(cd "$tmpdir" && CGO_ENABLED=0 go run .)

Repository: wundergraph/cosmo

Length of output: 604


Reject interior // in ValidateMountPath.

http.ServeMux redirects /a//b to /a/b before route matching, so the /a//b handler is unreachable. Update the test case to expect an error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/mcpserver/paths_test.go` at line 21, Update the “interior double
slash” case in ValidateMountPath tests to expect validation failure for /a//b,
ensuring the test asserts an error rather than accepting the path.

@asoorm asoorm changed the title feat(mcp): serve multiple MCP servers from one router on separate paths [WIP] feat(mcp): serve multiple MCP servers from one router on separate paths Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant