Skip to content

Troubleshooting auth walls

First connections fail in a small, well-mapped set of ways. This page is symptom-first: match the error you see, read the wall behind it, apply the fix. The W# labels cross-reference the deployer recipe's auth-onramp matrix, which carries the deepest realm-side detail.

Every client here is internal-only

MEHO is never publicly exposed. If a client cannot reach the backplane's /.well-known/oauth-protected-resource metadata, the fix is to put the client (or its mcp-remote shim) on the VPN / internal network — never to expose the backplane through a public ingress or tunnel.

The client never reaches the OAuth screen

Symptom. The /mcp call returns 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource", but the client never opens a browser or shows a login.

Wall. The client cannot fetch the resource_metadata URL, or the URL points at a host it cannot resolve.

Fix. Confirm the client's machine reaches the backplane over the VPN, and that BACKPLANE_URL in the backplane ConfigMap resolves to the internal hostname clients actually use — if it is wrong, the metadata document advertises the wrong host.

DCR fails with 403 Host not trusted (W1)

Symptom.

{"error":"insufficient_scope",
 "error_description":"Policy 'Trusted Hosts' rejected request to
  client-registration service. Details: Host not trusted."}

Wall. The client has no client_id in its config, so it attempted Dynamic Client Registration (RFC 7591). Keycloak's default Trusted Hosts policy ships with an empty whitelist, so anonymous DCR is disabled — the correct, secure response.

Fix. Give the client a pre-registered client_id. Claude Code takes it in .mcp.json (oauth.clientId); the Claude Desktop shim takes it via --static-oauth-client-info. For a client that exposes neither, carry a token through the static-token shim. Do not open DCR on a production realm to work around a per-client config gap.

Login start fails with unauthorized_client (W1)

Symptom. The device-code or authorization request 401s with {"error":"unauthorized_client", …} before any browser approval.

Wall. The flow ran against a confidential client (typically meho-backplane) that requires a secret the client cannot hold.

Fix. Point the client at a public client — meho-cli for the CLI device-code flow, meho-mcp for MCP. See Keycloak realm setup.

Authorization request fails with invalid_scope naming offline_access (W7)

Symptom. A browser-flow MCP client (Claude Code) 400s with {"error":"invalid_scope","error_description":"Invalid scopes: … offline_access"}; the Keycloak login page never appears.

Wall. The client requested offline_access to obtain a refresh token, but the meho-mcp client does not have that scope assigned.

Fix. Assign the realm's built-in offline_access scope to meho-mcp as an optional scope (not default — only flows that ask for a refresh token should mint one). meho-cli is deliberately not given it: the device-code CLI re-runs the device dance instead of holding a long-lived refresh token.

Token issues but every call 401s

The token is minted, but the backplane rejects it on every call. From v0.3.2 the 401 body carries a specific detail code — read it first, then match the row:

detail code What it means Fix
invalid_audience The token's aud does not include the MCP resource URI (MCP_RESOURCE_URI, i.e. https://<host>/mcp). Confirm the meho-mcp-audience mapper is on the client that issues the operator's token (W2). A reverse proxy that rewrites the request path can also leave the audience pointing at the wrong URI — capture the issued token via the realm's introspection endpoint and read aud.
missing_sub The token has no sub claim. Keycloak 25+ moved sub into a mapper inside the basic client scope, and clients created via the admin REST API do not auto-inherit realm default scopes. RFC 9068 makes sub required, so rejection is correct — the opacity is the problem. Assign basic (and roles, web-origins, acr) as default client scopes, then log out and back in (W3 — the deepest wall).
missing_tenant_claim / missing_tenant_role_claim The tenant-id / tenant-role mapper is absent, so the backplane cannot place the caller in a tenant. Add the tenant mappers (W2). Realm recipe: Keycloak realm setup § mappers.
invalid_issuer The token's iss does not match the configured realm. Point the client at the same realm KEYCLOAK_ISSUER_URL names; a stale OAuth state from a previous realm can survive across config changes.
token_expired / token_not_yet_valid exp is in the past, or nbf in the future beyond leeway (clock skew). Refresh the token; for nbf, sync the clock on the issuing or calling host.
signature_verification_failed The JWS does not verify against the issuer's published keys. Confirm the token comes from the realm the backplane validates against.
invalid_token A structural failure with no more specific code — a truncated JWS, alg: none, or a kid absent from the JWKS. Inspect the token locally — confirm it is a three-segment RS256 JWS (segment count, alg, kid). Treat the bearer as a live credential: decode it on your own machine and never paste it into a shared issue, log, or chat — redact it from any diagnostic you share.

The diagnostic detail (expected audience, claim name, exception class) is in the backplane's server log, not the 401 body — that body-vs-log split is the deliberate info-leak boundary an unauthenticated 401 honours.

meho login times out before you can approve (W4)

Symptom. meho: token exchange failed: context deadline exceeded, often before you have finished approving in the browser.

Wall. Not the device-code TTL (10 minutes). An ambient parent deadline — a CI step timeout, an IDE task wrapper, an agent bash-tool timeout — truncates the approval wait.

Fix. Run meho login in a real interactive terminal without a short wrapper deadline, or raise the wrapper timeout above the device-code lifetime.

tools/list returns an empty list

Symptom. The client connects and authenticates, but no MEHO tools appear.

Wall. The token's tenant_role claim is below the rank a tool requires (read_only < operator < tenant_admin), or the claim is missing entirely.

Fix. Confirm the tenant-role mapper emits a role of at least read_only; walk back through the realm's tenant mappers (Keycloak realm setup).

The whole toolset is rejected (Claude Desktop)

Symptom. Settings → Connectors lists the MEHO tools, but a conversation sees none of them; the UI names a tool-name pattern error like String should match pattern '^[a-zA-Z0-9_-]{1,64}$'.

Wall. Claude's frontend validates every remote-MCP tool name against that pattern and rejects the whole toolset if any name fails. The older dotted names (meho.status) failed.

Fix. Pin backplane v0.27.0 or later, where the tools were renamed to underscores (meho_status, list_targets, …). See Claude Desktop § pinning a build.

A tool call fails with "Tool execution failed" but the server logged 200

Symptom. A specific tool (e.g. list_targets, query_topology) fails client-side with "Tool execution failed", while the backplane logs a clean 200 and writes an audit row. meho_status works.

Wall. The tool declares an outputSchema, and MCP 2025-06-18 requires a conforming structuredContent result for such tools — which Claude's frontend enforces. A build without the fix omits it, so every schema-declaring tool hard-fails.

Fix. Pin a backplane build carrying the structuredContent fix (merged after v0.27.0). Tools that declare no schema (meho_status) are unaffected.

Newly-shipped tools don't appear after a backplane upgrade

Symptom. After upgrading the backplane, a new tool stays invisible; tools/list keeps returning the old catalog.

Wall. The backplane advertises tools.listChanged: false (the registry is fixed at process start), so a client that cached the catalog at initialize has no signal to refetch.

Fix. Re-initialize the client — restart it, or disconnect and reconnect the server. Also confirm the rollout actually cycled the process (kubectl rollout status, or check the serverInfo.version), since a new tool only exists in a new backplane process.

A successful call left no audit row

Symptom. A tool call succeeded, but no audit_log row exists for it.

Wall. The MCP audit writer fails closed — an unauditable call returns JSON-RPC INTERNAL_ERROR (-32603), so a successful call with no row means the row was rolled back mid-request, most often a transient DB connectivity issue.

Fix. Check the backplane's structured logs for a mcp_audit_write_failed event carrying the exception class before suspecting the writer.

Going deeper