authn-authz-with-mcps
Integrates with Auth0 as the authorization server for OAuth, handling dynamic client registration, token issuance, and login flows for portfolio access.
Integrates with Ethereum via Sign-In with Ethereum (SIWE) for wallet-based authentication, allowing users to log in with their Ethereum wallet to access their portfolio.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@authn-authz-with-mcpsview my portfolio"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.mdfor 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 ( |
Keycloak | authorization server - DCR, login, issues tokens | self-hosted ( |
siwe-oidc | wallet-signature → OIDC bridge, brokered by Keycloak |
|
Ymax API | source of truth for portfolio ownership |
|
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-portfolioa 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-oidcIdP (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
subis the internal user UUID, not the wallet - so the wallet is carried in thehttps://ymax.app/walletclaim, 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:
/authorizeruns in your browser - the browser solves Cloudflare's JS challenge. ✅/tokenand/userinfoare 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_secretThen 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.
6. Setup part E - consent screen: agent wallet + capability selection
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.
How it works - a Keycloak Authenticator + a consent page we host
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:
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/consentpage, passing the token, the state, and the Keycloaklogin-actionsreturn URL.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 insrc/scopes.ts(all checked by default).POST /consentvalidates 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 chosenscopes, theagentwallet address, and thestateKeycloak checks.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 theagentclaim 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 asauthInfo.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).makeVerifierrunsjwtVerify(signature + issuer + audience + expiry). On failure it rethrows as the SDK'sInvalidTokenErrorso the client gets a 401 (not a 500) and re-auths.Merges three claim sources into one
scopes[]list:scope(space-delimited),permissions(array), andhttps://ymax.app/scopes(the namespaced custom claim - the reliable carrier).Reads
https://ymax.app/agentintoauthInfo.extra.agent, andhttps://ymax.app/walletintoauthInfo.extra.sub(the wallet, since Keycloak'ssubis a UUID).Serves
/.well-known/oauth-protected-resource/mcp(RFC 9728) naming Keycloak as the authorization server, and returns therequireBearerAuthmiddleware that guardsPOST /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) andportfolio: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) andPOST /consent(validate → sign a return token with the chosen scopes + agent → redirect to Keycloak'slogin-actionsreturn 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)throwsMcpErrorunless the token carries the scope.requirePortfolio(extra)extracts the0x…address fromauthInfo.extra.sub(the wallet claim, robust to theeip155encoding), then callsGET https://main1.ymax.app/portfolios/by-wallet/{addr}:200 → authorized, returns the portfolio (incl.
portfolioId)404 →
Forbidden: this wallet has no Ymax portfoliono address →
Forbidden: no wallet identity on the token
Two tools, each gated by both a scope and portfolio ownership:
Tool
Scope
Returns
get_positionsportfolio:positionspositions, balances, total value
get_allocationportfolio:allocationtarget 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.devBecause the token audience must equal the server's own public /mcp URL, deployment is two-step:
First
yarn deployto learn the worker's URL (…workers.dev, or a custom domain/route).Set
KEYCLOAK_AUDIENCEandMCP_SERVER_URLinwrangler.tomltohttps://<that-url>/mcp, and set the realm's Audience mapperIncluded Custom Audienceto the same value. Thenyarn deployagain.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 |
|
| realm issuer (no trailing slash); derives discovery + JWKS |
|
| expected |
|
| this server's public URL; drives the PRM document |
Secret | Purpose |
| HS256 secret for the |
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
endCompanion files: keycloak/ (authorization server - docker-compose, realm export, the
consent-redirect authenticator) and siwe-oidc/ (self-hosted SIWE provider).
This server cannot be installed
Maintenance
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
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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