Skip to main content
Glama
rabi-siddique

authn-authz-with-mcps

Portfolio MCP Server - Sign-In with Ethereum (SIWE)

An MCP server that lets an AI client (ChatGPT / Claude) read a user's Ymax portfolio - where the user logs in with their Ethereum wallet.

The whole point of this repo is the auth: proving that wallet login (SIWE) can be plugged into the OAuth flow that MCP clients require, and then using the proven wallet address to authorize access to that wallet's portfolio. The two tools (get_positions, get_allocation) are just there to have something to protect.

Authorization server: Keycloak (self-hosted). This setup uses a self-hosted Keycloak realm as the OAuth authorization server. (An earlier iteration used Auth0; see the git history and docs/design-authn-authz.md for the rationale of the switch.)


1. How it fits together

Four moving parts:

Part

Role

Where

This MCP server

OAuth resource server - validates tokens, gates tools

this repo (src/)

Keycloak

authorization server - DCR, login, issues tokens

self-hosted (keycloak/)

siwe-oidc

wallet-signature → OIDC bridge, brokered by Keycloak

siwe-oidc/ (self-hosted Docker)

Ymax API

source of truth for portfolio ownership

main1.ymax.app

Everything Keycloak-specific (docker-compose, realm export, the consent-redirect authenticator, and the console/kcadm finishing steps) lives in keycloak/ - read keycloak/README.md for the authorization-server setup; this file focuses on the resource server and how the pieces connect.


Related MCP server: @blockrun/alpha

2. Setup part A - Keycloak as the authorization server

Keycloak runs as Docker (Keycloak + Postgres) with the realm ymax imported on first boot:

cd keycloak
export CONSENT_SECRET="$(openssl rand -hex 32)"   # >= 32 bytes; must equal the Worker's CONSENT_SECRET
docker compose up --build                         # → http://localhost:8080 (admin: admin/admin)

The realm import provisions the siwe-oidc identity provider and the ymax-portfolio client scope (with the wallet / scopes / agent / audience mappers). A handful of steps are finished in the admin console / kcadm - all detailed in keycloak/README.md:

  • Make ymax-portfolio a realm default client scope so DCR-registered clients inherit the custom claims + audience (optional scopes don't reach DCR clients).

  • Enable unmanaged user attributes so the authenticator can write ymax_scopes / ymax_agent.

  • Relax anonymous DCR (Trusted Hosts) so MCP clients can self-register, and raise the Max Clients policy from its default of 200.

  • Wire the siwe-oidc IdP (endpoints + client id/secret) and bind the consent-redirect authenticator into a copied browser flow.

Why the token aud needs a mapper

MCP clients send the RFC 8707 resource parameter, but current Keycloak (26.7.0) ignores it, so tokens come out without our resource-server audience. The realm's Audience mapper hardcodes aud = https://<worker-url>/mcp to compensate. (Native resource support - RESOURCE_INDICATORS - is experimental-on-main only; once it ships the mapper can go.)


3. Setup part B - SIWE as a login method

Keycloak delegates login to a Sign-In with Ethereum OIDC identity provider. The user signs an ERC-4361 message; siwe-oidc verifies the signature and returns an OIDC profile whose sub is the wallet (eip155:1:0x…). Keycloak's attribute importer copies that into the wallet_address user attribute, and a User Attribute mapper surfaces it in the access token as https://ymax.app/wallet.

Keycloak's sub is the internal user UUID, not the wallet - so the wallet is carried in the https://ymax.app/wallet claim, and the resource server reads the address from there.

Why self-host the SIWE provider

Out of the box the connection would point at SpruceID's public provider, oidc.login.xyz, which sits behind a Cloudflare bot challenge. A login has two kinds of calls:

  • /authorize runs in your browser - the browser solves Cloudflare's JS challenge. ✅

  • /token and /userinfo are server-to-server calls from Keycloak's backend - a backend can't solve a JS challenge, so it gets a Cloudflare HTML page instead of JSON. ❌

Fix: run your own copy of the SIWE provider (part C), then point Keycloak's IdP at it (part D).


4. Setup part C - self-hosting the SIWE provider

SpruceID open-sources the provider: spruceid/siwe-oidc. We run our own instance so Keycloak's server-to-server calls hit a normal server (no Cloudflare).

Everything for this lives in siwe-oidc/ (Dockerfile, docker-compose, README).


5. Setup part D - point Keycloak at your instance

Register a client on your siwe-oidc instance whose redirect URI is Keycloak's broker callback:

curl -X POST https://<your-siwe-url>/register \
  -H 'Content-Type: application/json' \
  -d '{"redirect_uris":["https://<keycloak-host>/realms/ymax/broker/siwe-oidc/endpoint"]}'
# returns client_id + client_secret

Then in Identity providers → siwe-oidc, import its /.well-known/openid-configuration (the Discovery endpoint field) or set the endpoints manually, and set the Client ID / Client Secret from the response. Details in keycloak/README.md §3.4.


A freshly-signed-in wallet is a brand-new user with no permissions, so every tool would return Forbidden. Rather than granting every scope wholesale, we show the user a consent screen right after they sign in that (a) shows the agent wallet created to act on their behalf, and (b) offers a checkbox per capability, granting only what they tick.

Keycloak's Authentication SPI lets a custom authenticator suspend login, redirect the browser to a page we control, and resume when it returns - the same shape as the old Auth0 Redirect Action. The authenticator (keycloak/authenticator/, provider id ymax-consent-redirect) is bound into the browser flow as a Required step after brokered SIWE login:

  1. authenticate() mints a short-lived HS256 token (sub = wallet, state = a single-use nonce stored on the auth session) and issues a 302 to the MCP server's /consent page, passing the token, the state, and the Keycloak login-actions return URL.

  2. GET /consent (src/consent.ts) verifies that token, validates the return URL is on the Keycloak origin (open-redirect guard), creates an Agoric agent wallet (src/wallet.ts, agoric1…) and shows its address, then renders a checkbox per scope from the catalog in src/scopes.ts (all checked by default).

  3. POST /consent validates the submission, keeps only the ticked scopes that exist in the catalog, and redirects back to Keycloak's return URL with a signed token carrying the chosen scopes, the agent wallet address, and the state Keycloak checks.

  4. action() validates that return token (HS256 signature + expiry + state), then writes the chosen scopes and agent wallet as user attributes (ymax_scopes, ymax_agent) and completes login. User Attribute mappers project those into the access token.

The authenticator needs the shared secret via a server SPI option (KC_SPI_AUTHENTICATOR_YMAX_CONSENT_REDIRECT_SECRET, set in keycloak/docker-compose.yml) and the consent-page URL via a per-execution config property. The secret must equal the Worker's CONSENT_SECRET and be ≥ 32 bytes (Keycloak signs with Nimbus, which rejects shorter HS256 keys).

Agent wallet is prototype scaffolding (PAK-550 direction). The agoric1… keypair is generated fresh and its private key is not persisted (discarded after render), so today it's identity- display only - the agent can't sign or act. Real custody (persist + encrypt the key) and an on-chain delegation step are deliberately out of scope here. The address is carried through a hidden form field, so the agent claim isn't yet authoritative (a real build makes the server the source of truth). See PAK-550.

Why user attributes → mappers, not the token directly

Keycloak has no Auth0-style "scopes silently dropped for third-party apps" problem: the authenticator writes the selection onto the user, and User Attribute protocol mappers (on the ymax-portfolio default client scope) project them into the token as namespaced claims:

  • https://ymax.app/scopes (multivalued → JSON array), https://ymax.app/agent, https://ymax.app/wallet.

  • The dots in the claim names are escaped in the mapper config (https://ymax\.app/…) because Keycloak treats an unescaped dot as a nested-object separator; the emitted claim string stays flat.

  • The MCP server's verifier merges the scopes claim into the token's scope list, reads the agent claim into authInfo.extra.agent, and reads the wallet claim as authInfo.extra.sub (below).


7. The MCP server code

src/auth.ts - token verification + resource metadata

  • On startup, fetches Keycloak's OIDC discovery document and builds a cached remote JWKS from the document's jwks_uri (Keycloak serves keys at …/protocol/openid-connect/certs, not .well-known/jwks.json - never hardcoded).

  • makeVerifier runs jwtVerify (signature + issuer + audience + expiry). On failure it rethrows as the SDK's InvalidTokenError so the client gets a 401 (not a 500) and re-auths.

  • Merges three claim sources into one scopes[] list: scope (space-delimited), permissions (array), and https://ymax.app/scopes (the namespaced custom claim - the reliable carrier).

  • Reads https://ymax.app/agent into authInfo.extra.agent, and https://ymax.app/wallet into authInfo.extra.sub (the wallet, since Keycloak's sub is a UUID).

  • Serves /.well-known/oauth-protected-resource/mcp (RFC 9728) naming Keycloak as the authorization server, and returns the requireBearerAuth middleware that guards POST /mcp.

src/consent.ts + src/scopes.ts + src/wallet.ts - the consent screen (§6)

  • src/scopes.ts - the selectable-capability catalog (single source of truth): portfolio:positions, portfolio:allocation (read) and portfolio:rebalance (write). The consent page renders it; the submit handler rejects anything not in it.

  • src/wallet.ts - createAgentWallet() derives a fresh Agoric (agoric1…) address (SLIP-44 coin type 564) via @scure/@noble. Prototype: the key is not persisted (see §6 note).

  • src/consent.ts - GET /consent (verify inbound token → create agent wallet → render checkboxes) and POST /consent (validate → sign a return token with the chosen scopes + agent → redirect to Keycloak's login-actions return URL). Both are unauthenticated - they run mid-login, before any token exists - and both validate the return URL against the Keycloak origin.

src/create-server.ts - the tools + authorization

  • requireScope(extra, scope) throws McpError unless the token carries the scope.

  • requirePortfolio(extra) extracts the 0x… address from authInfo.extra.sub (the wallet claim, robust to the eip155 encoding), then calls GET https://main1.ymax.app/portfolios/by-wallet/{addr}:

    • 200 → authorized, returns the portfolio (incl. portfolioId)

    • 404Forbidden: this wallet has no Ymax portfolio

    • no address → Forbidden: no wallet identity on the token

  • Two tools, each gated by both a scope and portfolio ownership:

    Tool

    Scope

    Returns

    get_positions

    portfolio:positions

    positions, balances, total value

    get_allocation

    portfolio:allocation

    target allocation

    The portfolio:rebalance (write) scope is selectable on the consent screen and carried in the token, but has no tool yet - exercising it needs a write tool plus the agent-wallet custody + on-chain delegation that are out of scope here (PAK-550).

src/worker.ts - the Cloudflare Workers host (Hono + @hono/mcp)

Wires POST /mcp behind the bearer-auth middleware, exposes GET /health, serves the .well-known discovery documents, and logs every request. The MCP transport is @hono/mcp's Web-standard StreamableHTTPTransport (stateless, sessionIdGenerator: undefined, enableJsonResponse: true).

The token-verification core lives in src/auth.ts and is runtime-agnostic (jose = Web Crypto). The bearer middleware verifies the JWT, stashes the AuthInfo on the Hono context via c.set('auth', …) (which the transport threads into each tool's extra.authInfo), and on failure returns 401 with a WWW-Authenticate header pointing at the protected-resource metadata.

src/server.ts remains as a local stdio entry (yarn start:stdio) for testing the tools without the HTTP/auth layer.


8. Deploy on Cloudflare Workers

This repo (the MCP server) deploys as a Cloudflare Worker. Config lives in wrangler.toml (main: src/worker.ts, compatibility_flags: ["nodejs_compat"], and the three vars below).

yarn install
yarn dev                 # local: wrangler dev (workerd) on http://127.0.0.1:8787
yarn deploy              # wrangler deploy → https://auth0-siwe-mcp.<subdomain>.workers.dev

Because the token audience must equal the server's own public /mcp URL, deployment is two-step:

  1. First yarn deploy to learn the worker's URL (…workers.dev, or a custom domain/route).

  2. Set KEYCLOAK_AUDIENCE and MCP_SERVER_URL in wrangler.toml to https://<that-url>/mcp, and set the realm's Audience mapper Included Custom Audience to the same value. Then yarn deploy again.

  3. Point the consent-redirect authenticator's Consent page URL at https://<that-url>/consent.

KEYCLOAK_ISSUER / KEYCLOAK_AUDIENCE / MCP_SERVER_URL are non-secret and live in wrangler.toml under vars. CONSENT_SECRET is a real secret - set it with wrangler secret put CONSENT_SECRET (and .dev.vars for local dev), and it must equal the value passed to Keycloak.

siwe-oidc is unchanged - it's a Rust Docker service (part C), not a Worker, and keeps its own separate deployment + env. It does not share this app's config.


9. Environment variables

Three non-secret vars (in wrangler.toml) plus one secret:

Var

Value (example)

Purpose

KEYCLOAK_ISSUER

https://<kc-host>/realms/ymax

realm issuer (no trailing slash); derives discovery + JWKS

KEYCLOAK_AUDIENCE

https://<worker-url>/mcp

expected aud - must equal the realm's Audience mapper

MCP_SERVER_URL

https://<worker-url>/mcp

this server's public URL; drives the PRM document

Secret

Purpose

CONSENT_SECRET

HS256 secret for the /consent page (§6). Must equal the value passed to Keycloak (KC_SPI_AUTHENTICATOR_YMAX_CONSENT_REDIRECT_SECRET) and be ≥ 32 bytes. wrangler secret put CONSENT_SECRET; .dev.vars locally.

KEYCLOAK_AUDIENCE and MCP_SERVER_URL must both point at this deployment's domain, and KEYCLOAK_AUDIENCE must match the realm's Audience mapper exactly - otherwise every token's aud fails verification (401). The server fails fast: if any of the three vars are missing, readConfig throws on the first request.


10. Sequence diagram

sequenceDiagram
    autonumber
    participant U as You (browser + wallet)
    participant C as MCP client (ChatGPT/Claude)
    participant M as Portfolio MCP Server
    participant K as Keycloak (Auth Server)
    participant S as siwe-oidc (self-hosted)
    participant Y as Ymax API

    Note over C,M: - Discovery (RFC 9728) -
    C->>M: POST /mcp  (no token)
    M-->>C: 401 + WWW-Authenticate: resource_metadata=...
    C->>M: GET /.well-known/oauth-protected-resource/mcp
    M-->>C: { authorization_servers: ["https://<kc>/realms/ymax"] }
    C->>K: GET /realms/ymax/.well-known/openid-configuration
    K-->>C: endpoints (authorize, token, registration, certs)

    Note over C,K: - Dynamic Client Registration -
    C->>K: POST /realms/ymax/clients-registrations/openid-connect
    K-->>C: { client_id }

    Note over U,S: - Wallet login (SIWE, brokered) -
    C->>K: GET /authorize (PKCE S256, resource=.../mcp)
    K->>S: redirect to siwe-oidc identity provider
    S->>U: render SIWE page (connect + sign)
    U-->>S: signed ERC-4361 message
    S-->>K: OIDC profile (sub = eip155:1:0xABC…)
    K->>K: attribute importer: sub → wallet_address

    Note over U,M: - Consent screen (authenticator) -
    K->>M: redirect browser to /consent (?session_token, ?state, ?redirect_uri)
    M->>M: create Agoric agent wallet (agoric1…, key not persisted)
    M->>U: show agent wallet + capability checkboxes
    U-->>M: tick scopes + Authorize
    M-->>K: redirect to login-actions URL (signed {scopes, agent, state})
    K->>K: action(): write ymax_scopes + ymax_agent user attributes

    K-->>C: code → token exchange
    C->>K: POST /realms/ymax/protocol/openid-connect/token (code + verifier)
    K-->>C: access_token (JWT: aud=/mcp, https://ymax.app/{wallet,scopes,agent})

    Note over C,Y: - Authenticated tool call -
    C->>M: POST /mcp  tools/call get_positions  Authorization: Bearer JWT
    M->>K: (fetch JWKS - cached)
    M->>M: jwtVerify - signature + issuer + audience + expiry
    M->>M: requireScope("portfolio:positions")
    M->>M: extract 0x address from https://ymax.app/wallet claim
    M->>Y: GET /portfolios/by-wallet/{address}
    alt owns a portfolio (200)
        Y-->>M: { portfolioId, latestSnapshot, ... }
        M-->>C: ✅ portfolio positions
    else owns none (404)
        Y-->>M: 404
        M-->>C: ❌ Forbidden: no Ymax portfolio
    end

Companion files: keycloak/ (authorization server - docker-compose, realm export, the consent-redirect authenticator) and siwe-oidc/ (self-hosted SIWE provider).

F
license - not found
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rabi-siddique/auth0-siwe'

If you have feedback or need assistance with the MCP directory API, please join our Discord server