Back to Tyk

Remote MCP proxies — Jira + Notion through Tyk

docs/mcp-remote-proxies.ipynb

5.13.08.6 KB
Original Source

Remote MCP proxies — Jira + Notion through Tyk

Drives two MCP Proxy APIs in a running Tyk gateway, each pointing at a different remote MCP upstream:

Proxy listen pathUpstream 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.

Prerequisites

  1. Tyk gateway running locally on http://localhost:8080 with the TT-17137 patch applied (see gateway/reverse_proxy.go MCP host-root branch).
  2. Admin secret in GATEWAY_SECRET below.
  3. Outbound HTTPS connectivity from the gateway to mcp.atlassian.com and mcp.notion.com.
  4. Python 3.9+ with requests (pip install requests).
  5. Optional: 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.

0 — Configuration

python
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)

1 — Pre-flight check

Confirms the gateway is reachable.

python
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"

2 — Helpers: upsert / delete / reload

python
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)

3 — Build a remote-MCP OAS doc

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.

python
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("---")

4 — Create both proxies

python
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}")

5 — Hit each proxy's listen path

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.

python
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()

6 — The fix in action: discovery probe routing

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).

python
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).

7 — Connect a real MCP client

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:

python
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:

bash
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.

8 — Cleanup

Removes the two proxies.

python
for api_id in REMOTES:
    delete_oas(api_id)
reload()
print("clean.")