Skip to content

Identity and permission

  • Lesson 15
  • 55 min
  • Module 5: Remote access and authorization

What you will be able to do

Decide, on the server, what each remote caller may do. You turn a checked token and a header into a caller with an organization, a role and scopes. Then you watch 48 real requests from twelve callers, and name the check that refused each one.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

Your project

  • ~/projects/support-mcp after Remote transport: python -m pytest -q prints 49 passed, and .idp/ holds the practice key.
  • Terminal 1 runs python -m idp, terminal 2 runs python -m support_mcp --http, terminal 3 is free. Each has the virtual environment active.

An office building has several companies on its floors. At the reception desk, a visitor shows a badge from the city. The badge proves who the visitor is. It does not say which company the visitor may enter.

So a good desk asks three separate questions. Who are you? Show your badge. Which company do you visit? Is your name on that company’s list? What may you do there? A visitor may sit in the meeting room, but not open the cash box. The authentication course used the same desk. Here, the desk is the MCP server, and it asks the three questions for every request.

The problem: a valid token is not permission

Section titled “The problem: a valid token is not permission”

Ines is the owner of Bramble Books. Her token is real: the practice provider signed it, it is for this server, and it has not expired. What if the server stopped at “the token is valid”? Then Ines could set X-Support-Tenant: larkfield and read Larkfield’s tickets.

Tomás has two other ideas. “Put the organization and the role in the token,” he says, “then the server can trust them.” But Camille is staff at Larkfield and read-only at Bramble Books. One role in their token would be wrong in one of the two shops. His second idea: “The host can hide the tools from people who may not use them.” Kwame asks his usual question: “Who is the caller, what may they do, and in which organization?” A host that hides a tool answers none of it. Any other client can still send the request.

Here is what the server does with Ines’s valid token and the wrong organization. This is a real run:

[host] discovered 2 tools: search_knowledge, get_ticket {"protocol": "2026-07-28", "server": "support-mcp"}
[model] wants get_ticket {"arguments": {"ticket_id": "T-30002"}}
[host] read-only tool on the allow-list: run it {"decision": "auto"}
[server] protocol error -32003: forbidden: you have no access to this organization {"code": -32003}

The token passed: there was no 401. A second check, after the token, refused.

The idea: authentication, then authorization

Section titled “The idea: authentication, then authorization”

The authentication course split the work in two. Authentication answers “who is the caller?” The token proves it, and the server checks the proof. Authorization answers “what may this caller do here?” The server decides that from its own records, for every request.

The server builds a caller from three inputs. The checked token gives the user (sub) and the scopes: what the person allowed this application to do, such as tickets:read. The X-Support-Tenant header gives the organization, the tenant. The server’s membership table gives the role of that user in that organization: owner, staff or read_only. A membership is one row of that table. If there is no row, there is no caller, and the answer is −32003.

Then the request passes the checks in a fixed order. First the token, then the membership, then the scope, then the role: one policy document is for staff only. Last comes an organization-scoped lookup: the server searches the data of the caller’s organization only. So another organization’s ticket is “not found”. Each check that refuses stops the request, and the later checks never run.

Three inputs on the left. 1. The access token (checked): sub usr-camille, scope knowledge:read tickets:read, aud http://127.0.0.1:8000/mcp. 2. The connection's header: X-Support-Tenant: bramble. 3. The server's membership table: larkfield, usr-camille, staff; bramble, usr-camille, read_only (data/memberships.csv). Arrows from all three go to one box, the caller: user usr-camille, organization bramble, role read_only, scopes knowledge:read tickets:read. Under it in red: no membership for this user here? -32003 forbidden: you have no access to this organization. On the right, two boxes. Authentication: who is the caller? The token proves it: signature, issuer, audience, expiry. Authorization: what may they do here? Membership in the header's organization, the scope, the role, a lookup inside this organization only. On every request, on the server. Footer: the token has no organization and no role. The same token with X-Support-Tenant: larkfield gives Camille the role staff. No tool takes an organization argument, so the model cannot choose one.
The server combines a checked token, the connection's header and its own membership table into one caller. The token proves who; the server decides what.
Text description of the diagram

Three inputs make one caller:

  1. The checked access token: sub is usr-camille, scope is knowledge:read tickets:read, aud is the server’s URL.
  2. The connection’s header: X-Support-Tenant: bramble.
  3. The server’s membership table, data/memberships.csv: Camille is staff at Larkfield and read_only at Bramble Books.

The caller is usr-camille, organization bramble, role read_only, with the token’s two scopes. With no membership row for this user and organization, the answer is −32003 “forbidden: you have no access to this organization”.

Authentication asks who the caller is; the token proves it with its signature, issuer, audience and expiry. Authorization asks what the caller may do here: membership, scope, role and a lookup inside this organization, on every request, on the server. The token has no organization and no role. With the header larkfield, the same token makes Camille staff.

A tiny example: one person, two organizations

Section titled “A tiny example: one person, two organizations”

Camille is the best test, because they have two memberships. Take one token and send two requests that differ only in the header. Predict both answers first.

Header Role from the table get_ticket T-40003 (a Bramble ticket)
X-Support-Tenant: larkfield staff not_found: there is no ticket T-40003 in this organization
X-Support-Tenant: bramble read_only the ticket: “Book arrived damaged”

Both answers are real. At Larkfield, Camille is staff, so they have more rights than at Bramble Books. Still they cannot see a Bramble ticket there, because the server only searches Larkfield’s data. A missing ID and another organization’s ID get the same answer, so the error reveals nothing.

support_mcp/identity.py gained one function, http_caller. Every tool calls it first, through caller(ctx):

support_mcp/identity.py (http_caller and resolve)
def http_caller(ctx) -> Caller:
"""The caller of a remote (Streamable HTTP) request: the validated token's user and scopes,
the organization named by the connection's header, the role from the membership table."""
from mcp.server.auth.middleware.auth_context import get_access_token
token = get_access_token()
if token is None or not token.subject: # the HTTP app refuses this before; checked again here
raise forbidden("forbidden: no signed-in user")
tenant = (ctx.headers or {}).get(TENANT_HEADER, "")
return resolve(token.subject, tenant, token.scopes, token.client_id)
def resolve(user_id: str, tenant: str, scopes, client_id: str = "local") -> Caller:
role = MEMBERSHIPS.get((user_id, tenant))
if role is None: # no membership, or no such organization: the same answer
raise forbidden("forbidden: you have no access to this organization")
return Caller(user_id, tenant, role, frozenset(scopes), client_id)

get_access_token() returns the token that JwtVerifier already checked. A token that failed never gets here: the SDK answered 401 before. resolve is the same function that the stdio server uses. Only the source of the user and the organization differs. A missing header gives an empty organization, and an empty organization has no membership: −32003.

The scope check sits inside each tool, before the data:

support_mcp/server.py (get_ticket, the checks)
who = caller(ctx)
identity.require_scope(who, "tickets:read")
try:
t = knowledge.ticket(who.tenant, ticket_id)
except NotFound:
raise ToolError(f"not_found: there is no ticket {ticket_id} in this organization") from None

knowledge.ticket(who.tenant, ticket_id) is the organization-scoped lookup. The organization comes from who, never from the model’s arguments. The role check sits in one place here: knowledge.document(..., staff=who.staff) hides the staff-only refund procedure from read-only callers. They get “Resource not found”, the same answer as for a missing document.

The HTTP layer adds one check before MCP. required_scopes in http.py lists both read scopes, so a token without them gets HTTP 403 insufficient_scope for every request. OAuth integration explains why the server advertises exactly these two.

scripts/attempts.py sends real requests over Streamable HTTP. It tries twelve callers, each with four requests, and names the check that refused each one. It starts its own server and signs its own tokens, so it needs no other terminal.

  1. In terminal 3, copy this lesson’s files:

    Terminal window
    S=../nextia-learning-labs/C28/snapshots/end-of-m05
    cp "$S/scripts/attempts.py" scripts/
    cp "$S/tests/test_http.py" tests/
  2. Run the tests:

    Terminal window
    python -m pytest -q
    ............................................................... [100%]
    63 passed in 9.87s

    The 14 new tests in tests/test_http.py send real HTTP requests. They try six kinds of bad token, two wrong organizations, a cross-organization ID, a missing scope and a person with no membership.

  3. Run the attempts, and send the server’s log lines to a file:

    Terminal window
    python -m scripts.attempts 2> attempts-log.txt

    The table (shortened to five of the twelve callers):

    usr-camille @ larkfield (token: valid, scopes: knowledge:read tickets:read)
    search policies ok allowed
    read a Larkfield ticket ok allowed
    read a Bramble ticket tool_error organization-scoped lookup (not in this organization)
    read the staff-only procedure ok allowed
    usr-camille @ bramble (token: valid, scopes: knowledge:read tickets:read)
    search policies ok allowed
    read a Larkfield ticket tool_error organization-scoped lookup (not in this organization)
    read a Bramble ticket ok allowed
    read the staff-only procedure refused role (the membership's role)
    usr-ines @ larkfield (token: valid, scopes: knowledge:read tickets:read)
    search policies refused membership (no membership here)
    ...
    usr-sam @ larkfield (token: valid, scopes: knowledge:read)
    search policies refused scope (the token does not allow it)
    ...
    usr-sam @ larkfield (token: wrong_audience, scopes: knowledge:read tickets:read)
    search policies refused token (signature, issuer, audience, expiry)
    ...
    48 attempts: 15 allowed, 33 refused on the server.

    Your table shows all twelve callers. Every refusal happened on the server: no host and no model took part.

The token check refused 12: no token, a token for another API, an expired token. The membership check refused 8 (Ines at Larkfield, Tomás), and the scope check 4. The role check refused 3: the staff-only procedure, for read-only callers. The organization-scoped lookup refused 6, each another organization’s ticket.

Now explore the same 48 attempts. Each one is a recorded answer, not a simulation.

Explore

Which check stops the request?

Role in this organization
staff
The server answers
Refused (a tool result with isError: true)
Stopped by
Organization-scoped lookup

Camille at Larkfield: valid token, scopes knowledge:read tickets:read: "Read Bramble ticket T-40003" is refused by check 5 of 5, Organization-scoped lookup. The checks after it never ran.

The server's checks, in order

  1. 1Tokensignature, issuer, audience, expirypassed
  2. 2Membershipthe user is a member of the organization in the headerpassed
  3. 3Scopethe token allows this kind of requestpassed
  4. 4Rolethe membership's role allows this action or this documentpassed
  5. 5Organization-scoped lookupthe record exists in this organizationrefused here

What was sent, and the real answer

tools/call get_ticket {"ticket_id": "T-40003"}

Error executing tool get_ticket: not_found: there is no ticket T-40003 in this organization
  • allowed
  • refused by the server
  • only proposed
Camille at Larkfield: valid token, scopes knowledge:read tickets:read: every request
RequestAnswerStopped by
Search the policiesok–
Read Larkfield ticket T-30002ok–
Read Bramble ticket T-40003 (chosen)tool_errorOrganization-scoped lookup
Read the staff-only procedureok–

Every answer is a real request over Streamable HTTP, sent by python -m scripts.attempts --json to the server of the project, with tokens signed by the practice provider. The people are made up.

48 real requests over Streamable HTTP: 12 callers × 4 requests. The chain shows the server's checks in order, and where the request stopped.
Text description of the explorer

The explorer shows the 48 attempts of python -m scripts.attempts --json. Pick a caller: a person, the organization in the header, a token and its scopes. Then pick one of four requests. The readout gives the role, the server’s answer and the check that stopped the request. The chain lists the five checks of the server in order: token, membership, scope, role, organization-scoped lookup. Each one is marked passed, refused here, or not reached. Below, the real request and answer appear, then the caller’s four requests in a table.

The starting case: Camille at Larkfield asks for T-40003. The token, membership, scope and role checks pass. The organization-scoped lookup refuses: not_found: there is no ticket T-40003 in this organization. With the header bramble, the same request is allowed. The membership check refuses all four requests of Ines at Larkfield, with −32003. The token check refuses a token for another API with HTTP 401, before any other check. The scope check refuses Sam’s token with knowledge:read only, with HTTP 403, even for a search. Omar, read-only at Larkfield, may search and read tickets, but the role check makes the staff-only procedure “Resource not found” for him.

Tomás has no membership at all. What does he see when his host connects? A real run, with his valid token:

['search_knowledge', 'get_ticket']

tools/list showed him both tools. Then every call was refused:

[server] protocol error -32003: forbidden: you have no access to this organization {"code": -32003}
[model] wants search_knowledge {"arguments": {"query": "Draft a reply to ticket"}}
[host] read-only tool on the allow-list: run it {"decision": "auto"}
[server] protocol error -32003: forbidden: you have no access to this organization {"code": -32003}
[model] final answer
I could not use get_ticket: forbidden: you have no access to this organization
I could not use search_knowledge: forbidden: you have no access to this organization

That is the design. Discovery here is not gated by membership: seeing a tool’s name and schema gives no data. The specification also allows a server to return a different tool list for each caller. That is a good option for a large server, because the model then sees fewer tools that it cannot use. But a shorter list is not a control. A client can always send a tools/call for a name that it never saw, so the server must check every call anyway.

Use Camille’s and Ines’s tokens with the course’s host. Change only --tenant, and predict each answer first.

Terminal window
CAMILLE=$(python -m idp token --user usr-camille)
INES=$(python -m idp token --user usr-ines)
python -m host "Draft a reply to ticket T-40003" --http http://127.0.0.1:8000/mcp --token "$CAMILLE" --tenant larkfield
python -m host "Draft a reply to ticket T-40003" --http http://127.0.0.1:8000/mcp --token "$CAMILLE" --tenant bramble
python -m host "Draft a reply to ticket T-30002" --http http://127.0.0.1:8000/mcp --token "$INES" --tenant nowhere
Show the answers

Camille at Larkfield: the lookup refuses, and the search finds nothing for their words.

[server] tool error: Error executing tool get_ticket: not_found: there is no ticket T-40003 in this organization {"ms": 2.2}
...
I could not use get_ticket: Error executing tool get_ticket: not_found: there is no ticket T-40003 in this organization
I found no policy about this.

Camille at Bramble Books: allowed, as read-only.

Ticket T-40003 (open): Book arrived damaged.
From "Damaged or wrong books" (policy://bramble/damaged-or-wrong-books): # Damaged or wrong books If a book arrives damaged, or we sent the wrong book, tell us within 14 days of delivery. Send a …

Ines at nowhere, an organization that does not exist: the same −32003 as Ines at Larkfield, a real organization where she has no membership.

[server] protocol error -32003: forbidden: you have no access to this organization {"code": -32003}

The server never says whether an organization exists. A stranger learns nothing by trying names.

Check membership on every request. The role lives in the server’s records, not in the token. Suppose Grace removes Omar from Larkfield. His token stays valid for up to ten more minutes, but his next request can already be refused. In this project, the server loads data/memberships.csv when it starts, so a change needs a restart. A real service reads a database on each request, or a cache that it keeps fresh. That lookup is the cost.

Answer “not found” for other organizations’ records. The lookup inside one organization is the last line of defence. Even a bug in an earlier check cannot show a Bramble ticket on a Larkfield connection. The cost: the server cannot tell a user “this ticket exists elsewhere”. That is the point.

Keep the decision on the server. The host’s allow-list and confirmation still matter, for the person’s experience and for writes in Module 6. But anyone can change a host, configure it wrongly or replace it with curl. The server’s checks are the ones that hold.

What the server does not record yet. The run’s log has no line for a −32003 refusal. Tomás’s two refused calls left no trace in terminal 2. A real service needs that trace for an audit; Resource limits and observability adds structured events.

Common mistake

Every request answers -32003, also for Sam

Symptom: the host prints protocol error -32003: forbidden: you have no access to this organization for every tool, even with Sam’s token.

Cause: the organization header is missing or wrong. A typo such as --tenant lark, or a curl without -H "X-Support-Tenant: larkfield", gives an empty or unknown organization. The course team sent a curl with no header and got exactly this −32003.

Fix: set the organization in the host’s configuration: --tenant larkfield for the course’s host, or the header in curl. Check the spelling against data/memberships.csv.

Common mistake

HTTP 403 with error="insufficient_scope"

Symptom:

HTTP/1.1 403 Forbidden
www-authenticate: Bearer error="insufficient_scope", error_description="Required scope: tickets:read", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

Cause: the token lacks one of the two read scopes. Here it was made with --scope knowledge:read. The server requires both read scopes for every request, so even a search is refused before MCP.

Fix: ask for both read scopes, which is the default of python -m idp token. If an application really needs fewer, change the server’s design on purpose. The module’s independent task does exactly that.

Clean up. Delete attempts-log.txt. Keep terminals 1 and 2 running for the next lesson, or stop them with Ctrl+C.

Check your understanding

Explain the difference between authentication and authorization in this server. Name the input that answers each question.

Show the answer

Authentication answers “who is the caller?”: the server checks the access token (signature, issuer, audience, expiry) and takes the user from its sub. Authorization answers “what may this caller do here?”. The server finds the user’s membership in the organization of the X-Support-Tenant header, and takes the role from its own table. Then it checks the token’s scope, and looks the record up inside that organization only. A valid token passes authentication and can still be refused by authorization.

Check your understanding

Predict: Omar is read-only at Larkfield. With a valid token and both read scopes, he reads the resource policy://larkfield/refund-approval-procedure. What comes back, and which check decides it?

Show the answer

−32602 “Resource not found: policy://larkfield/refund-approval-procedure”. The token, membership and scope checks pass. The role check hides the staff-only document from read-only callers, and the server answers as if it did not exist. Grace, Sam and Camille at Larkfield (owner or staff) read it.

Check your understanding

Practical check: run python -m scripts.attempts. How many attempts are allowed and refused, and why does Tomás see the tools but get every call refused?

Show the answer

48 attempts: 15 allowed, 33 refused on the server. Tomás has a valid token, so authentication passes, and discovery is not gated by membership in this server. He has no membership in any organization, so every call is refused with −32003 by the membership check. Hiding the tools would not protect anything; the server’s check does.

Recap

  • Authentication proves who the caller is: the server checks the token. Authorization decides what the caller may do in this organization: the server checks its own records.
  • The caller is the token’s user and scopes, the header’s organization and the role from the server’s membership table. The token holds no organization and no role.
  • The checks run in order, for every request: token, membership, scope, role, organization-scoped lookup. The first refusal stops the request.
  • Another organization’s record is “not found”, and an unknown organization gets the same −32003 as one without membership.
  • Hiding a tool is not authorization. The server refuses every call it should refuse.

Your project now has 63 tests and a table of 48 real attempts. So far, the tokens came from a practice shortcut. Next, you follow the official way an MCP client gets a token: metadata, sign-in with PKCE and the audience.

Next: OAuth integration