Skip to content

OAuth integration

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

What you will be able to do

Let any MCP client get a token for Larkfield’s server the official way, with no shortcut. You follow the flow step by step: the server’s 401, two metadata documents, registration, sign-in with PKCE, and a token for this server. Then you watch the SDK’s own client do it in one call, and fix the scopes that it asked for.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

Your project

  • ~/projects/support-mcp after Identity and permission: python -m pytest -q prints 63 passed.
  • Terminal 1 runs python -m idp, terminal 2 runs python -m support_mcp --http.
  • The by-hand steps use a macOS or Linux shell. On Windows, use Git Bash or WSL for them (not tested), or skip to python -m scripts.oauth_login.

A key card at a hotel you have never visited

Section titled “A key card at a hotel you have never visited”

The authentication course used a hotel. The reception desk checks your passport and gives you a key card. The card opens room 214 and the gym, and it stops working when you check out. The door never sees your passport; it only checks the card.

Now imagine a new guest at a hotel they do not know. They find a locked door. A sign on the door says which reception desk issues its cards. At that desk, a board lists the services: where to sign in, where to collect the card. The guest gives their name once and gets a guest number. Then they sign in, and they get a card for that one door only. The phone digits from the authentication course still apply: the person who collects the card must prove that they started the request.

So far, the tokens came from python -m idp token, a practice shortcut on the same computer. Omar’s partner app has no such shortcut. It knows one thing: the URL http://127.0.0.1:8000/mcp. It does not know which provider issues tokens, which scopes to ask for, or what the audience must be. It has no client ID either.

Its first request gets this answer, which you saw in Remote transport:

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

There is a second risk. A careless client could ask for every scope the provider offers, or reuse a token made for another service. The course team met the first one in a real run, as you see later in this lesson.

MCP authorization uses OAuth with a few standard documents. The parts have names that you saw in the authentication course, plus three new ones.

The MCP server is a resource server: it accepts tokens, and it never signs anyone in. The provider is the authorization server: it signs people in and issues tokens. The server publishes its protected resource metadata at a well-known URL (RFC 9728). That document names the server’s own URL, the resource, and its authorization servers. The provider publishes its own authorization server metadata (RFC 8414): its endpoints and what it supports.

A new client can register itself with dynamic client registration (RFC 7591), and gets a client ID. Revision 2026-07-28 prefers another way, a client ID that is the URL of a metadata document; it keeps dynamic registration, marked deprecated, for providers that lack that way. The practice provider and SDK 2.3.0’s client use dynamic registration. Then it runs the authorization code flow with PKCE (said “pixie”). It also sends a resource parameter (RFC 8707) with the server’s URL. The provider writes that URL into the token as its audience. So a token for this server works only here.

A sequence between four participants: the person (Sam), the host with its MCP client (the SDK's OAuthClientProvider), support-mcp (the resource server at :8000/mcp) and the practice provider (the authorization server at :8400). 1. POST /mcp with no token. 2. 401 with WWW-Authenticate: Bearer and resource_metadata pointing to /.well-known/oauth-protected-resource/mcp. 3. GET that document. 4. 200: resource :8000/mcp, authorization_servers [:8400], scopes_supported [knowledge:read, tickets:read]. 5. GET /.well-known/oauth-authorization-server from the provider. 6. 200: authorize, token, register, jwks_uri, S256. 7. POST /register with a redirect URI on 127.0.0.1. 8. 201: client_id client-…. 9. The host opens /authorize with scope knowledge:read tickets:read, resource :8000/mcp, code_challenge and method S256 for the person. 10. The person signs in (practice: pick a made-up person). 11. 302 to the redirect URI with code and state. 12. POST /token with the code, the code_verifier and the resource. 13. 200: an access token, Bearer, 600 s, audience :8000/mcp. 14. POST /mcp with Authorization: Bearer and X-Support-Tenant: larkfield. Note at the server: check the token (signature with the JWKS, issuer, audience = its own URL, expiry), then membership, scope, lookup. 15. 200: get_ticket T-30002, isError false.
The official flow, as the SDK's client ran it against the practice provider. The server only checks tokens; the provider signs the person in.
Text description of the diagram
  1. The host sends POST /mcp with no token. The server answers 401, with resource_metadata pointing to /.well-known/oauth-protected-resource/mcp.
  2. The host reads that document: the resource :8000/mcp, the authorization server :8400, and scopes_supported knowledge:read, tickets:read.
  3. The host reads the provider’s metadata at /.well-known/oauth-authorization-server: the authorize, token and register endpoints, the JWKS and the method S256.
  4. The host registers with POST /register and a redirect URI on 127.0.0.1, and gets a client_id.
  5. The host opens /authorize for the person, with the scopes, the resource and a PKCE code challenge. The person signs in; here they pick a made-up person.
  6. The provider redirects to the host’s redirect URI with a code. The host sends the code, the code verifier and the resource to POST /token, and gets an access token: Bearer, 600 seconds, audience :8000/mcp.
  7. The host sends POST /mcp with the token and X-Support-Tenant: larkfield. The server checks the token with the provider’s JWKS: issuer, audience equal to its own URL, expiry. Then it checks membership, scope and lookup, and answers 200.

A tiny example: PKCE with every value visible

Section titled “A tiny example: PKCE with every value visible”

The code from step 6 travels through the browser, in a redirect URL. Someone could copy it on the way. PKCE makes a copied code useless. Here is the example of RFC 7636, the standard that defines PKCE, with every value.

PKCE: prove that the code is yours (the example of RFC 7636). The code verifier, kept by the host: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk. An arrow through SHA-256, then base64url with no = at the end, to the code challenge, sent at the start: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. Step 1: /authorize with code_challenge=E9Melhoa… and code_challenge_method=S256; the provider keeps the challenge with the code. Step 2: POST /token with the code and code_verifier=dBjftJeZ…; the provider hashes the verifier and compares; the same value gives a token, and the code works once. Two red boxes: someone copies the code from the redirect but has no verifier: error invalid_grant, Invalid code verifier. The same code a second time: error invalid_grant, Unknown, used or expired code.
PKCE in two steps: the host sends a fingerprint first and the secret last. A copied code without the secret gets no token.
Text description of the diagram

The host makes a random code verifier and keeps it: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk. Its SHA-256 hash, written in base64url without the = at the end, is the code challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.

  1. The host sends the challenge to /authorize, with code_challenge_method=S256. The provider keeps it with the code.
  2. The host sends the code and the verifier to POST /token. The provider hashes the verifier and compares. The same value gives a token, and the code works once.

Someone who copies the code but has no verifier gets {"error": "invalid_grant", "error_description": "Invalid code verifier."}. The same code a second time gets "Unknown, used or expired code.". Both answers are real.

The hash goes one way only. Anyone can see the challenge, but nobody can compute the verifier from it. This is the phone-digits idea of the authentication course. You say the last four digits at the start, and only you can name the whole number at the end.

You run each step with curl against the practice provider. The provider has no passwords: the sign-in page lets you pick a made-up person. This is for learning only.

  1. Copy this lesson’s files, and run the tests:

    Terminal window
    S=../nextia-learning-labs/C28/snapshots/end-of-m05
    cp "$S/tests/test_idp.py" "$S/tests/test_oauth.py" tests/
    cp "$S/scripts/oauth_login.py" scripts/
    cp "$S/docs/remote-design.md" docs/
    python -m pytest -q
    .................................................................... [100%]
    68 passed in 11.39s

    In PowerShell, copy the same four files with Copy-Item, as in the earlier lessons.

  2. Read the server’s protected resource metadata:

    Terminal window
    curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp
    {"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["http://127.0.0.1:8400"],"scopes_supported":["knowledge:read","tickets:read"],"bearer_methods_supported":["header"]}

    resource is the server’s canonical URL: the audience that its tokens must name.

  3. Read the provider’s metadata:

    Terminal window
    curl -s http://127.0.0.1:8400/.well-known/oauth-authorization-server | python -m json.tool
    {
    "issuer": "http://127.0.0.1:8400",
    "authorization_endpoint": "http://127.0.0.1:8400/authorize",
    "token_endpoint": "http://127.0.0.1:8400/token",
    "registration_endpoint": "http://127.0.0.1:8400/register",
    "jwks_uri": "http://127.0.0.1:8400/jwks.json",
    "scopes_supported": [
    "knowledge:read",
    "tickets:read",
    "refunds:propose"
    ],
    "response_types_supported": [
    "code"
    ],
    "grant_types_supported": [
    "authorization_code"
    ],
    "code_challenge_methods_supported": [
    "S256"
    ],
    "token_endpoint_auth_methods_supported": [
    "none"
    ]
    }

    Note refunds:propose: the provider offers a write scope that the server does not advertise.

  4. Register a client, and keep its ID:

    Terminal window
    CLIENT=$(curl -s -X POST http://127.0.0.1:8400/register -H "Content-Type: application/json" -d '{"client_name": "my-host", "redirect_uris": ["http://127.0.0.1:8765/callback"]}' | python -c 'import json,sys; print(json.load(sys.stdin)["client_id"])')
    echo $CLIENT
    client-e3fd4aab3604

    The provider’s full answer is {"client_name":"my-host","redirect_uris":["http://127.0.0.1:8765/callback"],"client_id":"client-e3fd4aab3604","client_id_issued_at":…,"token_endpoint_auth_method":"none"}. none means a public client: it has no secret, so PKCE protects it. Your ID differs.

  5. Make a PKCE pair:

    Terminal window
    read VERIFIER CHALLENGE < <(python -c "import secrets,hashlib,base64; v=secrets.token_urlsafe(48); print(v, base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode())")
    echo ${#VERIFIER} ${#CHALLENGE}
    64 43

    The verifier has 64 characters; the provider accepts 43 to 128. The challenge always has 43.

  6. Build the sign-in address, and ask for a code as Sam:

    Terminal window
    AUTH="http://127.0.0.1:8400/authorize?response_type=code&client_id=$CLIENT&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=knowledge%3Aread%20tickets%3Aread&resource=http%3A%2F%2F127.0.0.1%3A8000%2Fmcp&code_challenge=$CHALLENGE&code_challenge_method=S256&state=xyz"
    curl -s -i "$AUTH&login_hint=usr-sam" | grep -i -E '^(HTTP|location)'
    HTTP/1.1 302 Found
    location: http://127.0.0.1:8765/callback?code=Lcr39eFHq1ki1M2RC9Uec02GBXCC0Xq_&state=xyz&iss=http%3A%2F%2F127.0.0.1%3A8400

    login_hint is a practice shortcut that skips the sign-in page. Open $AUTH without it in a browser to see the page: “Practice sign-in. Made-up people only. Pick one:”. Nothing listens on port 8765. So after you pick, a browser shows an error, with the code in its address bar (not tested by the course).

  7. Exchange the code for a token. Copy the code from the location line:

    Terminal window
    CODE=Lcr39eFHq1ki1M2RC9Uec02GBXCC0Xq_
    curl -s -X POST http://127.0.0.1:8400/token -d "grant_type=authorization_code&code=$CODE&client_id=$CLIENT&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&resource=http%3A%2F%2F127.0.0.1%3A8000%2Fmcp&code_verifier=$VERIFIER" > token.json
    python -c 'import json; t=json.load(open("token.json")); print(t["token_type"], t["expires_in"], t["scope"])'
    Bearer 600 knowledge:read tickets:read

    A code lives 60 seconds. If this step says "Unknown, used or expired code.", repeat step 6 and be faster.

  8. Use the token with the host:

    Terminal window
    SIGNED=$(python -c 'import json; print(json.load(open("token.json"))["access_token"])')
    python -m host "Draft a reply to ticket T-30002" --http http://127.0.0.1:8000/mcp --token "$SIGNED" --tenant larkfield
    [host] discovered 2 tools: search_knowledge, get_ticket {"protocol": "2026-07-28", "server": "support-mcp"}
    [model] wants get_ticket {"arguments": {"ticket_id": "T-30002"}}
    ...
    Ticket T-30002 (open): Charged twice for order LK-182074.

    Decode it as in Remote transport: aud is http://127.0.0.1:8000/mcp, from your resource parameter, and client_id is your registered client.

  9. Delete token.json. It holds a working token for ten minutes.

idp/app.py is about 140 lines of Starlette. The token endpoint shows the checks of steps 6 and 7 in order:

idp/app.py (token, the checks)
c = _codes.pop(form.get("code", ""), None) # a code works once
if c is None or c["expires"] < time.time():
return error("invalid_grant", "Unknown, used or expired code.")
if form.get("client_id") != c["client_id"] or form.get("redirect_uri") != c["redirect_uri"]:
return error("invalid_grant", "The code was issued to another client or redirect URI.")
verifier = form.get("code_verifier", "")
if not 43 <= len(verifier) <= 128:
return error("invalid_grant", "The code verifier must have 43 to 128 characters (RFC 7636).")
digest = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
if digest != c["challenge"]:
return error("invalid_grant", "Invalid code verifier.")

pop removes the code before any other check, so a wrong verifier also uses up the code. /authorize refuses a resource that the provider does not know (invalid_target), and any PKCE method except S256. /register accepts redirect URIs on 127.0.0.1 or localhost only. Signing uses PyJWT and cryptography: there is no hand-written cryptography. It is a simplified copy of the authentication course’s provider. Never use it for real accounts.

A real host does not run nine curl commands. The MCP Python SDK has an OAuth client, OAuthClientProvider, that does every step after a 401. scripts/oauth_login.py connects with it. It starts its own provider and server on free ports, so stop nothing:

Terminal window
python -m scripts.oauth_login
POST /mcp -> 401
GET /.well-known/oauth-protected-resource/mcp -> 200
GET /.well-known/oauth-authorization-server -> 200
POST /register -> 201
authorize: client client-e1177552f893, scope 'knowledge:read tickets:read', resource http://127.0.0.1:<server>/mcp, PKCE S256
callback: got an authorization code
POST /token -> 200
POST /mcp (with a token) -> 200
POST /mcp (with a token) -> 200
POST /mcp (with a token) -> 200
token: Bearer, expires in 600 s, scope 'knowledge:read tickets:read'
get_ticket T-30002 -> ok: Charged twice for order LK-182074

These are the steps of the flow diagram, in the same order. The script skips the sign-in page with login_hint; a real host opens the page in the person’s browser. The three POST /mcp with a token are server/discover, the tools/call, and a tools/list that the SDK sent by itself, to check the result against the tool’s output schema. The server’s log lines and the SDK’s HTTP Request: lines go to stderr.

A real surprise: the SDK asked for every scope

Section titled “A real surprise: the SDK asked for every scope”

The first run of this script, before the fix you see now, asked the provider for this:

&scope=knowledge%3Aread+tickets%3Aread+refunds%3Apropose

The script’s own client said scope="knowledge:read tickets:read". The SDK replaced it with all three scopes, including the write scope. A host that only reads would have held a token that can propose refunds.

Which scopes does the SDK's OAuth client ask for? (SDK 2.3.0). It takes the first list it finds, in this order, and replaces the client's own scope. 1. scope in the server's 401 answer, in WWW-Authenticate (none here). 2. scopes_supported in the server's protected resource metadata at /.well-known/oauth-protected-resource/mcp. 3. the provider's scopes_supported, the SDK's own fallback, at /.well-known/oauth-authorization-server. Before the fix, in red: the server advertised no scopes (no required_scopes), so the SDK used 3: scope=knowledge:read tickets:read refunds:propose. A token that can propose refunds, for an application that only reads. After the fix, in green, the project now: required_scopes = knowledge:read, tickets:read, so the resource metadata says scopes_supported [knowledge:read, tickets:read]; scope=knowledge:read tickets:read; the write scope is granted separately.
SDK 2.3.0 picks its scopes from the server first. A server that advertises nothing lets the client ask for everything the provider offers.
Text description of the diagram

SDK 2.3.0 takes the first list of scopes that it finds, in this order, and replaces the client’s own scope with it:

  1. a scope in the server’s 401 WWW-Authenticate header (none here);
  2. scopes_supported in the server’s protected resource metadata;
  3. scopes_supported in the provider’s metadata, the SDK’s own fallback.

Before the fix, the server advertised no scopes, so the SDK used the third list: knowledge:read tickets:read refunds:propose. After the fix, required_scopes lists the two read scopes, the resource metadata advertises only those, and the SDK asks for knowledge:read tickets:read. The write scope is granted separately.

The first two steps follow the MCP specification’s scope selection; the third is a fallback in the SDK’s code (get_client_metadata_scopes). The fix is on the server, because the server knows what it needs:

support_mcp/http.py
READ_SCOPES = ("knowledge:read", "tickets:read")
...
required_scopes=list(READ_SCOPES),

required_scopes does two things. The metadata advertises exactly these scopes, so a client asks for no more. And the SDK refuses a token without them with HTTP 403 insufficient_scope, before MCP. Asking for the least that works is least privilege. Credential boundaries adds the write scope as a separate grant.

The resource parameter is why the token’s aud is the server’s URL. The server’s check refuses everything else. Here is a real token that the provider made for another API, ticket-api, sent to this server:

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

The server’s log names the reason: "event": "token_refused", "reason": "wrong_audience". The rule also works the other way. This server never sends a person’s token on to another service, and another service must refuse it too. That rule has a name, no token passthrough, and Credential boundaries tests it.

Use it for any client that a person you do not control runs: a desktop assistant, a code editor, a partner’s app. They can find the provider, register and sign the person in without any setup from you. Use a simpler way only for tests and scripts on your own computer, as the course’s python -m idp token does, and label it.

The costs. The provider is a new service to run, and if it is down, nobody can sign in. Redirect URIs, the audience and the scopes are security settings: one mistake is a real bug. Open registration lets anyone create a client, so a real provider limits it. Tokens expire after minutes, and the practice provider issues no refresh token, so the person signs in again.

What the course did not test. A real identity provider such as Keycloak or Microsoft Entra ID, HTTPS, refresh tokens, and any desktop assistant or code editor as the client. A real provider may differ: some write typ: JWT instead of at+jwt, as the authentication course showed, and some do not support dynamic registration.

Common mistake

invalid_target: Unknown resource

Symptom: /authorize answers {"error":"invalid_target","error_description":"Unknown resource: 'https://api.example.com/tickets'."}.

Cause: the resource parameter names a server that the provider does not issue tokens for. The provider’s list is IDP_RESOURCES; by default http://127.0.0.1:8000/mcp and http://localhost:8000/mcp.

Fix: send the server’s exact URL from its protected resource metadata. If you moved the server to another port, add its URL to IDP_RESOURCES in the provider’s terminal, and restart the provider.

Common mistake

invalid_request: PKCE with S256

Symptom: /authorize answers {"error":"invalid_request","error_description":"This provider needs response_type=code and PKCE with S256."}.

Cause: the request used code_challenge_method=plain, or no challenge. The plain method sends the secret itself at the start, so it protects nothing.

Fix: send the SHA-256 challenge with code_challenge_method=S256, as in step 5.

Reset. If your project is broken, copy the whole snapshot end-of-m05, as in Where MCP fits. Delete .idp/ and run python -m idp init to make a new practice key.

Check your understanding

A new client knows only the server's URL. Name the two metadata documents it reads, and one fact it learns from each.

Show the answer

The server’s protected resource metadata, at /.well-known/oauth-protected-resource/mcp: the resource URL (the audience), the authorization server and the scopes to ask for. The provider’s authorization server metadata, at /.well-known/oauth-authorization-server: the authorize, token and register endpoints, the JWKS, and that PKCE must use S256. The 401’s resource_metadata header points to the first one.

Check your understanding

Predict: someone copies the code from the redirect URL and sends it to /token with a verifier of their own. What happens, and can the real host still use the code?

Show the answer

The provider answers invalid_grant, “Invalid code verifier.”: the SHA-256 of their verifier does not match the challenge. The provider removes the code before it checks the verifier, so the real host then gets “Unknown, used or expired code.” and must start again. Nobody gets a token from a copied code.

Check your understanding

Practical check: run python -m scripts.oauth_login. Which scope did the SDK ask for, and what makes it ask for exactly that?

Show the answer

scope 'knowledge:read tickets:read'. The server sets required_scopes to the two read scopes, so its protected resource metadata advertises only them. SDK 2.3.0 takes the resource metadata’s scopes_supported before the provider’s list. Without that setting, the SDK fell back to the provider’s list and asked for refunds:propose too.

This practice ends the module. First you read a worked example, then you change one condition yourself.

Open docs/remote-design.md. It is Amira’s remote deployment design for support-mcp. It has the table from Remote transport, the request flow of this lesson in five steps, and four decisions:

  • The organization comes from the connection, never from the model.
  • The role comes from the server’s membership table, never from the token.
  • The server never forwards the user’s token.
  • Production needs HTTPS, a real identity provider, a token lifetime of minutes, logs without tokens, and a rate limit per caller.

Her evidence is python -m scripts.attempts, with 48 attempts by synthetic identities: 15 allowed, 33 refused on the server, each with its check named. Read the design next to the table. Every decision in the design has at least one refused row as its proof.

Omar’s partner app needs only one thing: to search Larkfield’s policies. It must never read a ticket. Change one condition, the scopes, and work it out:

  1. Make a token with only the search scope, and predict what the server answers to a search: python -m idp token --user usr-omar --scope knowledge:read.
  2. Try it with the host and a policy question. Explain the answer from the code of http.py.
  3. Decide how the server must change so that the partner can search but not read tickets. Make the change in your project, and test both requests again.
  4. Run the tests. Explain each failure: a bug, or a test that pinned the old design?
  5. Add a section “Partner access” to docs/remote-design.md: the scopes, the evidence, and what remains open.
Show the worked solution

1 and 2. The project as it is refuses every request of that token before MCP. The host prints a long traceback that ends in MCPError: Server returned an error response. With curl, the answer is clearer:

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"

required_scopes lists both read scopes, so the SDK’s HTTP layer demands both for every request, a search too.

3. Each tool already checks its own scope: search_knowledge asks for knowledge:read, and get_ticket asks for tickets:read. So the HTTP layer only needs the scope that every request needs. In support_mcp/http.py:

READ_SCOPES = ("knowledge:read",)

Restart the server. The metadata now advertises one scope:

{"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["http://127.0.0.1:8400"],"scopes_supported":["knowledge:read"],"bearer_methods_supported":["header"]}

The partner’s search works, and the ticket is refused by the tool’s own check:

[server] result: 1078 characters {"ms": 196.8, "clipped": false}
...
From "Returns policy" (policy://larkfield/returns-policy): … ## Return window You can return an unused item …
[server] protocol error -32003: insufficient_scope: this needs the scope tickets:read {"code": -32003}
...
I could not use get_ticket: insufficient_scope: this needs the scope tickets:read

4. 3 failed, 65 passed. The three failures are test_a_token_without_the_read_scopes_is_refused_before_mcp, test_the_resource_metadata_advertises_only_the_read_scopes and test_the_sdk_client_signs_in_and_asks_only_for_the_advertised_scopes. They are not bugs: they pinned the old design on purpose. Update them to the new design, and add a test that a knowledge:read token gets −32003 for get_ticket. A changed security test is a decision: write it down.

5. A good section names the trade-off. The SDK’s client now asks only for knowledge:read. So the help desk’s host, which needs tickets, must ask for tickets:read itself, or the 401 must name the scope. Stopping a missing scope at the HTTP layer gave one clear 403. Now a ticket read without the scope is a −32003 inside MCP. Both are refusals on the server. The course team ran steps 1 to 4 exactly like this; your times differ.

Completion check: refuse on the server, not in the host

Section titled “Completion check: refuse on the server, not in the host”

Show that the server itself refuses three kinds of access, with no help from the host. With the provider and the server running, send each request with curl or the course’s host:

  1. no token at all;
  2. a token for another API: python -m idp token --user usr-sam --audience ticket-api;
  3. another organization’s data: Camille’s valid token with X-Support-Tenant: larkfield and ticket T-40003, and Ines’s valid token with X-Support-Tenant: larkfield.

For each one, write the status or error, and the server’s log line or the test that proves it.

Show the worked solution
  1. No token: HTTP/1.1 401 Unauthorized, www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="…". No MCP request reached a tool. Test: test_no_token_gets_401_and_where_to_sign_in.
  2. Wrong audience: the same 401, on purpose: the answer tells a stranger nothing. The server’s log says "event": "token_refused", "reason": "wrong_audience". Test: test_bad_tokens_get_401[wrong_audience].
  3. Another organization: Camille at Larkfield asking for T-40003 gets the tool error not_found: there is no ticket T-40003 in this organization; with X-Support-Tenant: bramble she gets the ticket. Ines at Larkfield gets −32003 forbidden: you have no access to this organization. Tests: test_a_cross_tenant_id_is_not_found and test_another_organization_is_refused_on_the_server.

Why “not in the host”? curl has no allow-list, no confirmation and no model. Every refusal above came from the server alone. python -m pytest -q tests/test_http.py prints 14 passed and repeats all of them.

Demonstrated means: all three refused, each with its evidence, and an explanation of why a host setting is not enough. Partially demonstrated: the refusals happen, but the evidence or the explanation is missing for one. Not yet demonstrated: any of the three requests succeeds.

Recap

  • Streamable HTTP serves the same tools at one URL. In revision 2026-07-28, each request stands alone, with no session. A remote server binds to 127.0.0.1 for practice and needs HTTPS beyond your computer.
  • Each request carries an access token. Its audience is the server’s own URL, so a token for another API is refused.
  • Authentication proves who; authorization decides what, on the server, for every request: token, membership, scope, role, organization-scoped lookup. The organization comes from the connection; the role from the server’s own table.
  • A new client gets a token the official way. It follows the 401 to the protected resource metadata and the provider’s metadata, registers, and runs the code flow with PKCE and the resource parameter.
  • Advertise only the scopes the client needs. SDK 2.3.0 otherwise asks for every scope the provider offers.

Your project now has 68 tests, the practice provider and docs/remote-design.md. In Module 6, the AI application meets untrusted text in tickets and tool results, and the server gains its first write.

Next: Untrusted inputs and outputs