Engineering · Part 2 · remote MCP over OAuth

A real MCP server, and the OAuth that guards it

apiai

Written during auth v2. The architecture below is current, but v3 renamed the identifiers (Actor/ActorProjectConsent, SPEC §24.8) and retired token scopes — wire tokens are scope-less (§24.4); the per-blog policy on the consent is the whole machine cap.

Reference documentation for a remote MCP server built directly on Part 1's operation catalogue. Two halves — a small protocol and a standard authorization flow — neither requiring new infrastructure.

Part 1 ended on a promise: the operation catalogue — every capability registered once, with schemas — could be served to any AI client with almost no new code. This document keeps it. By the end you have a remote MCP server that Claude, ChatGPT or any MCP-speaking client adds by URL, logs into with your existing account system, and uses — with the user deciding, on a real consent screen, exactly which blogs the connector may touch.

Half one — MCP is a URL that speaks JSON-RPC

Over its Streamable HTTP transport, an MCP server is a single endpoint — say POST /mcp — that receives one JSON-RPC 2.0 message per request and returns one JSON response. A tools-only server needs no server-sent events at all. The entire conversation:

client → initialize                  (version + capability exchange)
client → notifications/initialized   (you reply 202, no body)
client → tools/list                  ("what can you do?" — names, descriptions, JSON Schemas)
client → tools/call                  (name + arguments → result)   …repeatedly, forever

The dispatch table is a page of code, and every branch maps onto Part 1's machinery:

def dispatch(message, operations, invoke, *, not_found):
    method, rid, params = message.get('method'), message.get('id'), message.get('params') or {}
    if method == 'initialize':
        return _result(rid, {'protocolVersion': _negotiate(params.get('protocolVersion')),
                             'operations': {'tools': {}}, 'serverInfo': SERVER_INFO})
    if method == 'notifications/initialized':
        return None                                   # the transport answers HTTP 202
    if method == 'ping':
        return _result(rid, {})
    if method == 'tools/list':
        return _result(rid, {'tools': tool_defs(operations)})
    if method == 'tools/call':
        name, args = params.get('name'), params.get('arguments') or {}
        if name not in operations:
            return _tool_error(rid, f'unknown tool: {name}')
        try:
            out, _effects = invoke(name, args)        # perform_json, bound to user + scope
        except ValidationError as e:
            return _tool_error(rid, f'invalid input: {e.message}')
        except PermissionDenied as e:
            return _tool_error(rid, f'forbidden: {e}')
        except not_found as e:
            return _tool_error(rid, f'not found: {e}')
        return _result(rid, {'content': [{'type': 'text', 'text': json.dumps(out)}],
                             'isError': False, 'structuredContent': as_object(out)})
    return _error(rid, -32601, f'method not found: {method}')

Four wire-level details that will bite you if you skip them:

  • Negotiate the protocol version. Echo the client's requested version when you support it, otherwise offer your newest. Hard-coding an old version tells a newer client to expect a transport you are not speaking.
  • Tool errors are not JSON-RPC errors. A failed tool call returns a successful JSON-RPC response with isError: true and a plain-text explanation — that text is what the LLM reads, so make it actionable ("call list_blogs first"), never a stack trace. A JSON-RPC error object is reserved for a malformed protocol message.
  • Both structuredContent and outputSchema must be top-level JSON objects. Your read operations return arrays, so wrap the data as {"items": [...]} — and wrap the advertised schema the same way. Miss the second half and strict clients reject your whole tools/list.
  • The endpoint is stateless. One POST, one response, no session id: every request re-authenticates by token, which is what makes revocation and grant edits effective immediately.

From registry to tool list

The registry already knows everything tools/list needs. Two adjustments happen at this boundary, and nowhere else. First, MCP has one flat tool namespace and your endpoint is account-level, so scope moves into the arguments: inject a required integer parameter named after the scope (blog) into each scope-bound operation's advertised schema, and strip it back out before dispatch; account-level operations advertise without it.

def tool_defs(router):
    defs = []
    for name, op in router.visible_ops.items():
        schema = deepcopy(op.input_schema)
        if not op.account:                                     # inject the scope argument
            schema['properties']['blog'] = {'type': 'integer', 'description': 'blog id'}
            schema['required'] = schema.get('required', []) + ['blog']
        defs.append({'name': name, 'description': op.description,
                     'inputSchema': schema,
                     'outputSchema': as_object_schema(op.output_schema)})   # array → {items:…}
    return defs

Second, give the model a way to discover ids rather than guess them — a transport-level meta-tool that is not in the registry (it is cross-scope, so it does not fit the scope-bound catalogue):

def list_blogs(user, actor):
    """Actor ∩ membership — the blogs THIS connector may name (the Actor is defined below)."""
    granted = {ap.blog_id for ap in actor.project_grants.all()} if actor else set()
    return [{'id': m.blog_id, 'name': m.blog.name}
            for m in user.memberships.filter(accepted=True, blog_id__in=granted)]

Everything else — validation, gates, authorization, serialisation — is Part 1's perform_json, untouched. The MCP endpoint is roughly sixty lines and contains no business rules. That is the whole point of Part 1.

Half two — the OAuth that guards it

Remote MCP standardised on OAuth 2.1, with your endpoint in the resource-server role. The steady state is trivial: every request carries Authorization: Bearer <token> and you map token → user. Everything below is just the standardised way a client you have never heard of obtains that token, with a real human approving it in a real browser. The entire dance:

sequenceDiagram
    autonumber
    participant C as MCP client
    participant B as User browser
    participant S as Your server
    C->>S: POST /mcp with no token
    S-->>C: 401 plus a WWW-Authenticate pointer
    C->>S: GET the protected-resource metadata
    S-->>C: my authorization server is this origin (RFC 9728)
    C->>S: GET the authorization-server metadata
    S-->>C: authorize, token and registration endpoints (RFC 8414)
    C->>S: register itself (Dynamic Client Registration, RFC 7591)
    S-->>C: a client_id — a name-tag that grants nothing
    C->>B: open the authorize URL with a PKCE challenge
    B->>S: normal login, then MFA — your existing auth
    S-->>B: consent screen to pick the allowed blogs
    B->>C: redirect back with a one-time code
    C->>S: exchange code and PKCE verifier at the token endpoint
    S-->>C: opaque access token plus a rotating refresh token
    C->>S: POST /mcp with the bearer token, then call tools forever

Walk it once slowly; every arrow answers a "why":

  • ① – ② The 401 is the front door. The WWW-Authenticate header carrying a resource_metadata URL is what tells a generic client "I am an OAuth resource — here is who guards me." Implementing this header is implementing discoverability.
  • ③ – ⑥ Discovery documents are two static-ish JSON views: the protected-resource metadata ("my authorization server is this same origin", RFC 9728) and the authorization-server metadata ("here are its endpoints and capabilities", RFC 8414). Ten lines each. Clients try /.well-known/oauth-authorization-server and fall back to /.well-known/openid-configuration; serve both, aliased.
  • ⑦ – ⑧ Dynamic Client Registration exists because classical OAuth assumed a developer pre-registering each app in a console — impossible when any user's copy of any MCP client may connect to any server. So the client registers itself, anonymously, and receives a client_id. Leave it open (DCR_ENABLED on, no initial access token): registration conveys zero access — no data moves until a real user consents — and rate-limit it like any unauthenticated endpoint.
  • ⑨ – ⑪ The authorize step runs in the browser, against your normal login. You do not need a second identity system: the user hits your ordinary session-auth login (password rules, MFA, everything) and lands on a consent page. PKCE — the hashed challenge in ⑨, the verifier in ⑬ — proves the token redeemer is the flow starter, which is what lets a public client skip a client secret entirely.
  • ⑭ Opaque tokens, stored hashed. Resist JWTs here: an opaque random string verified by a database lookup means revocation is deleting a row, and there is no claim format to version. Short-lived access token (~1h), rotating refresh token; the browser ceremony happens once per connector, not per session.

On Django you likely do not build the authorization-server half at all — django-allauth ships one (allauth.idp.oidc) covering ⑤–⑭ including DCR; add it to INSTALLED_APPS, mount its URLs, give it an RSA signing key, and it serves the discovery documents, the authorize/token/registration endpoints, and the token store. Your genuinely custom code is the consent screen's contents — which is the interesting design decision.

The anonymous, self-registering connector above is the common case, but it is one of three client shapes the same resource server accepts. A DCR/MCP client registers itself (RFC 7591) and gets access only when a user consents — the dance above. An authorization-code app is pre-registered with fixed redirect URIs and shared among users; because the Actor is keyed on (user, client), each person who authorizes it gets their own Actor and cap. A client-credentials client has no user at all — a CI bot, say — so it acts as the person who registered it: its access is set on the register form (no consent screen), and its calls run against that owner's live membership, capped identically. One grant model, three ways in. (The device-code flow, RFC 8628, is deliberately left off — there is no browser-less client this product needs to support, and every disabled flow is attack surface removed.)

Consent lends a permission set, capped by yours

The consent screen should not ask "allow or deny?". It asks "which blogs may this client reach, and with what powers?" — and it must never let the client out-reach the human behind it. Store the answer as first-class rows the token merely points at. Reuse Part 17's permission sets: a connector borrows a named set per blog.

class Actor(models.Model):
    """One connector's grant, FOR ONE USER — replaces a token-baked scope list. Keyed on
    (user, client), not the client alone: a shared authorization-code app is one client with an
    Actor per consenting user, so each person's grant (and cap) is their own."""
    user   = models.ForeignKey(User, on_delete=models.CASCADE, related_name='actors')
    # Nullable on purpose: a NULL client is the product's OWN in-app AI assistant — an Actor with no
    # OAuth client behind it (the last section). Every OAuth caller has a client; the assistant does not.
    client = models.ForeignKey(OAuthClient, null=True, on_delete=models.CASCADE, related_name='actors')

    class Meta:
        constraints = [models.UniqueConstraint(fields=['user', 'client'],
                                               name='one_actor_per_user_client')]

class ActorProject(models.Model):
    """A per-blog cap: the permission SET this connector borrows on this blog."""
    actor          = models.ForeignKey(Actor, on_delete=models.CASCADE, related_name='project_grants')
    blog           = models.ForeignKey(Blog, on_delete=models.CASCADE)
    policy = models.ForeignKey(Policy, on_delete=models.CASCADE)

    class Meta:
        constraints = [models.UniqueConstraint(fields=['actor', 'blog'],
                                               name='one_grant_per_actor_blog')]

You override the authorization-server's consent view to render, per blog the user belongs to, the permission sets they could grant there — plus a checkbox for each account-level capability — and write the Actor on approval (list it before the library's own authorize view so your URL wins). The choices come straight from Part 1's registry, so a new capability appears on the consent screen with no extra code:

class ConsentView(AuthorizationView):                 # your subclass wins by URL order
    def form_valid(self, form):
        client   = OAuthClient.objects.get(id=self._request_info['client_id'])
        actor, _ = Actor.objects.update_or_create(user=self.request.user, client=client)
        actor.project_grants.all().delete()
        for blog_id, set_id in form.chosen_sets():        # blog → the borrowed permission set
            actor.project_grants.create(blog_id=blog_id, policy_id=set_id)
        return super().form_valid(form)                   # library mints the code/token

The cap is the whole point. The connector borrows a named set per blog, but the borrow is intersected with the user's own live membership on every call (Part 17's _member_operations). So the token can never do more than both the borrowed set and what the human currently may — widen the set later, and the connector still can't exceed the person it acts for. That single intersection is why a borrowed capability is safe to hand out.

Why rows and not token scopes? Bake the selection into the token and consent becomes a snapshot — changing access means revoking and re-running the whole browser ceremony. Store it as rows the token points at and consent becomes an installation: a Clients screen (two sections — Your clients, the ones you registered, and Connected apps, the ones you authorized) lets the user change a blog's borrowed set or drop one effective on the connector's very next call, and "Disconnect" deletes the Actor and revokes that client's tokens — instant, total. (GitHub learned this moving from OAuth-scope apps to installable Apps.) Two behaviours fall out for free and deserve tests:

  • A stale grant grants nothing. The borrow is ∩'d with the live membership, so a blog the user has since left (their seat gone) yields the empty set — no matter what the Actor still names. Actor ∩ seat, always.
  • No Actor, no reach. A token whose client has no Actor row (or a deleted one) fails every call, even while the token is still technically valid.
# the Clients screen — edit is immediate; disconnect is total
def client_update(request, pk):
    actor = get_object_or_404(Actor, pk=pk, user=request.user)
    actor.project_grants.all().delete()
    for blog_id, set_id in _posted_sets(request):       # only sets ⊆ the user's own membership
        actor.project_grants.create(blog_id=blog_id, policy_id=set_id)
    return redirect('clients')

def client_disconnect(request, pk):
    actor = get_object_or_404(Actor, pk=pk, user=request.user)
    Token.objects.filter(client=actor.client, user=request.user).delete()       # revoke tokens
    actor.delete()
    return redirect('clients')

One more way in: the in-app agent, with no OAuth at all

Everything so far obtained an Actor through the OAuth ceremony — register a client, approve a consent screen, carry a bearer token. But the same table serves a caller with none of those: the product's own in-app AI assistant — the chat-and-tools engine of Part 4. It runs inside the user's ordinary logged-in session, never crosses the bearer/JSON boundary, and belongs to no third party, so minting it an OAuth client and a token would be ceremony for nothing. It gets a client-less Actor instead: the same row, with client = NULL.

@classmethod
def assistant_for(cls, user):
    # One per user, always present — no client, no token, no consent. The in-app agent runs as THIS.
    actor, _ = cls.objects.get_or_create(user=user, client=None)
    return actor

There is no consent dance because there is no third party to consent to. The owner just edits the assistant's caps in the same Clients screen — an always-present "AI assistant" section beside the connector cards — granting it a permission set per project exactly as they would a connector. And because every guarantee lives below the transport, they all carry over unchanged: the granted set is still ∩'d with the live membership on every call (the assistant can never out-reach its owner), a project the owner leaves silently drops out of its reach, and clearing a grant disables the tool on its very next turn. On top of the Actor cap the chat adds one orthogonal gate — it is owner-only, a rule about who may summon the agent, separate from the cap on what it may do.

So the resource server ends up with a single grant model and four entrances: a self-registering DCR/MCP client, a shared authorization-code app, a user-less client-credentials bot — and the first-party assistant that skips OAuth entirely. The transport differs each time; the cap never does. That is the payoff of keeping authorization in the service layer and out of the token (Part 1): the one place that says yes doesn't care how the caller arrived.

The attenuation stack, fully populated

Part 1 introduced the geometry; the MCP transport is where every layer finally has an occupant. Read the shape, not the details — each check above the service layer may only say no:

token scopes read tokens can't call kind='write' operations per-client grant this connector may only name the blogs it was granted product gates terms accepted? subscription live? (gated=False flows skip) service authorization role + scoped fetch — the AUTHORITATIVE decision each layer may only NARROW any layer can say no… …only this one can say yes A session-cookie browser request enters lower down (no token, no grant) — the bottom two layers run for EVERY caller. Delete any upper layer and you lose defence in depth, never correctness.
# the MCP endpoint: bearer only, CSRF-exempt (no cookies), login_not_required.
def endpoint(request):
    if request.method != 'POST':
        return HttpResponse(status=405)
    auth = authenticate_bearer(request)               # opaque token → (user, client, scopes)
    if auth is None:
        r = HttpResponse(status=401)
        r['WWW-Authenticate'] = f'Bearer resource_metadata="{metadata_url(request)}"'
        return r
    user, client, token_scopes = auth
    actor  = Actor.objects.filter(user=user, client=client).first()
    router = Router(user, actor, token_scopes)        # builds visible_ops + the bound invoke
    message = json.loads(request.body)
    return JsonResponse(dispatch(message, router.ops, router.invoke, not_found=NotFound))

# inside Router.invoke, per tools/call — everything here may only narrow:
def invoke(self, name, args):
    op = self.ops[name]
    want = 'read' if op.kind == 'read' else 'write'
    if f'api:{want}' not in self.token_scopes:        # ①  token scope vs op kind (api:*, not mcp:*)
        raise PermissionDenied(f'token lacks api:{want}')
    if self.actor is None:                            # ②  no consent record ⇒ no reach
        raise PermissionDenied('no actor — re-connect and consent')
    if op.account:
        return perform_json(name, self.user, None, args, actor=self.actor)
    blog_id = args.pop('blog', None)                  # the injected scope argument, stripped
    blog    = Blog.objects.get(pk=blog_id)
    # ③+④ perform_json threads the actor into authorize(user, blog, op, actor), which ∩s the actor's
    #     borrowed set on this blog with the user's LIVE seat. A blog the actor never borrowed, an op
    #     outside the borrow, or an op the user has themselves lost — all denied, in ONE place.
    return perform_json(name, self.user, blog, args, actor=self.actor)

Notice the transport no longer owns a "grant ∩ requested blog" check — the cap moved into the service, because authorize now takes the actor and intersects its borrowed set with the live membership itself. Delete the two MCP-specific checks above and you lose scope enforcement and a courteous early error — never correctness, because perform_json runs the gates and the authoritative borrowed-set-∩-seat check exactly as it does for every other caller. That is also why the same bearer token works on the JSON API: /api/ and /mcp are two transports over one resource server, sharing scopes, the Actor cap, and authorize.

Production details that matter

  • Per-connector tools/list. Filter what you advertise by the same rules that govern calls — account tools only when granted, and (optionally) only the operations the borrowed set covers. Hiding stays UX (a guessed name dies in the service layer) but a model that cannot see irrelevant tools does not waste turns on them.
  • CSRF does not apply — because cookies do not. The endpoint is bearer-only; exempt it from CSRF and from any login-redirect middleware, and let the 401-with-pointer be the unauthenticated experience.
  • Staff-only tools raise the consent bar. The consent page is now a security-relevant surface; require the same step-up (e.g. MFA) to consent as you require to use those tools directly.
  • File operations ride the same door. Part 1's from_json hook (base64 → uploaded file) means even an upload tool works over JSON; cap the size in the schema.
  • Conformance is free. The reference MCP Inspector CLI has strict schema validation; point it at your endpoint in CI with a pre-minted token — it catches the array-schema-wrap mistake and anything like it.

The recipe

  1. Mount the two discovery documents and the 401-with-pointer on your endpoint.
  2. Enable the authorization server (a library): anonymous DCR on, opaque tokens, an RSA signing key; leave the device-code flow off. No scopes — v3 wire tokens are scope-less (§24.4): the per-blog cap is the consent's policy (next step), which says everything a coarse read/write scope would and more.
  3. Override consent to write an Actor + a per-blog ActorProject (a permission-set picker per blog, offering only sets ⊆ the user's membership); add the Clients screen (Your clients / Connected apps; register, reveal the secret once, delete) — edit = immediate, disconnect = delete Actor + tokens.
  4. Write the endpoint: dispatch table + per-connector tool list (scope injected, arrays wrapped) + the scope/actor checks → perform_json, which threads the actor into authorize for the borrowed-set-∩-seat cap.
  5. Point the Inspector at it for conformance, then a real client end to end.

Notice what you did not do: define tools by hand (the registry did), duplicate authorization (the services own it), or invent an identity system (your login carried the whole ceremony). The server is thin because Part 1 made it possible to be thin.

Part 3 tests all of this without paying for the cloud — including driving this exact OAuth dance with the official MCP SDK as the client and a real browser doing the consent.


← All posts