Skip to main content
Glama
joaorura

mcp-stepup-gateway

by joaorura

mcp-stepup-gateway

An MCP gateway that requires passkey (WebAuthn) on demand -- "step-up auth" -- before allowing a remote client (Claude.ai, via Custom Connector) to read or write to an Obsidian vault protected by enquire-mcp. Google login and allowlist (as in mcp-oauth-gateway) decide who can connect; this project decides, tool by tool, what that person can do without proving identity again, and what requires a fresh passkey touch.

It was born from a concrete case: mcp-oauth-gateway/enquire-mcp-gateway already solve "authenticate who connects" (OAuth + allowlist). What was missing was a second layer: even within the allowlist, not every tool call should be equally free. Reading a note is cheap; deleting or rewriting vault content via an LLM that may be under prompt injection is not. This gateway adds that distinction without touching enquire-mcp itself.

Why this exists

A remote MCP client authenticated by OAuth is still, from the vault's point of view, "an LLM with full access." This is a problem on two axes:

  1. The LLM can be manipulated. Malicious content in a note, or in a tool response, can try to instruct the agent to delete or overwrite things -- prompt injection is not hypothetical.

  2. "Authenticated once" should not mean "authorized forever." A long-lived OAuth session should not give the same LLM unrestricted write permission indefinitely, without any fresh proof of human presence.

The solution here is a per-tool risk level model, with a short-lived capability handle (15 min) that authorizes reading, and a per-call passkey confirmation that authorizes any write or deletion -- rendered from the actual arguments the server received, never from text the LLM controls.

Architecture

Cliente MCP remoto (Claude.ai, via Custom Connector)
        │  HTTPS (OAuth Google + allowlist -- fora do escopo deste
        │  README; ver mcp-oauth-gateway/enquire-mcp-gateway)
        ▼
┌───────────────────────────────────────────────────────────┐
│                          gateway                            │
│                                                              │
│  StepUpMiddleware -- por tool call:                         │
│    1. policy.yaml decide o nivel (0/1/2) da tool             │
│    2. L0 (tools de auth) -- sempre passa                     │
│    3. L1 (leitura) -- exige handle de sessao valido           │
│       (senao devolve AUTH_REQUIRED + URL de unlock)           │
│    4. L2 (escrita/delete) -- exige confirmacao fresca          │
│       por chamada (args_digest HMAC liga a aprovacao aos       │
│       argumentos EXATOS; senao devolve CONFIRMATION_REQUIRED)  │
│                                                              │
│  Tools injetadas (nivel 0, sempre disponiveis):               │
│    vault_auth_unlock / vault_auth_check / vault_auth_status   │
└──────────────────────────┬───────────────────────────────────┘
                            │ Streamable HTTP + bearer
                            ▼
┌───────────────────────────────────────────────────────────┐
│                       auth-service                          │
│                                                              │
│  WebAuthn (passkey) -- registro, challenges de unlock e de    │
│  confirmacao, sessoes (SQLite), audit log append-only.        │
│  So alcancavel via rotas /internal (X-Gateway-Key) do          │
│  gateway, ou pelas telas publicas /unlock, /confirm,           │
│  /register (esta ultima so com token de bootstrap).            │
└──────────────────────────┬───────────────────────────────────┘
                            │ nunca fala com o backend
                            │ diretamente -- so autentica
                            ▼
              (o handle/token volta ao Claude via
               gateway, que entao repassa a chamada
               original ao backend)
                            │
                            ▼
┌───────────────────────────────────────────────────────────┐
│                          backend                             │
│              enquire-mcp (serve-http, vault Obsidian)         │
└───────────────────────────────────────────────────────────┘

The gateway never stores any credentials -- it only talks to the auth-service (internal routes, authenticated by GATEWAY_KEY) to ask "does this handle authorize this tool?" or "did this confirmation approve exactly these arguments?". The human never types or pastes anything in the chat: the entire passkey ceremony happens in the browser, at a URL served by the auth-service.

Risk levels

Level

What it requires

Example

L0

Nothing -- always allowed

vault_auth_unlock, vault_auth_check, vault_auth_status

L1

Valid session handle (absolute TTL 15 min, idle 5 min)

obsidian_search, obsidian_read_note, obsidian_list_notes

L2

Passkey confirmation per call, bound to the exact arguments via args_digest (HMAC-SHA256)

obsidian_create_note, obsidian_append_to_note, obsidian_archive_note

policies/policy.yaml maps each backend tool to a level. Deny-by-default: any tool not explicitly mapped falls into the most restrictive level (default_level: 2) -- if enquire-mcp gains a new tool in an update (the backend runs npx -y, so it can change version at any start), it arrives protected, not open. See the comments in policies/policy.yaml itself for the provenance of the tool names used and what still needs to be verified live before production.

Setup

Requires Docker and Docker Compose. The three services (gateway, auth-service, backend) come up together.

1. Environment variables

cp .env.example .env    # Windows: Copy-Item .env.example .env

Fill in, at the repository root:

  • Google OAuth (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, PUBLIC_BASE_URL, ALLOWED_EMAILS) -- same pattern as mcp-oauth-gateway; see that project's README for the step-by-step on creating the OAuth Client in the Google Cloud Console.

  • WebAuthn (WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, PUBLIC_ORIGIN, GATEWAY_KEY, DIGEST_KEY) -- see the warning below before setting WEBAUTHN_RP_ID. Generate GATEWAY_KEY and DIGEST_KEY with openssl rand -hex 32.

  • Backend (BACKEND_BEARER_TOKEN, OBSIDIAN_VAULT_PATH) -- the token shared between gateway and backend, and the host path of the Obsidian vault to protect.

WEBAUTHN_RP_ID is PERMANENT. It is the domain (no port, no protocol) that is embedded in the WebAuthn signature of every registered passkey itself. Changing this value after the first registration invalidates ALL passkeys -- everyone needs to register again, with a new bootstrap. Decide the final domain (the same host as PUBLIC_BASE_URL, without https://) before registering the first passkey, not after. The auth-service refuses to start without this variable set (src/authsvc/config.py) -- deliberately: a silent default here would be worse than failing at boot.

2. Bring up the stack

docker compose --env-file .env -f docker/docker-compose.yml up -d --build
docker compose --env-file .env -f docker/docker-compose.yml logs -f auth-service

--env-file .env is not optional -- Docker Compose resolves ${VAR} in the compose file relative to the directory of the file itself (docker/), not the repository root. Running without that flag makes OBSIDIAN_VAULT_PATH fall into a silent fallback (docker/vault, empty) instead of the real vault, with no visible error. See the Uso: comment at the top of docker/docker-compose.yml for the full detail (found in the Task 17 review).

3. Register the first passkey (bootstrap)

In the auth-service logs, look for:

[bootstrap] token de registro (10 min): <token>

Open <PUBLIC_BASE_URL>/register?t=<token> in the browser of a device with a passkey (phone, or a compatible password manager) and complete the registration. The token expires in 10 minutes; if you miss the deadline, restart the auth-service (docker compose restart auth-service) to generate another one -- this also clears pending sessions/challenges (SESSION_PURGE_ON_START=true by default).

Register at least two passkeys (phone + password manager, for example) while the bootstrap token is still valid -- this is this project's mitigation for "I lost the device": there is no recovery code (deliberate decision; see the design spec, open decisions section).

4. Connect as a Custom Connector

In claude.ai -> Settings -> Connectors -> Add custom connector, paste <PUBLIC_BASE_URL>/mcp. Leave the OAuth Client fields empty (dynamic registration). After logging in with a Google account present in ALLOWED_EMAILS, the full verification script (unlock, read, write with confirmation, and the two-conversation test) is in tests/integration/test_e2e_manual.md.

Known limitations

  • A8 -- Person B opening the same conversation within the 15-minute window inherits the handle. This is the real gap, already documented and accepted by design, of the handle model: the session handle (L1) is not tied to the identity of who is reading the conversation at that moment, only to the conversation where it was born. If the Claude account is shared and Person B opens the same conversation that Person A unlocked -- not a new conversation -- within the 15-minute absolute TTL (or 5 min idle), B inherits the read capability (L1) that A obtained. Mitigated by short TTL, idle timeout, and additional binding to Mcp-Session-Id when the client provides it stably -- but not eliminated. Write (L2) remains unreachable for B in any case, because it requires a fresh passkey signature per call. See section A8 of the design spec (docs/superpowers/specs/2026-08-16-mcp-stepup-auth-proxy-design.md) for the full threat analysis. This is not a bug to be silently fixed -- it is a known limitation of the per-conversation shared handle model, and Step 7 of the script in tests/integration/test_e2e_manual.md exists precisely to prove that the distinct case (new conversation) is correctly blocked.

  • Rate limiting is not wired into any request path. The module src/authsvc/ratelimit.py (in-memory sliding window, class Janela) exists and has its own tests, but no route in auth-service or gateway instantiates or calls it -- it is not "wired". In practice, this means the mitigation for "handle brute force" and "systematic vault scanning" described in section 20 (Security tests) and section 14 (Prompt injection protection, item 4) of the design spec still does not exist in production, even though the base code is ready. This is a real gap, not covered by any other control in this project -- policies/policy.yaml has a rate_limits section with example values (level_1: { calls: 60, window_s: 300 }), but nothing in gateway_main.py or the current src/stepup/middleware.py reads those values to actually limit calls. Before exposing this gateway to real-volume usage (not just a single trusted user), wiring ratelimit.Janela into the L1 path (and, ideally, also into challenge/confirmation attempts in the auth-service) should be treated as a priority, not as polish.

  • Other structural limitations (no process supervision, BACKEND_BEARER_TOKEN shared as a secret without per-caller scoping, public exposure requires your own tunnel) are the same as mcp-oauth-gateway, from which this project inherits the OAuth/allowlist layer -- see that project's README for details.

Tests

# Windows
.venv\Scripts\pytest.exe -v
# Linux/macOS
.venv/bin/pytest -v

They cover: the authorization policy (src/stepup/policy.py), the step-up middleware (levels, AUTH_REQUIRED/CONFIRMATION_REQUIRED), the auth-service (WebAuthn, sessions, challenges, confirmations, audit log, HMAC digest), and the docker-compose.yml configuration resolution (including the two error modes of the missing --env-file .env).

The end-to-end script against a real MCP client and a physical passkey is not in this suite -- see tests/integration/test_e2e_manual.md.

-
license - not tested
-
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.

Related MCP Connectors

  • Remote MCP for Copilot CLI switch gate MCP, structured receipts, audit logs, and reviewer-ready evid

  • Self-hosted federated MCP gateway: one OAuth 2.1 MCP server in front of N apps, user-level scopes.

  • MCP server teaching AI agents to implement TideCloak: auth, E2EE, IGA, security analysis

View all MCP Connectors

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/joaorura/mcp-stepup-gateway'

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