outsystems-mcp-relay
by izambasiron
README.md
# outsystems-mcp-relay
A **lightweight, generic stdio → remote MCP relay** with OAuth, plus an RFC 9207
issuer override for remote servers whose published OAuth metadata doesn't match
the authorization response. Zero runtime dependencies. One file.
```
stdio (your MCP client) ⇄ outsystems-mcp-relay ⇄ remote MCP server (Streamable HTTP)
```
## Why this exists
Some remote MCP deployments are a **reverse proxy in front of Keycloak** (the
OutSystems Developer Cloud MCP gateway is one). They publish OAuth metadata
whose `issuer` is the proxy URL (e.g. `https://<tenant>/mcp`), but the
authorization server stamps its **real issuer** in the authorization-response
`iss` parameter (e.g. `https://<tenant>/auth/realms/<realm>`).
RFC 9207-compliant clients **must** reject that mismatch, so OAuth login fails
on every harness — Claude Code, pi, Cursor, Codex, you name it. This relay lets
you validate `iss` against the **true backend issuer** while keeping every other
OAuth check strict. For normal servers it behaves like a plain relay.
## When to use this
Try the **official direct connection first** — point your harness straight at
the remote MCP URL, no relay in between. Only reach for this relay if that
fails with the RFC 9207 issuer-mismatch error above.
This exists solely to work around that one server-side bug. It doesn't do
anything better than the official path once the bug isn't there — so if
OutSystems fixes it tenant-wide, or your tenant never hit it in the first
place, drop the relay and connect directly. The relay tells you when that's
the case: on a successful login it checks whether any issuer correction was
actually needed, and if not, prints a note to stderr saying so. Don't wait for
a "does it still need it" review — if you see that note, switch back to the
official direct connection right away.
## Install
Requires **Node.js ≥ 20**. No dependencies — just the file.
```bash
npm install -g outsystems-mcp-relay # recommended
# or, without a global install:
npx outsystems-mcp-relay <remote-url> ...
```
**You do not need to clone this repo to use the relay.** Install from npm (or
use `npx`) and you're done. Clone it only to audit the source (a single
~500-line file) or contribute.
## Usage
```bash
outsystems-mcp-relay <remote-url> [options]
--as-metadata-url <url> OAuth AS metadata URL (default: discover from remote-url)
--expected-issuer <url> Override the RFC 9207 expected issuer (the proxy fix)
--client-id <id> Pre-registered client id (skips dynamic registration)
--bearer <token> Static bearer token mode (skips OAuth entirely)
--force Ignore cached tokens and re-authenticate
--help Show help
```
### Generic example (normal remote server)
```jsonc
// mcp.json
{
"mcpServers": {
"my-remote": {
"command": "outsystems-mcp-relay",
"args": ["https://api.example.com/mcp"]
}
}
}
```
### OutSystems example (issuer mismatch)
```jsonc
{
"mcpServers": {
"outsystems": {
"command": "outsystems-mcp-relay",
"args": ["https://<tenant>.outsystems.dev/mcp"]
}
}
}
```
No `--expected-issuer` needed — the relay **auto-detects** the real
authorization-server issuer from the authorize redirect (Keycloak realm
setups). Only add
`--expected-issuer https://<tenant>.outsystems.dev/auth/realms/<realm-id>` if
auto-detection fails (a non-Keycloak server): the OAuth error message prints
the realm — its **received** value is the value to use. Auto-detection only
adopts a cross-origin candidate when its issuer is confirmed by the realm's
own metadata (reachable through the origin allowlist); for a genuinely
separate IdP host, pass `--expected-issuer` (or `--as-metadata-url` to
pre-allowlist it).
On the first call the relay prints an authorization URL — open it in your
browser to sign in. Tokens are cached and refreshed automatically.
### Install via an AI assistant (paste this)
Paste into Claude Code, pi, Cursor, or any AI harness:
```
Install the OutSystems MCP fix (outsystems-mcp-relay). The official OutSystems
MCP fails OAuth on every AI harness with an RFC 9207 issuer mismatch — a
server-side bug in the gateway's Keycloak setup.
Step 1: run `npm install -g outsystems-mcp-relay`.
Step 2: ask me for my OutSystems tenant hostname (something like
`mycompany.outsystems.dev`).
Step 3: when I tell you, replace the broken server:
`claude mcp remove outsystems`
`claude mcp add -s user --transport stdio outsystems -- outsystems-mcp-relay https://<my-tenant>/mcp`
(substitute my actual tenant. No other flags needed — the relay auto-detects
the real authorization-server issuer; if auto-detection can't, ask me to paste
the OAuth error and use its "received" URL as `--expected-issuer`.)
Step 4: tell me to restart Claude Code, then ask anything OutSystems-related.
The first tool call prints an authorization URL — open it in your browser to
sign in.
```
### Claude Code quickstart (OutSystems issuer mismatch)
The error you're seeing looks like this:
```
Issuer mismatch in authorization response (RFC 9207):
expected "https://<tenant>.outsystems.dev/mcp",
received "https://<tenant>.outsystems.dev/auth/realms/<realm-id>"
```
In a terminal (not inside Claude Code):
```bash
npm install -g outsystems-mcp-relay
# 1. remove the broken HTTP entry
claude mcp remove outsystems
# 2. add the relay as a local stdio server — no other flags needed: it
# auto-detects the real authorization-server issuer
claude mcp add -s user --transport stdio outsystems -- \
outsystems-mcp-relay \
https://<tenant>.outsystems.dev/mcp
```
Then **restart Claude Code**. On the first OutSystems tool call the relay
prints an authorization URL — open it in your browser to sign in. Tokens
are cached, so later sessions skip sign-in. Verify with `/mcp` (server should be
connected) and a simple "list my environments".
> **You don't need to hunt for the realm issuer.** The relay auto-detects it
> from the authorize redirect. If auto-detection can't (a non-Keycloak server),
> the error message prints it: the **received** value in the error IS the
> `--expected-issuer` value.
## How it works
- **Protocol-agnostic passthrough**: reads newline-delimited JSON-RPC from
stdin, POSTs each frame verbatim to the remote server, writes the JSON-RPC
response back to stdout. No tool semantics live here — works for tools,
resources, prompts, anything.
- Handles **Streamable HTTP** details: `Mcp-Session-Id` echo, direct-JSON
responses, and `202`/`text/event-stream` responses (SSE reassembly).
- **OAuth**: discovers the authorization-server metadata, dynamically registers
a public client (PKCE S256), prints the authorization URL for you to open,
validates `state` and `iss`, exchanges the code, refreshes tokens on 401.
`--expected-issuer` sets the issuer `iss` is validated against — the fix for
proxy/Keycloak mismatches.
- Requests are serialized (no interleaved responses on stdout).
## Security
- **RFC 9207 enforced**: `iss` is validated only when the authorization server
actually sends it (absent = the AS doesn't implement RFC 9207, no check;
present = strict string match against the expected issuer).
`--expected-issuer` opts into a different *expected value* — it never disables
validation.
- **Origin allowlist**: the relay only contacts the configured remote origin
(and an explicitly provided `--as-metadata-url`). Redirects are walked
manually and **every hop is allowlisted** (307/308 preserve the request body;
301/302/303 downgrade to GET per HTTP semantics), and `Authorization`/`Cookie`
are **stripped when a redirect changes origin** (matching native fetch). No SSRF.
- **PKCE S256** + random `state` (validated) + localhost-only callback server on
an ephemeral port.
- **Never logs secrets**: tokens and authorization codes never appear in output
(all diagnostics go to stderr; stdout carries protocol messages only).
- Tokens are stored at
`~/.mcp-auth/outsystems-mcp-relay-<sha1(url)>.json` with `0600` permissions —
the ecosystem convention (same store shape as `mcp-remote`). OS-keychain
storage is a planned enhancement; see Non-goals.
## Testing
```bash
npm test # mock-server protocol test (passthrough, session-id, SSE, 401)
npm run test:e2e -- <remote-url> --expected-issuer <issuer> # real-tenant round trip
```
## Troubleshooting
| Symptom | Fix |
|---|---|
| `Issuer mismatch ... expected "…/mcp", received "…/auth/realms/…"` | Normally auto-detection handles this with zero flags. If it can't, pass the **received** URL as `--expected-issuer` — the error prints it for you |
| `authentication failed` after a long idle | The cached token expired and refresh failed. Re-run with `--force` (or delete the relay's file in `~/.mcp-auth/`) to re-authenticate |
| Dynamic client registration fails | The server's registration endpoint is restricted (e.g. Keycloak's Trusted-Hosts policy). If it's the OutSystems proxy this shouldn't happen; otherwise register a client yourself and pass `--client-id` |
| Something else | Open an issue with the full error text (all diagnostics go to stderr — redact any tokens) |
## Non-goals (v1)
- OS-keychain token storage (file with 0600 perms for now)
- Multi-server aggregation / management (use a gateway for that)
- Server-initiated notification streaming beyond passthrough
- Custom CA flags
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues