Key takeaways
- ✓A first connection that fails with 401 is the server working: the WWW-Authenticate header points to the protected-resource metadata, which names the authorization server.
- ✓The demo's authorization server has no authorization_endpoint and only the client_credentials grant, so the Inspector can't offer a browser sign-in: get a token for a registered client yourself.
- ✓Anyone can decode a token. Read its claims to understand it (org_id, scope, aud, exp); trust comes from the server checking the signature, issuer, audience and expiry on every call.
- ✓The tools tab shows exactly what a model sees: names, descriptions that say when to use a tool, input schemas, and hints like readOnly and destructive.
- ✓A green connection isn't evidence of isolation. Check the records a call returns, and repeat with another tenant's token: Dana is 0 matches for acme and c_201 for globex.
MCP Inspector is the tool for exploring an MCP server by hand: connect, list its tools, call them, and watch every message. Against a server with real authorization, the first connection fails with a 401, and that's where understanding the server starts. This chapter connects Inspector to the running acme-crm-mcp demo: following the 401 to a token, reading the token, the handshake, the tools tab, and checking that a successful call returned the right tenant's data.
01 · Configure
Add server · Streamable HTTP · http://localhost:8787/mcp
transport: streamable-http url: http://localhost:8787/mcp # the Inspector itself runs in the browser, behind its own local session token
Streamable HTTP is for remote servers; stdio is for local processes. Every MCP request goes to this one URL.
Run it yourself
The acme-crm-mcp demo needs Docker and Node 20+:
npm install
npm run db:up # Postgres on :5433 with the schema and demo data
npm run dev # authorization server on :8788, MCP server on :8787/mcp
npm run inspect # the MCP Inspector web UI (a pinned version)
In the Inspector, add a server manually: any name, transport Streamable HTTP (stdio is for local processes), URL http://localhost:8787/mcp. The Inspector itself runs in your browser behind its own local session token.
Demo data: tenant acme has Priya Shah, Marcus Lee and Sofia Alvarez; tenant globex has Dana Kim (c_201) and Tom Becker. npm run db:reset puts it back at any time.
Following the 401
The first connection fails, and that's the server working. Three requests happen before you can do anything, all visible in the Inspector's network log:
| Request | Answer | What it tells the client |
|---|---|---|
POST :8787/mcp |
401 · WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" |
No token, and where to look next |
GET :8787/.well-known/oauth-protected-resource |
authorization_servers: ["http://localhost:8788"], scopes_supported: ["crm:read", "crm:write"] |
Which authorization server issues tokens for this resource |
GET :8788/.well-known/oauth-authorization-server |
token_endpoint, jwks_uri, grant_types_supported: ["client_credentials"], response_types_supported: [] |
How to get a token, and the public keys that verify it |
Why there's no sign-in. A browser sign-in needs an authorization_endpoint and the code response type. The demo's authorization server has neither: it's a small mock that issues tokens only to registered clients, with the client-credentials grant. So the Inspector stops there, and you get a token yourself. A production authorization server (Auth0, Okta, Keycloak) adds the authorization-code flow with PKCE, and the Inspector would open a sign-in page instead.
Getting and reading a token
export TOKEN=$(npm run -s token) # registered client acme-agent
npm run decode -- $TOKEN
| Claim | Value | Meaning |
|---|---|---|
org_id |
acme |
The tenant: the server takes it from here, never from tool arguments |
scope |
crm:read crm:write |
What the token may do; each tool needs one |
aud |
http://localhost:8787/mcp |
This exact server, nothing else |
iss |
http://localhost:8788 |
Who issued it |
exp |
issued-at + 3600 | It expires in an hour |
header alg, kid |
RS256, demo-key-1 |
Which key signed it, to look up in jwks.json |
Decoding isn't verifying. The claims are base64url, readable by anyone, and a forged token decodes just as well. What matters is the signature, which the server checks on every call along with the issuer, audience and expiry.
In the Inspector, open the server's settings and add one custom header, Authorization: Bearer <token>, then reconnect. That's exactly how any MCP client presents its token: the same header, on every request.
Other tokens to try, for the next chapters:
npm run -s token -- acme-readonly # crm:read only
npm run -s token -- globex-agent # the other tenant
npm run -s token -- acme-agent --expired # test-only: expired an hour ago
npm run -s token -- acme-agent --audience https://other # test-only: issued for another server
The two test-only tokens are minted locally with the demo's signing key, not by the authorization server, to show the MCP server rejecting them.
The handshake, and the protocol version
Once connected, the Inspector's protocol log shows:
| Message | Answer |
|---|---|
initialize |
Protocol version and capabilities agreed |
notifications/initialized |
Accepted (202) |
tools/list |
The three tools |
GET /mcp, for a notification stream |
405, Allow: POST |
The 405 isn't a failure: the server is stateless on purpose, builds a fresh MCP server for every request, and only accepts POST, so any instance can answer any request.
This is the handshake-era flow. The demo uses the MCP TypeScript SDK 1.32. The 2026-07-28 specification retired the initialize / initialized exchange and the Mcp-Session-Id header: each request now carries its own protocol version, client identity and capabilities in _meta, and an optional server/discover call covers capability discovery. Check which version your client and server speak before comparing a trace with this one (release notes).
What a model sees: the tools tab
| Tool | Hints | Input | Description |
|---|---|---|---|
search_contacts |
readOnlyHint |
query, required |
Find contacts by name, email, or company. Call this before create_contact to avoid duplicates. |
create_contact |
not read-only, not destructive, not idempotent | name required; email (must be an email), company |
Create a contact. Only call this after search_contacts finds no match; it does not merge duplicates. |
delete_contact |
destructiveHint, idempotentHint |
id, required |
Permanently delete a contact by id. Confirm with the user first. |
Read the descriptions as instructions to a model: they say when to use a tool, not just what it does. The hints are how clients decide when to ask before acting. Neither is enforcement: scopes and row-level security are (Chapter 1).
Verify the result, not just the connection
| Call (acme token) | Result |
|---|---|
search_contacts {"query": "Priya"} |
1 match: Priya Shah, Initech (c_101), straight out of acme's rows |
search_contacts {"query": "Dana"} |
0 matches: Dana belongs to globex, and with acme's token globex's data doesn't exist |
A green connection proves the token works. It doesn't prove the data is right. For every call, look at the records that came back and check they belong to the token's tenant, then repeat with another tenant's token: with globex-agent, the same Dana search returns c_201. If the result doesn't change with identity, something is wrong.
Beyond clicking
Every check above can run without the browser: npm run smoke uses the Inspector's CLI mode for five real calls, checked by exit code (0 ok, 3 authentication required, 5 tool error). Chapter 6 builds on that.
Practice: what the Inspector did for you
Do the Inspector's discovery and token reading in code: pull the metadata URL out of a 401, decide from the authorization-server metadata how a client can get a token, decode a token's claims (and see that a forged one decodes just as well), and list why a token won't work for a given call. The tests use the demo's real responses.
Quiz
1.The Inspector's first connection to localhost:8787/mcp fails with 401. What does that tell you?
2.Why did the Inspector find no way to sign in?
3.You decode a token and see org_id: acme. What have you proven?
4.After connecting, the Inspector's GET for a notification stream gets 405. Is something wrong?
5.search_contacts for "Dana" with acme's token returns 0 matches, though Dana Kim is in the database. What should you do?
6.Recordings and current traces disagree: one shows initialize / initialized, the other doesn't. Why?
Check your understanding
Try each question before opening its answer.
What does the first 401 help the MCP client discover?
The WWW-Authenticate header points to the protected-resource metadata, which names the authorization server; that server's metadata gives the token endpoint, the grant types and the signing keys.
Why can't the Inspector sign you in to the demo?
Its authorization server has no authorization_endpoint and supports only client_credentials: it issues tokens to registered clients, not browser users. Get a token with npm run token and add it as a header.
What can you learn by decoding a token, and what can't you?
You can read the tenant (org_id), scopes, audience, issuer and expiry. You can't learn whether it's genuine: a forged token decodes the same. The server's signature check decides that.
Is the 405 on GET /mcp a problem?
No. The server is stateless and only accepts POST. The handshake and tools/list succeeded; the 405 is only the optional notification stream.
Why don't current MCP traces show initialize / initialized?
The 2026-07-28 specification retired that exchange and protocol-level sessions; each request carries its protocol version and client info, with an optional server/discover. The demo still uses the handshake-era SDK.
What should you verify after a tool call succeeds?
The records themselves: they belong to the token's tenant. Then repeat with another tenant's token and check the result changes: Dana is 0 matches for acme and c_201 for globex.
Next up: Chapter 4 switches tokens on purpose: a read-only token on a write, another tenant's token, an expired one, and what each must return.