docs/mcp-remote-proxies.ipynb
Drives two MCP Proxy APIs in a running Tyk gateway, each pointing at a different remote MCP upstream:
| Proxy listen path | Upstream URL |
|---|---|
/jira/ | https://mcp.atlassian.com/v1/mcp |
/notion/ | https://mcp.notion.com/mcp |
Both upstreams have a non-root path component. This is the case fixed by TT-17137: discovery probes (/.well-known/oauth-protected-resource) must reach the upstream host root, not the resource path. Without the fix, OAuth discovery is routed to /v1/mcp/.well-known/... and 404s. With the fix, MCP clients (mcp-remote, Claude Desktop, Roo) can complete the OAuth dance through Tyk transparently.
http://localhost:8080 with the TT-17137 patch applied (see gateway/reverse_proxy.go MCP host-root branch).GATEWAY_SECRET below.mcp.atlassian.com and mcp.notion.com.requests (pip install requests).mcp-remote for the live OAuth dance (npm i -g mcp-remote or npx mcp-remote).This notebook mutates gateway state. Use a dev gateway. The cleanup cell at the bottom removes everything it created.
import json, requests, time
GATEWAY_URL = "http://localhost:8080"
GATEWAY_SECRET = "352d20ee67be67f6340b4c0605b044b7" # X-Tyk-Authorization
ADMIN_HEADERS = {"X-Tyk-Authorization": GATEWAY_SECRET, "Content-Type": "application/json"}
# Stable APIIDs so cells can be re-run without orphans.
JIRA_API_ID = "jira-mcp-remote"
NOTION_API_ID = "notion-mcp-remote"
REMOTES = {
JIRA_API_ID: {
"name": "jira",
"listen_path": "/jira/",
"upstream_url": "https://mcp.atlassian.com/v1/mcp",
},
NOTION_API_ID: {
"name": "notion",
"listen_path": "/notion/",
"upstream_url": "https://mcp.notion.com/mcp",
},
}
def pp(obj):
print(json.dumps(obj, indent=2, default=str))
def admin(method, path, **kw):
return requests.request(method, f"{GATEWAY_URL}{path}", headers=ADMIN_HEADERS, timeout=15, **kw)
Confirms the gateway is reachable.
r = admin("GET", "/hello")
print("status:", r.status_code)
print("body:", r.text[:300])
assert r.status_code == 200, "gateway not reachable — fix GATEWAY_URL / GATEWAY_SECRET first"
def upsert_oas(api_id, oas_doc):
existing = admin("GET", f"/tyk/apis/oas/{api_id}")
if existing.status_code == 200:
r = admin("PUT", f"/tyk/apis/oas/{api_id}", data=json.dumps(oas_doc))
else:
r = admin("POST", "/tyk/apis/oas", data=json.dumps(oas_doc))
print(f"{api_id}: {r.status_code} {r.text[:200]}")
return r
def delete_oas(api_id):
r = admin("DELETE", f"/tyk/apis/oas/{api_id}")
print(f"DELETE {api_id}: {r.status_code} {r.text[:200]}")
return r
def reload():
r = admin("GET", "/tyk/reload/group")
print("reload:", r.status_code, r.text[:120])
time.sleep(1.0)
An MCP Proxy whose upstream is the remote MCP server. Listen path stripped, no auth on the Tyk side — the upstream handles OAuth itself and the gateway is a transparent reverse proxy.
Note applicationProtocol: mcp — that's what gates the host-root discovery routing in reverse_proxy.go.
def remote_mcp_oas(api_id, name, listen_path, upstream_url):
return {
"openapi": "3.0.3",
"info": {"title": name, "version": "1.0.0"},
"servers": [{"url": "/"}],
"security": [],
"paths": {
"/": {
"get": {"summary": "MCP endpoint", "responses": {"200": {"description": "OK"}}},
"post": {"summary": "MCP endpoint", "responses": {"200": {"description": "OK"}}},
},
},
"components": {"securitySchemes": {}},
"x-tyk-api-gateway": {
"info": {
"id": api_id,
"name": name,
"state": {"active": True, "internal": False},
"applicationProtocol": "mcp",
},
"middleware": {
"global": {
"contextVariables": {"enabled": True},
"trafficLogs": {"enabled": True},
},
},
"server": {"listenPath": {"value": listen_path, "strip": True}},
"upstream": {"url": upstream_url},
},
}
for api_id, cfg in REMOTES.items():
pp(remote_mcp_oas(api_id, cfg["name"], cfg["listen_path"], cfg["upstream_url"]))
print("---")
for api_id, cfg in REMOTES.items():
upsert_oas(api_id, remote_mcp_oas(api_id, cfg["name"], cfg["listen_path"], cfg["upstream_url"]))
reload()
for api_id in REMOTES:
spec = admin("GET", f"/tyk/apis/oas/{api_id}?mode=public")
print(f"{api_id} loaded: {spec.status_code}")
An anonymous POST / to a remote MCP returns 401 with a WWW-Authenticate: Bearer ... header. That's the OAuth challenge — expected, not an error.
The header should pass through Tyk verbatim. If resource_metadata= is present, that's the URL the MCP client will probe next — the load-bearing thing fixed by TT-17137.
def jsonrpc(method, params=None, id_=1):
return {"jsonrpc": "2.0", "id": id_, "method": method, **({"params": params} if params is not None else {})}
for api_id, cfg in REMOTES.items():
url = f"{GATEWAY_URL}{cfg['listen_path']}"
print(f"=== {api_id} → POST {url} ===")
init_env = jsonrpc("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "notebook", "version": "0"},
})
r = requests.post(url, data=json.dumps(init_env), headers={"Content-Type": "application/json"}, timeout=15)
print(" status:", r.status_code)
print(" WWW-Authenticate:", r.headers.get("WWW-Authenticate", "<none>"))
print(" body[:300]:", r.text[:300])
print()
With TT-17137 applied, a GET <listen-path>/.well-known/oauth-protected-resource is routed to the upstream host root (e.g. https://mcp.atlassian.com/.well-known/oauth-protected-resource) instead of being prefixed with the upstream URL's path (/v1/mcp or /mcp).
Expect a 200 with a JSON document (or 404 for upstreams that don't expose PRM at host root — Notion advertises absolute URLs in its WWW-Authenticate, so its discovery doc may not exist at this path; that's fine, just confirms the routing is now correct rather than double-prefixed).
for api_id, cfg in REMOTES.items():
url = f"{GATEWAY_URL}{cfg['listen_path']}.well-known/oauth-protected-resource"
print(f"=== {api_id} → GET {url} ===")
r = requests.get(url, timeout=15)
print(" status:", r.status_code)
print(" Content-Type:", r.headers.get("Content-Type", "<none>"))
print(" body[:500]:", r.text[:500])
print()
How to verify the routing fix from gateway logs
Run the gateway with --log-level=debug and tail. For the discovery probe above you should see:
msg="MCP discovery path detected; routing to upstream host root"
msg="Outbound request URL: https://mcp.atlassian.com/.well-known/oauth-protected-resource"
Without the patch the outbound URL would have been https://mcp.atlassian.com/v1/mcp/.well-known/oauth-protected-resource (broken).
The cells above prove the proxy is reachable and discovery routes correctly. To complete the OAuth dance and actually call tools, point an MCP client at each listen path. Examples for Claude Desktop / Roo / Cline mcp_settings.json:
mcp_settings = {
"mcpServers": {
cfg["name"]: {
"command": "npx",
"args": ["-y", "mcp-remote", f"{GATEWAY_URL}{cfg['listen_path']}"],
"alwaysAllow": [],
"disabled": False,
}
for cfg in REMOTES.values()
}
}
pp(mcp_settings)
Or run from a terminal to drive the OAuth flow interactively:
npx -y mcp-remote http://localhost:8080/jira/
npx -y mcp-remote http://localhost:8080/notion/
The first invocation opens a browser tab to the upstream's authorization server, you sign in, the token is cached locally by mcp-remote, and subsequent calls succeed.
Removes the two proxies.
for api_id in REMOTES:
delete_oas(api_id)
reload()
print("clean.")