[TT-17137] Support remote MCPs as upstream — routing fix + auto-mirror PRM + OAuth proxy#8181
Conversation
Two related fixes so an MCP API can transparently proxy an OAuth-protected remote MCP server (e.g. Atlassian Rovo, Notion) without manual sidecars. Routing fix: when the upstream URL has a path component (e.g. /v1/mcp), OAuth/OIDC well-known discovery probes were incorrectly prefixed with that path and 404'd against the upstream. The reverse-proxy director now detects host-root discovery prefixes (RFC 9728, RFC 8414, OIDC) for MCP APIs and routes them to the upstream host root. PRM mirror mode: a new mode="mirror" on x-tyk-api-gateway.server.authentication.protectedResourceMetadata makes Tyk fetch the upstream's Protected Resource Metadata document, rewrite the `resource` field to the gateway URL the client connected to (so RFC 9728 §3.3 origin validation passes), cache with TTL, and serve at the path-suffix URL clients actually probe (`<gateway>/.well-known/oauth-protected-resource<listen-path>`, with and without trailing slash). The mirror route is auto-registered for any MCP API with PRM enabled. A response-side helper also augments upstream 401 challenges with `resource_metadata=` when the upstream sends a bare Bearer challenge so lax clients still find the doc. Validated end-to-end against Atlassian Rovo with Roo / mcp-remote: a single POST /tyk/mcps with `protectedResourceMetadata.mode: mirror` is enough to put Tyk in front of the remote MCP and have the OAuth dance complete in the browser. Notion works with the documented mcp-remote `--resource` flag (Notion enforces RFC 8707 strictly; full transparent support needs an OAuth-flow proxy, deferred to a follow-up).
Mirror-mode PRM rewrites the doc's `resource` field to the gateway URL
so RFC 9728 §3.3 origin validation passes, but strict authorization
servers (Notion as of 2026-Q2) then reject the client's authorize
request with `invalid_target` because the `resource` parameter the
client carries forward (per RFC 8707) is the gateway URL — not what
the upstream AS knows the resource by.
Mirror mode now interposes a per-API OAuth proxy so the two specs no
longer pull in opposite directions:
* The mirrored PRM doc redirects `authorization_servers` at a Tyk
internal URL `<gateway>/__tyk-as/<api-id>` instead of the upstream
AS, so clients discover Tyk's proxy first.
* Three new gateway-root routes per mirror-mode MCP API:
- GET /.well-known/oauth-authorization-server/__tyk-as/<api-id>
serves the upstream's AS metadata with `authorization_endpoint`
and `token_endpoint` rewritten to point at Tyk; `issuer` and
`registration_endpoint` are preserved verbatim (DCR doesn't carry
a `resource` parameter, so it can stay direct).
- GET /__tyk-as/<api-id>/authorize 302-redirects to the upstream
authorize endpoint after swapping `resource=<gateway-url>` for
`resource=<upstream-url>` in the query string.
- POST /__tyk-as/<api-id>/token forwards the token request to the
upstream token endpoint with the same `resource` form-field swap
and returns the upstream response unmodified.
Net effect: the client believes it's talking to one coherent OAuth
resource server at the gateway URL (origin check passes); the
upstream AS sees its own URL in the resource parameter (RFC 8707
check passes); Tyk reconciles the two on the wire. Validated end to
end against Atlassian Rovo and Notion via Roo with a single
POST /tyk/mcps per upstream and no client-side flags.
Tests cover the AS metadata rewrite, the authorize 302 with resource
rewrite, and the token forward with resource rewrite.
Make `mode: mirror` the implicit default for any MCP API that doesn't
configure protectedResourceMetadata explicitly. The minimal MCP API
definition is now just listen path + upstream — POSTing it to
/tyk/mcps is enough to put Tyk transparently in front of any
spec-compliant remote MCP, with no `authentication` block and no
`protectedResourceMetadata` block at all.
Implementation
* `EffectiveMode(isMCP bool)` resolves an empty `Mode` from the
configuration shape: Resource set → static (back-compat for existing
configs); Resource empty AND IsMCP → mirror; everything else →
static. `Validate` and `IsMirrorMode` go through it.
* `OAS.ValidateForMCP(ctx, opts...)` runs the full validation chain
with PRM checked in MCP context (Validate(true)). Used by the
/tyk/mcps admin endpoint and by APISpec.Validate during reload, so
on-disk MCP specs aren't rejected with "resource is required" at
spec-load time after each reload.
* `GetPRMConfig` synthesises a default `{Enabled: true, Mode: ""}`
for MCP APIs whose OAS doc has no explicit PRM block, so all the
runtime plumbing (well-known suffix route, AS-proxy routes,
WWW-Authenticate augmenter) engages without any opt-in config.
Bug fixes uncovered by the auto-mirror flow
* augmentMCPWWWAuthenticate previously read scheme/host from
res.Request, which the reverse-proxy director has rewritten to the
upstream URL. The injected `resource_metadata=` parameter pointed
at the upstream rather than the gateway. Threaded the original
inbound request through and use httputil.RequestScheme so the
injected URL is the gateway's PRM endpoint. Call site moved into
WrappedServeHTTP where the inbound request is in scope.
* augmentMCPWWWAuthenticate now overwrites an existing
`resource_metadata=` instead of preserving it. Spec-compliant
upstreams (Notion) already advertise their own URL via the
parameter; preserving it pointed clients at the upstream's PRM
doc, which then failed RFC 9728 §3.3 origin validation at the
client and crashed the dance in registerClient on a fall-through
to /register. Mirror mode now consistently redirects clients at
Tyk's PRM endpoint regardless of what the upstream said.
Tests
Covers default-mirror-on-MCP, static back-compat (resource set keeps
static), explicit `enabled: false` opt-out, and the overwrite
semantics for upstream-supplied resource_metadata. All gateway,
apidef/oas, apidef/mcp, internal/mcp tests pass; go vet clean.
Verified end-to-end against Atlassian Rovo and Notion via Roo /
mcp-remote with the bare-minimum API definition (no auth block, no
PRM block).
…-remote-mcp-as-upstream
|
This PR enables Tyk to act as a zero-configuration, transparent reverse proxy for remote Multi-Channel Proxy (MCP) servers like Atlassian Rovo and Notion. It resolves three critical failure points in the OAuth flow, allowing MCP clients to authenticate through the gateway seamlessly.
Files Changed AnalysisThe changes are well-structured across the API definition, core gateway logic, and a new internal package:
Architecture & Impact Assessment
OAuth Flow VisualizationsequenceDiagram
participant Client
participant Tyk Gateway
participant Upstream MCP
participant "Upstream Auth Server (AS)"
Client->>+Tyk Gateway: 1. POST / (MCP request)
Tyk Gateway->>+Upstream MCP: Forward request
Upstream MCP-->>-Tyk Gateway: 2. 401 Unauthorized + WWW-Authenticate
Tyk Gateway->>Tyk Gateway: 3. Augment WWW-Authenticate header with<br>resource_metadata=<gateway-prm-url>
Tyk Gateway-->>-Client: 401 with augmented header
Client->>+Tyk Gateway: 4. GET /.well-known/oauth-protected-resource/... (PRM probe)
Tyk Gateway->>Tyk Gateway: 5. (Mirror Mode) Fetch upstream PRM, rewrite `resource` & `authorization_servers`
Tyk Gateway-->>-Client: 6. Mirrored PRM Document (points to Tyk AS Proxy)
Client->>+Tyk Gateway: 7. GET /__tyk-as/<api-id>/authorize?resource=<gateway-url>
Tyk Gateway->>Tyk Gateway: 8. Rewrite `resource` param to <upstream-url>
Tyk Gateway-->>-"Upstream Auth Server (AS)": 9. 302 Redirect to AS with rewritten param
Note over "Upstream Auth Server (AS)", Client: (User completes OAuth dance via browser)
Client->>+Tyk Gateway: 10. POST /__tyk-as/<api-id>/token
Tyk Gateway->>Tyk Gateway: 11. Rewrite `resource` param to <upstream-url>
Tyk Gateway->>+"Upstream Auth Server (AS)": 12. Forward token request
"Upstream Auth Server (AS)"-->>-Tyk Gateway: 13. Access Token
Tyk Gateway-->>-Client: 14. Access Token
Scope Discovery & Context ExpansionThis PR establishes a powerful new pattern of protocol-aware proxying within Tyk. While scoped to MCP, the architectural components are generalizable:
Metadata
Powered by Visor from Probelabs Last updated: 2026-05-07T14:58:24.362Z | Triggered by: pr_updated | Commit: ce09452 💡 TIP: You can chat with Visor using |
Security Issues (2)
Architecture Issues (2)
Performance Issues (2)
Quality Issues (1)
Powered by Visor from Probelabs Last updated: 2026-05-07T14:57:38.909Z | Triggered by: pr_updated | Commit: ce09452 💡 TIP: You can chat with Visor using |
A previous change inferred mirror mode from `Resource` being empty for MCP APIs. That regressed two existing tests in TestValidateMCP_PRM that POST a PRM block with only `authorizationServers` set (no resource): the user is clearly midway through configuring static mode and expects "resource is required" feedback, not a silent default to mirror. Tighten EffectiveMode so any static-field hint — `Resource` or `AuthorizationServers` populated — keeps the empty-mode path on static. Mirror is the implicit default only when neither field is set and the API is MCP, which is the bare-minimum-config flow this PR is aiming for. Adds a regression case to TestProtectedResourceMetadata_IsMirrorMode.
- Extract `resolveUpstreamASMetadata` from the three handlers that all
did the same firstAuthorizationServer + fetchUpstreamASMetadata
two-step with identical bad-gateway error paths. The handlers now
early-exit with a single `if !ok { return }`, dedupes the literal
error strings (caught by SonarQube as 3x "upstream authorization
server unavailable" / "upstream AS metadata unavailable"), and
drops the cognitive complexity of authorizeProxyHandler (17 -> 9)
and tokenProxyHandler (19 -> 10).
- Move the per-handler error literals to package constants
(errUpstreamASUnavailable, errUpstreamASMetadataUnavail,
errUpstreamMissingAuthorizeEP, errUpstreamMissingTokenEP,
errInvalidUpstreamAuthorizeURL).
- Pull the authorize-URL query merge out of the handler into
`mergedAuthorizeQuery`.
- Pull the token-endpoint POST construction out into
`buildTokenRequest` and the upstream response mirror into
`writeUpstreamResponse`.
While refactoring caught a real bug: the previous code did
context.WithTimeout inside the helper that built the token request
and stashed cancel without anyone deferring it; cancel fired the
moment the helper returned, killing the in-flight upstream request.
Lifted the timeout context into the handler with `defer cancel()`
so it covers the full Do + body-read.
Behavior unchanged otherwise. Verified live against Atlassian Rovo
via mcp-remote: discovery, DCR, browser-opened authorize URL with
the resource parameter rewritten as before.
Pushes new-code coverage past 80%. Tests cover: gateway/mcp_oauth_proxy_test.go (new): * AS-proxy metadata endpoint serves a rewritten doc (issuer preserved, authorize/token endpoints repointed at Tyk). * AS-proxy authorize handler 302s with the resource parameter rewritten gateway-URL -> upstream-URL. * AS-proxy token handler forwards the POST and the upstream sees resource rewritten on the form body. * Both authorize and token handlers return 502 when upstream PRM / AS metadata is missing or unreachable. * fetchUpstreamASMetadata path-suffix, path-prefix and host-root variants, all-404 fallback, malformed URL rejection. * registerMCPPRMSuffixRoutes early-return guards (nil spec, non-MCP, PRM disabled, root listen path). * replaceOrAppendResourceMetadata: append-if-absent, replace-if- present, case-insensitive match. * PRM mirror error path: serveMirroredPRM returns 502 when upstream is unreachable. * rewriteResourceParam unit cases. internal/mcp/prm_cache_test.go (extended): * FetchUpstreamPRM error variants (empty URL, malformed JSON, 404). * PRMCache.Invalidate. * PRMDocument nil-safety (Resource, SetResource, MarshalJSON). * SetResource initialises Raw lazily. * NewPRMCache(0) defaults to DefaultPRMCacheTTL. apidef/oas/validate_for_mcp_test.go (new): * OAS.ValidateForMCP passes empty PRM (mirror by default for MCP); plain Validate rejects same config; explicit static configs pass either path; static-MCP without authorizationServers still rejected; disabled or absent PRM block passes both. * EffectiveMode resolution table — explicit overrides win, shape- driven defaults match. All green; go vet clean.
Mirror is the implicit default for MCP APIs without static PRM fields, and shape-based inference (resource/authorizationServers populated => static, otherwise mirror) covers every sane case. The explicit `mode` field and `upstreamPRMUrl` override never carried real product value: setting `mode: "mirror"` on a config with `resource` populated would just override what the operator obviously meant; setting `upstreamPRMUrl` is unnecessary because the path-suffix derivation from the upstream URL works for every spec-compliant remote MCP. Removed: * `Mode` and `UpstreamPRMUrl` fields on `apidef/oas.ProtectedResourceMetadata`. * `PRMModeStatic` / `PRMModeMirror` constants. * `EffectiveMode(isMCP bool) string` — superseded by the simpler `IsMirrorMode(isMCP bool) bool`, which now contains the entire mode resolution logic. * JSON-schema entries for `mode` and `upstreamPRMUrl` in both `apidef/oas/schema/x-tyk-api-gateway*.json` and `apidef/mcp/schema/x-tyk-api-gateway.json`. Validation logic preserved: static mode requires `resource`, plus `authorizationServers` for MCP APIs. Mirror skips both. `mw_protected_resource.go::upstreamPRMDoc` always derives the PRM URL via `mcp.DeriveUpstreamPRMURL` now. Tests pruned to match: `EffectiveMode` cases gone, mirror-mode test fixtures no longer set `Mode` (the empty struct + IsMCP context is the canonical mirror-mode signal). Live E2E against Atlassian Rovo via mcp-remote unchanged: bare-minimum API def => mirror engages, AS-proxy 302s with resource parameter rewritten upstream URL.
10856c0 to
1078126
Compare
Formatting: gofmt-fix apidef/oas/validate_for_mcp_test.go and
gateway/mw_protected_resource_test.go.
Production unchecked errors:
* gateway/api_loader.go: log a warning when json.Encoder.Encode
fails on the PRM suffix-route handler instead of dropping the
error silently.
* gateway/mcp_oauth_proxy.go: same for the AS-proxy metadata
encoder; log when io.Copy on the upstream-response mirror fails;
return a wrapped error when io.ReadAll on AS metadata fails (was
swallowed, masking decode-time errors).
* internal/mcp/prm_cache.go: replace the type-assertion `_, _`
pattern in PRMDocument.Resource with the if-ok form; rename the
shadowing `url` parameter on SetResource to resourceURL; surface
io.ReadAll failures on PRM error-body decode instead of
swallowing them.
Production lint:
* gateway/mw_protected_resource.go: rename unused parameter prm to
_ on serveMirroredPRM (the doc is fetched via upstreamPRMDoc
inside).
* gateway/reverse_proxy.go: rename the shadowing `header` parameter
on replaceOrAppendResourceMetadata to headerValue.
Test-side annotations:
* Added //nolint:errcheck on httptest fixtures where w.Write,
fmt.Fprintf, and r.ParseForm legitimately discard their return
values (server-side test helpers can't surface failures
meaningfully).
* Replaced http.NewRequest discards with `errReq := …;
require.NoError(t, errReq)` in test bodies.
* Replaced url.Parse discards in reverse_proxy_test.go with proper
error checks.
* Renamed unused closure parameters (req in CheckRedirect,
t in subtests) to _.
* Converted `switch { case r.URL.Path == … }` blocks to tagged
`switch r.URL.Path` per QF1002; collapsed a single-case switch
to an if.
go vet clean. All MCP-mirror tests pass. Live E2E against
Atlassian Rovo via mcp-remote unchanged: bare-minimum API def
=> mirror engages, PRM doc rewritten, AS-proxy 302s with
resource parameter rewritten to upstream URL, DCR completes,
browser opens. No behavior change.
* mcp_oauth_proxy.go: replace `_, _ := metadata[...].(string)` and `_, _ := doc.Raw[...].([]any)` patterns with the if-ok form. Three call sites (authorize handler, token handler, firstAuthorizationServer); each was a type assertion paired with an emptiness check, where a missing key returns the zero value and is then caught — but errcheck (and SonarQube) flag the discarded ok value. The if-ok form is clearer and surfaces malformed upstream metadata as an explicit error from firstAuthorizationServer. * mw_protected_resource_test.go: move the //nolint:errcheck directive for the multi-line io.WriteString call from the closing line to the line above the call. nolint comments only apply to the line they're on, so the trailing-comment placement wasn't suppressing the warning on the call's first line. go vet clean. Live E2E unchanged: AS-proxy 302s with resource parameter rewritten to upstream URL; AS metadata served with authorize/token endpoints repointed at the gateway. PRM/auth flow verified end to end.
|
|
/release to release-5.13 |
|
/release to release-5.13.0 |
|
✅ Cherry-pick successful. A PR was created: #8182 |
|
✅ Cherry-pick successful. A PR was created: #8183 |
… routing fix + auto-mirror PRM + OAuth proxy (#8181) (#8182) [TT-17137] Support remote MCPs as upstream — routing fix + auto-mirror PRM + OAuth proxy (#8181) ## Summary Make Tyk able to put a transparent reverse proxy in front of any spec-compliant remote MCP server (Atlassian Rovo, Notion, etc.) so MCP clients (Roo, Claude Desktop, anything that uses `mcp-remote`) can complete the OAuth dance through the gateway with zero per-upstream knobs. Three commits, each fixing a distinct failure point along the wire. The minimal MCP API definition is now just listen path + upstream: ```json { "openapi": "3.0.3", "info": {"title": "jira", "version": "1.0.0"}, "paths": {"/": {"get": {...}, "post": {...}}}, "x-tyk-api-gateway": { "info": {"id": "jira-mcp-remote", "name": "jira", "state": {"active": true}}, "server": {"listenPath": {"value": "/jira/", "strip": true}}, "upstream": {"url": "https://mcp.atlassian.com/v1/mcp/authv2"} } } ``` POST that to `/tyk/mcps`. Mirror mode auto-engages — no `authentication` block, no `protectedResourceMetadata` block. The full OAuth dance completes end-to-end through Tyk. ## What was broken (three failure modes, in order) 1. **Routing**: when the upstream URL had a path component (e.g. `/v1/mcp/authv2`), `.well-known/*` discovery probes were prefixed with that path and 404'd — well-known endpoints live at the host root. 2. **RFC 9728 §3.3 origin mismatch**: even with routing fixed, transparent-proxying the upstream's PRM doc gives a `resource` field that doesn't match the URL the client connected to — strict clients (mcp-remote, Roo) refuse to authenticate. 3. **RFC 8707 `invalid_target`**: even with PRM rewritten, the client sends the gateway URL as the `resource` parameter to the upstream's authorize endpoint. Strict authorization servers (Notion) only accept the upstream URL and reject with `invalid_target`. ## What we built * **TT-17137 routing fix** (`gateway/reverse_proxy.go`): MCP-aware host-root branch in the reverse-proxy director routes well-known discovery probes (RFC 9728 / RFC 8414 / OIDC) to the upstream host root, ignoring the upstream URL's path. * **PRM mirror mode** (`apidef/oas/authentication.go`, `gateway/mw_protected_resource.go`, `gateway/api_loader.go`): new `mode: "mirror"` fetches the upstream's PRM doc, rewrites `resource` to the gateway URL, caches with TTL, auto-serves at the path-suffix URL clients actually probe (`<gateway>/.well-known/oauth-protected-resource<listen-path>`). * **OAuth-flow proxy** (`gateway/mcp_oauth_proxy.go`): mirror mode redirects `authorization_servers` at a per-API Tyk URL. Tyk serves a synthesised AS metadata document with `authorization_endpoint`/`token_endpoint` pointing at itself, then 302-redirects the authorize request and forwards token POSTs after rewriting the `resource` parameter from gateway URL to upstream URL. Reconciles RFC 9728 §3.3 vs. RFC 8707 on the wire. * **Auto-mirror default**: `EffectiveMode(isMCP)` resolves empty mode by configuration shape — Resource set → static (back-compat); Resource empty + MCP → mirror; everything else → static. `OAS.ValidateForMCP` threads MCP context through OAS-level validation so empty configs don't get rejected with "resource is required". `GetPRMConfig` synthesises a default for MCP APIs without an explicit PRM block, so the runtime plumbing engages with zero opt-in. * **WWW-Authenticate rewriter** (`augmentMCPWWWAuthenticate`): replaces (not appends) the `resource_metadata=` parameter on upstream 401s so clients are pointed at the gateway's PRM endpoint. Uses the inbound request's scheme/host (not the rewritten outbound). ## Validated end-to-end * **Atlassian Rovo** (`https://mcp.atlassian.com/v1/mcp/authv2`) — Roo browser flow completes, tools list returned. * **Notion** (`https://mcp.notion.com/mcp`) — same. RFC 8707 `invalid_target` resolved by the AS proxy's `resource` parameter rewrite. ## Tests * Unit: `apidef/oas/authentication_test.go` (`Validate`, `EffectiveMode`, `IsMirrorMode`), `internal/mcp/discovery_test.go` (host-root prefix matching), `internal/mcp/prm_cache_test.go` (TTL cache + URL derivation + upstream PRM fetch). * Integration: `gateway/reverse_proxy_test.go::TestReverseProxyMCPHostRootDiscovery` (routing fix; non-MCP regression guard), `gateway/mw_protected_resource_test.go::TestPRMMirrorMode_SuffixRoute` (PRM serving + rewrite), `TestPRMMirrorMode_OAuthProxy` (AS metadata rewrite, authorize 302, token forward), `TestAugmentMCPWWWAuthenticate` (overwrite semantics + scheme/host correctness + opt-out via `enabled: false`). `go vet` clean across changed packages. ## Architectural follow-ups (out of scope for this PR) * **Static-client / non-DCR upstreams** — for SaaS that don't allow public Dynamic Client Registration (most enterprise IdPs). Would extend `protectedResourceMetadata` with a `staticClient` block; Tyk synthesises a registration response from configured `client_id`/`client_secret` so mcp-remote stays oblivious. * **AS metadata caching** — the PRM doc is cached with TTL but AS metadata is currently re-fetched on every authorize/token round-trip. Easy win; ~40 LOC. * **Service-account / token-broker modes** — for use cases where the gateway should hold upstream credentials (`client_credentials`) or broker per-user tokens itself instead of being a transparent proxy. Different product surface. ## Test plan - [x] Unit tests pass (`go test ./apidef/... ./gateway/... ./internal/mcp/...`) - [x] `go vet` clean - [x] Live E2E: bare-minimum jira MCP API + Roo → browser opens, OAuth completes - [x] Live E2E: bare-minimum notion MCP API + Roo → browser opens, OAuth completes (RFC 8707 path) - [x] Existing PRM/PRM-suffix/JWT/AuthKey PRM tests still pass (no regression on static mode) ## See also - [`docs/mcp-remote-oauth-flow.md`](docs/mcp-remote-oauth-flow.md) — full wire diagram of the OAuth flow with the three failure-mode diagrams. - [`docs/mcp-remote-proxies.ipynb`](docs/mcp-remote-proxies.ipynb) — operator notebook for both Jira and Notion proxies. <!---TykTechnologies/jira-linter starts here--> ### Ticket Details <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17137" title="TT-17137" target="_blank">TT-17137</a> </summary> | | | |---------|----| | Status | Merge | | Summary | User should be easily able to set remote MCP as upstream | Generated at: 2026-05-07 18:36:33 </details> <!---TykTechnologies/jira-linter ends here--> [TT-17137]: https://tyktech.atlassian.net/browse/TT-17137?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: andrei-tyk <[email protected]>
… — routing fix + auto-mirror PRM + OAuth proxy (#8181) (#8183) [TT-17137] Support remote MCPs as upstream — routing fix + auto-mirror PRM + OAuth proxy (#8181) ## Summary Make Tyk able to put a transparent reverse proxy in front of any spec-compliant remote MCP server (Atlassian Rovo, Notion, etc.) so MCP clients (Roo, Claude Desktop, anything that uses `mcp-remote`) can complete the OAuth dance through the gateway with zero per-upstream knobs. Three commits, each fixing a distinct failure point along the wire. The minimal MCP API definition is now just listen path + upstream: ```json { "openapi": "3.0.3", "info": {"title": "jira", "version": "1.0.0"}, "paths": {"/": {"get": {...}, "post": {...}}}, "x-tyk-api-gateway": { "info": {"id": "jira-mcp-remote", "name": "jira", "state": {"active": true}}, "server": {"listenPath": {"value": "/jira/", "strip": true}}, "upstream": {"url": "https://mcp.atlassian.com/v1/mcp/authv2"} } } ``` POST that to `/tyk/mcps`. Mirror mode auto-engages — no `authentication` block, no `protectedResourceMetadata` block. The full OAuth dance completes end-to-end through Tyk. ## What was broken (three failure modes, in order) 1. **Routing**: when the upstream URL had a path component (e.g. `/v1/mcp/authv2`), `.well-known/*` discovery probes were prefixed with that path and 404'd — well-known endpoints live at the host root. 2. **RFC 9728 §3.3 origin mismatch**: even with routing fixed, transparent-proxying the upstream's PRM doc gives a `resource` field that doesn't match the URL the client connected to — strict clients (mcp-remote, Roo) refuse to authenticate. 3. **RFC 8707 `invalid_target`**: even with PRM rewritten, the client sends the gateway URL as the `resource` parameter to the upstream's authorize endpoint. Strict authorization servers (Notion) only accept the upstream URL and reject with `invalid_target`. ## What we built * **TT-17137 routing fix** (`gateway/reverse_proxy.go`): MCP-aware host-root branch in the reverse-proxy director routes well-known discovery probes (RFC 9728 / RFC 8414 / OIDC) to the upstream host root, ignoring the upstream URL's path. * **PRM mirror mode** (`apidef/oas/authentication.go`, `gateway/mw_protected_resource.go`, `gateway/api_loader.go`): new `mode: "mirror"` fetches the upstream's PRM doc, rewrites `resource` to the gateway URL, caches with TTL, auto-serves at the path-suffix URL clients actually probe (`<gateway>/.well-known/oauth-protected-resource<listen-path>`). * **OAuth-flow proxy** (`gateway/mcp_oauth_proxy.go`): mirror mode redirects `authorization_servers` at a per-API Tyk URL. Tyk serves a synthesised AS metadata document with `authorization_endpoint`/`token_endpoint` pointing at itself, then 302-redirects the authorize request and forwards token POSTs after rewriting the `resource` parameter from gateway URL to upstream URL. Reconciles RFC 9728 §3.3 vs. RFC 8707 on the wire. * **Auto-mirror default**: `EffectiveMode(isMCP)` resolves empty mode by configuration shape — Resource set → static (back-compat); Resource empty + MCP → mirror; everything else → static. `OAS.ValidateForMCP` threads MCP context through OAS-level validation so empty configs don't get rejected with "resource is required". `GetPRMConfig` synthesises a default for MCP APIs without an explicit PRM block, so the runtime plumbing engages with zero opt-in. * **WWW-Authenticate rewriter** (`augmentMCPWWWAuthenticate`): replaces (not appends) the `resource_metadata=` parameter on upstream 401s so clients are pointed at the gateway's PRM endpoint. Uses the inbound request's scheme/host (not the rewritten outbound). ## Validated end-to-end * **Atlassian Rovo** (`https://mcp.atlassian.com/v1/mcp/authv2`) — Roo browser flow completes, tools list returned. * **Notion** (`https://mcp.notion.com/mcp`) — same. RFC 8707 `invalid_target` resolved by the AS proxy's `resource` parameter rewrite. ## Tests * Unit: `apidef/oas/authentication_test.go` (`Validate`, `EffectiveMode`, `IsMirrorMode`), `internal/mcp/discovery_test.go` (host-root prefix matching), `internal/mcp/prm_cache_test.go` (TTL cache + URL derivation + upstream PRM fetch). * Integration: `gateway/reverse_proxy_test.go::TestReverseProxyMCPHostRootDiscovery` (routing fix; non-MCP regression guard), `gateway/mw_protected_resource_test.go::TestPRMMirrorMode_SuffixRoute` (PRM serving + rewrite), `TestPRMMirrorMode_OAuthProxy` (AS metadata rewrite, authorize 302, token forward), `TestAugmentMCPWWWAuthenticate` (overwrite semantics + scheme/host correctness + opt-out via `enabled: false`). `go vet` clean across changed packages. ## Architectural follow-ups (out of scope for this PR) * **Static-client / non-DCR upstreams** — for SaaS that don't allow public Dynamic Client Registration (most enterprise IdPs). Would extend `protectedResourceMetadata` with a `staticClient` block; Tyk synthesises a registration response from configured `client_id`/`client_secret` so mcp-remote stays oblivious. * **AS metadata caching** — the PRM doc is cached with TTL but AS metadata is currently re-fetched on every authorize/token round-trip. Easy win; ~40 LOC. * **Service-account / token-broker modes** — for use cases where the gateway should hold upstream credentials (`client_credentials`) or broker per-user tokens itself instead of being a transparent proxy. Different product surface. ## Test plan - [x] Unit tests pass (`go test ./apidef/... ./gateway/... ./internal/mcp/...`) - [x] `go vet` clean - [x] Live E2E: bare-minimum jira MCP API + Roo → browser opens, OAuth completes - [x] Live E2E: bare-minimum notion MCP API + Roo → browser opens, OAuth completes (RFC 8707 path) - [x] Existing PRM/PRM-suffix/JWT/AuthKey PRM tests still pass (no regression on static mode) ## See also - [`docs/mcp-remote-oauth-flow.md`](docs/mcp-remote-oauth-flow.md) — full wire diagram of the OAuth flow with the three failure-mode diagrams. - [`docs/mcp-remote-proxies.ipynb`](docs/mcp-remote-proxies.ipynb) — operator notebook for both Jira and Notion proxies. <!---TykTechnologies/jira-linter starts here--> ### Ticket Details <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17137" title="TT-17137" target="_blank">TT-17137</a> </summary> | | | |---------|----| | Status | Merge | | Summary | User should be easily able to set remote MCP as upstream | Generated at: 2026-05-07 18:36:49 </details> <!---TykTechnologies/jira-linter ends here--> [TT-17137]: https://tyktech.atlassian.net/browse/TT-17137?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: andrei-tyk <[email protected]>



Summary
Make Tyk able to put a transparent reverse proxy in front of any spec-compliant remote MCP server (Atlassian Rovo, Notion, etc.) so MCP clients (Roo, Claude Desktop, anything that uses
mcp-remote) can complete the OAuth dance through the gateway with zero per-upstream knobs. Three commits, each fixing a distinct failure point along the wire.The minimal MCP API definition is now just listen path + upstream:
{ "openapi": "3.0.3", "info": {"title": "jira", "version": "1.0.0"}, "paths": {"/": {"get": {...}, "post": {...}}}, "x-tyk-api-gateway": { "info": {"id": "jira-mcp-remote", "name": "jira", "state": {"active": true}}, "server": {"listenPath": {"value": "/jira/", "strip": true}}, "upstream": {"url": "https://mcp.atlassian.com/v1/mcp/authv2"} } }POST that to
/tyk/mcps. Mirror mode auto-engages — noauthenticationblock, noprotectedResourceMetadatablock. The full OAuth dance completes end-to-end through Tyk.What was broken (three failure modes, in order)
/v1/mcp/authv2),.well-known/*discovery probes were prefixed with that path and 404'd — well-known endpoints live at the host root.resourcefield that doesn't match the URL the client connected to — strict clients (mcp-remote, Roo) refuse to authenticate.invalid_target: even with PRM rewritten, the client sends the gateway URL as theresourceparameter to the upstream's authorize endpoint. Strict authorization servers (Notion) only accept the upstream URL and reject withinvalid_target.What we built
gateway/reverse_proxy.go): MCP-aware host-root branch in the reverse-proxy director routes well-known discovery probes (RFC 9728 / RFC 8414 / OIDC) to the upstream host root, ignoring the upstream URL's path.apidef/oas/authentication.go,gateway/mw_protected_resource.go,gateway/api_loader.go): newmode: "mirror"fetches the upstream's PRM doc, rewritesresourceto the gateway URL, caches with TTL, auto-serves at the path-suffix URL clients actually probe (<gateway>/.well-known/oauth-protected-resource<listen-path>).gateway/mcp_oauth_proxy.go): mirror mode redirectsauthorization_serversat a per-API Tyk URL. Tyk serves a synthesised AS metadata document withauthorization_endpoint/token_endpointpointing at itself, then 302-redirects the authorize request and forwards token POSTs after rewriting theresourceparameter from gateway URL to upstream URL. Reconciles RFC 9728 §3.3 vs. RFC 8707 on the wire.EffectiveMode(isMCP)resolves empty mode by configuration shape — Resource set → static (back-compat); Resource empty + MCP → mirror; everything else → static.OAS.ValidateForMCPthreads MCP context through OAS-level validation so empty configs don't get rejected with "resource is required".GetPRMConfigsynthesises a default for MCP APIs without an explicit PRM block, so the runtime plumbing engages with zero opt-in.augmentMCPWWWAuthenticate): replaces (not appends) theresource_metadata=parameter on upstream 401s so clients are pointed at the gateway's PRM endpoint. Uses the inbound request's scheme/host (not the rewritten outbound).Validated end-to-end
https://mcp.atlassian.com/v1/mcp/authv2) — Roo browser flow completes, tools list returned.https://mcp.notion.com/mcp) — same. RFC 8707invalid_targetresolved by the AS proxy'sresourceparameter rewrite.Tests
apidef/oas/authentication_test.go(Validate,EffectiveMode,IsMirrorMode),internal/mcp/discovery_test.go(host-root prefix matching),internal/mcp/prm_cache_test.go(TTL cache + URL derivation + upstream PRM fetch).gateway/reverse_proxy_test.go::TestReverseProxyMCPHostRootDiscovery(routing fix; non-MCP regression guard),gateway/mw_protected_resource_test.go::TestPRMMirrorMode_SuffixRoute(PRM serving + rewrite),TestPRMMirrorMode_OAuthProxy(AS metadata rewrite, authorize 302, token forward),TestAugmentMCPWWWAuthenticate(overwrite semantics + scheme/host correctness + opt-out viaenabled: false).go vetclean across changed packages.Architectural follow-ups (out of scope for this PR)
protectedResourceMetadatawith astaticClientblock; Tyk synthesises a registration response from configuredclient_id/client_secretso mcp-remote stays oblivious.client_credentials) or broker per-user tokens itself instead of being a transparent proxy. Different product surface.Test plan
go test ./apidef/... ./gateway/... ./internal/mcp/...)go vetcleanSee also
docs/mcp-remote-oauth-flow.md— full wire diagram of the OAuth flow with the three failure-mode diagrams.docs/mcp-remote-proxies.ipynb— operator notebook for both Jira and Notion proxies.Ticket Details
TT-17137
Generated at: 2026-05-07 14:55:39