Skip to content
arpitjain310Public

About

A remote MCP server that acts as an OAuth resource server: verified JWTs, per-tool scopes with 403 step-up, per-user data.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

7 Commits

Folders and files

Repository files navigation

warrant

ci

A remote MCP server that acts as a proper OAuth resource server: every tool call is authenticated against an external identity provider, authorized per tool, and scoped to the caller's own data.

Status: work in progress.


The problem

Most MCP server examples run over stdio and trust whoever launched them. On a URL, every request comes from a client you don't control, carrying a token issued by an authorization server you don't run. Each request has to answer:

  • Is the token genuine? Signature against the issuer's published keys, issuer, expiry.
  • Was it issued for this server? A token minted for a different API is refused (audience). Otherwise any service that sees a user's token can replay it here.
  • Can it call this tool? Read and write are separate scopes; a read-only token can't create.
  • Whose data? Every query is filtered by the token's subject. There is no unscoped read path.

The server never issues tokens. Login, consent and client registration stay with the authorization server; this side only verifies.

What it does

client ── POST /mcp + Bearer ──▶ JwtVerifier ──▶ scope gate ──▶ tool ──▶ NoteStore
  ▲                              JWKS, iss,      tool → scope   owner =    SQLite
  │                              aud, exp             │         token.sub
  ├── 401 + resource_metadata ◀── missing / invalid   │
  │   (discover the authorization server, log in)     │
  └── 403 insufficient_scope ◀────────────────────────┘
      (re-authorize with the added scope, retry)
  • Discovery — an unauthenticated request gets 401 with WWW-Authenticate: Bearer resource_metadata=.... The protected resource metadata (RFC 9728) names the authorization server and the baseline scope, notes:read, so a client can log in without per-server configuration.
  • Step-up — a call the token lacks scope for gets 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="...", listing everything that operation needs. The client re-authorizes and retries, so write access is granted when first used, not at login.
  • JwtVerifier (auth.py) — validates JWT access tokens (RFC 9068) against the issuer's JWKS: signature, iss, aud, exp, sub. RS256 only. An unknown key ID triggers one JWKS refetch (key rotation), with a cooldown so forged key IDs can't make it hammer the issuer.
  • Scope gate (server.py) — TOOL_SCOPES maps each tool to its scope. The HTTP gate reads the tool from the 2026-07-28 Mcp-Name header, which the SDK rejects if it disagrees with the body. A handler-tier check repeats it for requests without the header, and fails closed on a tool missing from the map.
  • NoteStore (store.py) — SQLite. Every method takes the owner.

Tools

Tool Scope Notes
search_notes(query, limit) notes:read Case-insensitive substring; empty query lists the most recent.
get_note(note_id) notes:read Another user's note reads as not found.
create_note(title, body) notes:write

Every request also needs notes:read.

Explicit non-goals

  • Not an authorization server — no login, token issuance or client registration. Targets any OIDC provider that issues JWT access tokens.
  • Not a gateway — one server, its own tools.
  • Notes are a deliberately small domain; the auth layer is the point.

Protocol

Built on the official Python SDK (mcp 2.x) at MCP revision 2026-07-28: stateless, no sessions, each request carries its own protocol version. The SDK handles transport and the 401. Token verification, per-tool scopes, the 403 step-up challenge and per-user data scoping are here.

tests/test_oauth_flow.py runs the SDK's own OAuth client against the server and a stub authorization server: discovery, a notes:read login, a 403 on create_note, re-authorization for notes:read notes:write, and the retry.

Quickstart

python -m venv .venv && . .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
pytest -q
warrant --dev

--dev needs no identity provider. It runs a local issuer (signs tokens and serves its keys; not an authorization server) and prints a read token and a read+write token for user dev. Pass one as a bearer header from any MCP client, e.g. Claude Code:

claude mcp add --transport http warrant http://127.0.0.1:8000/mcp --header "Authorization: Bearer <token>"

With the read token, create_note gets the 403 step-up challenge. A static header can't re-authorize, so the call stops there.

Against an identity provider

The API (audience) identifier is the server's public /mcp URL, with notes:read and notes:write defined as its scopes:

warrant --issuer https://YOUR_TENANT/ --resource-url http://localhost:8000/mcp

About

A remote MCP server that acts as an OAuth resource server: verified JWTs, per-tool scopes with 403 step-up, per-user data.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages