Connecting to Claude Desktop, Cursor, Etc.
The Kitebase server from article 01: What Is MCP? works. Your test client calls get_ticket and gets KITE-142 back. Now three people want it. The support lead wants it in Claude Desktop. An engineer wants it in Claude Code. And the eight-person support team wants it without each of them cloning a repo and building a Python environment.
The first two are a few lines of config that tell a host how to start your server. The third means running the server somewhere everyone can reach it, over HTTP. And once it’s on a URL, anyone who finds the URL can read your customers’ tickets, so it needs a login too.
What you’ll build: the Kitebase server from article 01, runnable two ways: over stdio for Claude Desktop and Claude Code, and over Streamable HTTP at http://127.0.0.1:8000/mcp behind a bearer token. A small script connects to it the three ways a host does and shows the 401 a host gets before it has a token. It runs offline.
Two ways a host reaches a server
A host is the AI app you talk to, like Claude Desktop or Claude Code. It reaches an MCP server over one of two transports (the channel the messages travel on):
- stdio: the host starts your server as a child process and talks to it through its standard input and output. The server runs on the same machine and lives as long as the host keeps it. No port, no URL, no login: only the host holds the pipe.
- Streamable HTTP: your server is a web service that’s already running. The host sends each message as an HTTP POST to one URL, usually ending in
/mcp. Anyone who can reach that URL can talk to it.
Article 02: MCP Architecture shows the real messages on both; the JSON is the same. This article is about the part that trips people up: where each host keeps its config, and who actually opens the connection.
The rest of the article follows those three routes.
Route 1: Claude Desktop, over stdio
Claude Desktop (macOS and Windows) reads its local servers from claude_desktop_config.json. Open it from the app: Settings, then the Developer tab, then Edit Config. The file lives at:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add Kitebase under mcpServers. The key, kitebase, is a name you pick for the app’s menus and log files:
{
"mcpServers": {
"kitebase": {
"command": "/Users/you/07-connecting-to-hosts/.venv/bin/python",
"args": ["/Users/you/07-connecting-to-hosts/server.py"]
}
}
}
The host runs command with args, exactly as written. Two rules follow.
Use absolute paths, and the venv’s Python. Claude Desktop isn’t started from your terminal, so it isn’t in your project folder, and its python may be a system Python without mcp. A virtual environment (venv) is a private copy of Python with its own packages; .venv/bin/python is the one that has them.
Quit and restart the app after every change. It reads the config and starts the server once, at launch. Closing the window isn’t enough.
To check it loaded, click the “Add files, connectors, and more” button at the bottom left of the message box, then Connectors, then Manage connectors: kitebase should be listed. Menus move between versions; the reliable check is the logs, in ~/Library/Logs/Claude/ on macOS or %APPDATA%\Claude\logs on Windows. mcp-server-kitebase.log holds your server’s stderr, where a startup crash shows up.
Now ask “What’s going on with KITE-142?” Claude asks to run get_ticket, you allow it, and the answer comes from your server: in progress, assigned to Priya.
If the server needs a secret, like a Kitebase API key, put it in an env block next to command and read it with os.environ:
"env": { "KITEBASE_API_KEY": "kb_live_..." }
That’s what the MCP spec asks for: stdio servers get credentials from the environment, not from a login flow. Don’t rely on variables exported in your shell profile; Claude Desktop never runs your shell.
Route 1, again: Claude Code, over stdio
Claude Code, the terminal coding agent, adds servers with one command. Everything after -- is the command to run:
claude mcp add kitebase -- /Users/you/07-connecting-to-hosts/.venv/bin/python /Users/you/07-connecting-to-hosts/server.py
Options go before the name. --scope decides where the config is saved and who gets it:
| Scope | Saved in | Who sees it |
|---|---|---|
local (default) | ~/.claude.json, under this project | You, in this project |
project | .mcp.json in the project root | Everyone who clones the repo |
user | ~/.claude.json, top level | You, in every project |
A .mcp.json has the same mcpServers shape as Claude Desktop’s file, plus a type:
{
"mcpServers": {
"kitebase": {
"type": "stdio",
"command": "/Users/you/07-connecting-to-hosts/.venv/bin/python",
"args": ["/Users/you/07-connecting-to-hosts/server.py"]
}
}
}
Claude Code asks you to approve servers from a project’s .mcp.json before it starts them, since anyone with commit access could have added one. claude mcp list shows each server and whether it connected, and /mcp inside a session shows the same. The tools appear with the server name in front, like mcp__kitebase__get_ticket.
Absolute paths in a committed .mcp.json only work on the machine that wrote them; for a whole team, one shared URL beats everyone fixing paths. That’s route 2. For stdio first-run problems, see article 03: Setting Up Your First MCP Server.
Route 2: the same server over Streamable HTTP
Now the server runs on its own and the host connects to it. The Kitebase server does both from one file:
if __name__ == "__main__":
if "--http" in sys.argv:
if not os.environ.get("KITEBASE_MCP_TOKEN"):
sys.exit("Set KITEBASE_MCP_TOKEN first, e.g. export KITEBASE_MCP_TOKEN=$(openssl rand -hex 32)")
mcp.run(transport="streamable-http", host=HOST, port=PORT)
else:
mcp.run() # stdio
mcp.run(transport="streamable-http") starts a web server with one endpoint at /mcp. HOST defaults to 127.0.0.1 and PORT to 8000. Start it:
export KITEBASE_MCP_TOKEN=$(openssl rand -hex 32)
python server.py --http
openssl rand -hex 32 prints 64 random hex characters, a secret nobody will guess. The server refuses to start without one, so it can’t serve tickets to anyone by accident. The tools don’t change: get_ticket has no idea which transport called it.
Two details matter even on your laptop:
- Bind to
127.0.0.1, not0.0.0.0.127.0.0.1accepts connections only from your own machine;0.0.0.0accepts them from your whole network, coffee-shop Wi-Fi included. - The SDK checks the
Hostheader. Bound to localhost, it only answers requests addressed to127.0.0.1orlocalhost, and returns421 Misdirected Requestto anything else. That blocks DNS rebinding, where a web page tricks your browser into calling a server on your laptop. Behind a proxy on a real domain, you passtransport_security=TransportSecuritySettings(allowed_hosts=["mcp.kitebase.example"])tomcp.run.
Connect Claude Code to it
In another terminal with the same KITEBASE_MCP_TOKEN:
claude mcp add --transport http kitebase-remote http://127.0.0.1:8000/mcp \
--header "Authorization: Bearer $KITEBASE_MCP_TOKEN"
Authorization: Bearer <token> is the standard header for a bearer token: whoever holds (“bears”) it gets in, like a key.
The gotcha: your shell expands $KITEBASE_MCP_TOKEN before Claude Code runs, so the literal secret lands in ~/.claude.json. That’s tolerable in your home folder. For a .mcp.json you commit, write the entry by hand and let Claude Code fill the token in when it connects:
"kitebase-remote": {
"type": "http",
"url": "${KITEBASE_MCP_URL:-http://127.0.0.1:8000/mcp}",
"headers": { "Authorization": "Bearer ${KITEBASE_MCP_TOKEN}" }
}
Claude Code expands ${VAR} in url, headers, command, args and env, and ${VAR:-default} uses the default when the variable isn’t set. The file holds no secret; each teammate sets the variable in their own environment.
main.py connects the same way. The token goes on the HTTP client, and the SDK’s Client does the rest:
async def over_http(url: str, token: str):
async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as http:
async with Client(streamable_http_client(url, http_client=http)) as client:
return await ask_kitebase(client) # list_tools, then get_ticket KITE-142
(httpx2 is the HTTP library the mcp SDK already depends on.) Every POST then carries the token next to the MCP headers from article 02:
POST /mcp
authorization: Bearer 3f9c...
mcp-protocol-version: 2026-07-28
mcp-method: tools/call
mcp-name: get_ticket
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_ticket","arguments":{"ticket_id":"KITE-142"},"_meta":{...}}}
The token goes in a header, never in the URL. The spec forbids tokens in the query string, because URLs end up in server logs, proxy logs and browser history.
Route 3: Claude Desktop and a remote server
Claude Desktop doesn’t take remote servers in claude_desktop_config.json. You add them as a custom connector: open the Connectors page (Customize, then Connectors, at the time of writing), click “Add custom connector”, and paste the URL. On Free, Pro and Max you add it for yourself (Free allows one); on Team and Enterprise an Owner adds it for the organization and each member connects with their own account.
The catch is in the diagram: Claude Desktop doesn’t make that connection. Anthropic’s cloud does, from the 160.79.104.0/21 range, for Claude on the web, Desktop and mobile alike. So http://127.0.0.1:8000/mcp fails; “localhost” there is Anthropic’s machine. A connector needs a public HTTPS URL, like https://mcp.kitebase.example/mcp.
The dialog offers three ways in: sign in with OAuth, no sign-in, or request headers, a fixed API key entered once. Request headers are a beta for a limited set of organizations at the time of writing, so the shared token that works in Claude Code may not work here. For a team on Claude Desktop, plan on OAuth.
Authentication for remote servers
Whoever can reach your URL can call get_ticket, and KITE-142 names a real customer. A remote server has to ask on every request: who is this, and may they see it?
The MCP spec makes authorization optional, and defines it for HTTP only. Its model is OAuth 2.1, the standard behind “Sign in with Google” buttons, with three roles:
- The MCP server is a resource server: it holds the data and accepts requests that carry a valid access token (a short-lived string that proves who’s calling and what they may do).
- The authorization server signs people in and issues tokens: usually your company’s identity provider (Okta, Auth0, Keycloak, Entra ID), not code you write.
- The host is the OAuth client: it gets a token for you and sends it as
Authorization: Bearer ...on every request.
There are two sensible levels. A shared bearer token, like the example’s KITEBASE_MCP_TOKEN, is simple and works in Claude Code and Cursor. But you can’t tell users apart, and taking access away from one person means rotating the token for everyone. OAuth per user fixes both, and it’s what Claude Desktop’s connectors expect.
The MCP-specific part of OAuth is how the host finds out where to sign in. main.py shows the first step:
no token: HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:50680/.well-known/oauth-protected-resource/mcp"
GET http://127.0.0.1:50680/.well-known/oauth-protected-resource/mcp
{"resource":"http://127.0.0.1:50680/mcp","authorization_servers":["https://auth.kitebase.example"],"scopes_supported":["kitebase:read"],"bearer_methods_supported":["header"]}
The 401 points to the protected resource metadata, a small JSON document every protected MCP server must publish. It names the server (resource), who issues its tokens (authorization_servers), and the scopes it wants (named permissions, like kitebase:read). From there the host does ordinary OAuth:
A few rules in that flow come from the MCP spec, not plain OAuth:
- PKCE is required. The host makes a random secret (the
code_verifier), sends only its hash when you sign in, and proves it has the original when it trades the code for a token. A stolen code alone is useless. - Tokens are bound to one server. The host names the MCP server’s URL in a
resourceparameter, and the server must reject tokens issued for anyone else. - No passing tokens along. If
get_ticketcalls Kitebase’s REST API, it uses the server’s own credentials. Forwarding the user’s MCP token is forbidden. - The host needs a client ID. The spec prefers a Client ID Metadata Document, a client ID that’s a URL to a JSON file describing the host, so the authorization server needs no setup per host. Pre-registering works too. Dynamic Client Registration is deprecated in
2026-07-28. - Missing permission is a
403, witherror="insufficient_scope"and the scope needed, so the host can ask you to approve more.
What the server code does
The SDK handles the HTTP side. You give MCPServer an AuthSettings, which says who issues tokens and which scope to require, and a token verifier, which checks each token:
class TeamTokenVerifier:
"""Accepts one shared team token. Swap this for JWT checks against your identity provider."""
async def verify_token(self, token: str) -> AccessToken | None:
expected = os.environ.get("KITEBASE_MCP_TOKEN", "")
if not expected or not hmac.compare_digest(token, expected):
return None
return AccessToken(token=token, client_id="kitebase-team", scopes=[SCOPE], resource=PUBLIC_URL)
mcp = MCPServer(
"kitebase",
token_verifier=TeamTokenVerifier(),
auth=AuthSettings(
issuer_url="https://auth.kitebase.example",
resource_server_url=PUBLIC_URL, # http://127.0.0.1:8000/mcp locally
required_scopes=[SCOPE], # "kitebase:read"
validate_token_resource=True, # reject tokens issued for another server
),
)
Now the SDK answers a missing or bad token with the 401 above, serves the metadata, and returns 403 when a token lacks kitebase:read. Over stdio these settings are ignored. hmac.compare_digest takes the same time wherever the first wrong character is, so nobody can recover the token by timing rejections.
This verifier accepts one shared token. For OAuth, verify_token checks a real access token instead, usually a JWT (a signed JSON token). A JWT library checks its signature against the authorization server’s public keys, its expiry and its audience. PUBLIC_URL must be the exact URL clients use, /mcp included; Claude compares it character for character with the URL the user entered.
What the hosts do
The host runs the sign-in. In Claude Code, a server that answers 401 shows as needing authentication in /mcp; pick it there, or run claude mcp login kitebase-remote, and a browser tab opens. Claude Code stores the token (in the keychain on macOS) and refreshes it. In Claude Desktop, you click Connect on the connector.
Your authorization server must accept both hosts’ redirect URIs, where it sends you after sign-in: https://claude.ai/api/mcp/auth_callback for Claude’s apps, and http://localhost:<port>/callback for Claude Code, on a port that changes each time.
Other hosts
Most hosts copied the mcpServers shape. Cursor reads .cursor/mcp.json in a project or ~/.cursor/mcp.json globally, with command and args for stdio or url and headers for HTTP. But it spells variables ${env:KITEBASE_MCP_TOKEN}. Copy a Claude Code config into Cursor and the header goes out as the literal text Bearer ${KITEBASE_MCP_TOKEN}: a 401. Check each host’s variable syntax.
Try it yourself
The companion example is the Kitebase server with both transports and auth, main.py, which connects the three ways a host does, and example configs in host-configs/.
Download the runnable example (zip)
cd 07-connecting-to-hosts
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python main.py
It starts the HTTP server on a free port and stops it at the end:
stdio: protocol 2026-07-28, tools ['search_help', 'get_ticket']
get_ticket KITE-142 -> in_progress, assigned to priya
HTTP server at http://127.0.0.1:50680/mcp
no token: HTTP 401
WWW-Authenticate: Bearer error="invalid_token", ... resource_metadata="http://127.0.0.1:50680/.well-known/oauth-protected-resource/mcp"
...
with token: protocol 2026-07-28, tools ['search_help', 'get_ticket']
get_ticket KITE-142 -> in_progress, assigned to priya
Then start the server yourself with export KITEBASE_MCP_TOKEN=$(openssl rand -hex 32) and python server.py --http, and try these in a second terminal with the same token:
- Send the right token with the wrong
Hostheader. You get421 Misdirected Request, as you would behind a proxy before allowing your domain:curl -i http://127.0.0.1:8000/mcp -H "Authorization: Bearer $KITEBASE_MCP_TOKEN" \ -H 'Content-Type: application/json' -d '{}' -H 'Host: mcp.kitebase.example' - In
TeamTokenVerifier, changescopes=[SCOPE]toscopes=[], restart the server, and send the same request without theHostheader. The token is valid, but you get403witherror="insufficient_scope". - Copy
host-configs/.mcp.jsoninto a project, fix the paths, and runclaude mcp listthere. After you approve them,kitebaseconnects over stdio andkitebase-remoteover HTTP.
pip install pytest && pytest -q runs the tests. They start the HTTP server on a free local port and check the 401, the metadata, the 421, and that the configs keep the token out of the file.
Common beginner mistakes
- Relative paths or plain
pythonin a stdio config. The host doesn’t start in your project folder. Use absolute paths and the venv’s Python. - Editing the config without restarting. Claude Desktop reads it once, at launch.
- Adding
http://localhostas a Claude Desktop connector. The connection comes from Anthropic’s cloud. Use Claude Code for local HTTP servers, and a public HTTPS URL for connectors. - Committing a token. A
.mcp.jsonwith a literalBearer 3f9c...is a leaked secret. Use${KITEBASE_MCP_TOKEN}. - Binding to
0.0.0.0on your laptop. Your whole network can reach the server, and the SDK stops turning on its Host check by itself.
Questions you will face in production
“Shared token or OAuth?”
A shared token for an internal tool a few people use from Claude Code or Cursor, rotated when someone leaves. OAuth once users need their own permissions, you need to know who did what, or people connect from Claude Desktop. Don’t write the authorization server yourself: point issuer_url at the identity provider your company already uses and verify its tokens.
“Where do I run the HTTP server?”
Anywhere that runs a Python web process: a container, a VM, a platform like Cloud Run. Put HTTPS in front, set HOST=0.0.0.0 in the container, KITEBASE_MCP_URL to the public URL, and transport_security with your domain, since the SDK only turns the Host check on by itself for localhost. The 2026-07-28 revision has no sessions, so several copies can sit behind a load balancer (article 02 explains why).
“It’s only reachable on our VPN. Do we still need auth?” Yes. The VPN keeps strangers out, but everyone inside could still read every customer’s tickets, and you couldn’t tell who did. Use at least a shared token, and per-user OAuth if the data is sensitive.
Check your understanding
A teammate adds http://127.0.0.1:8000/mcp as a custom connector in Claude Desktop, and it fails, though Claude Code on the same laptop connects fine. Why?
Claude Code connects from the laptop, so 127.0.0.1 is the laptop. A custom connector connects from Anthropic’s cloud, where 127.0.0.1 is Anthropic’s own machine. Connectors need a public HTTPS URL; for local testing, use Claude Code.
A pull request adds .mcp.json with "Authorization": "Bearer 3f9c81...". What do you ask for?
Rotate that token now: it’s in the git history. Then change the value to Bearer ${KITEBASE_MCP_TOKEN} so Claude Code fills it in from each person’s environment.
You deploy behind nginx at https://mcp.kitebase.example with HOST left at 127.0.0.1. Every request with a valid token gets 421. What happened?
Bound to localhost, the SDK turned on DNS rebinding protection, which only accepts Host: 127.0.0.1 or localhost. nginx passes Host: mcp.kitebase.example. Pass transport_security=TransportSecuritySettings(allowed_hosts=["mcp.kitebase.example"]) to mcp.run, and set KITEBASE_MCP_URL so the metadata advertises the public URL.
Your OAuth-protected server calls Kitebase's REST API inside get_ticket. Can it send the user's MCP access token to that API?
No. That token was issued for the MCP server, and the spec forbids passing it along. The server uses its own credentials for the Kitebase API, from its environment, and uses the token only to know who’s asking and what they may see.
What to remember
- stdio: the host starts your server from
commandplusargs. Use absolute paths and the venv’s Python, restart the host after changes, and put secrets inenv. - Claude Desktop keeps local servers in
claude_desktop_config.json. Claude Code usesclaude mcp addwith a scope, and.mcp.jsonfor the team. - Streamable HTTP is the same server as a web service at one
/mcpURL. Bind to127.0.0.1locally, and allow your domain when you deploy. - Claude Desktop’s remote connectors connect from Anthropic’s cloud, so they need a public HTTPS URL.
- Auth is for HTTP. A shared bearer token is the simple start; the spec’s OAuth 2.1 flow gives each user their own token, found through the
401and the protected resource metadata. - Keep tokens in headers and environment variables, never in URLs or committed files.
What to study next
Your server now connects to the hosts you use. Next is getting it onto other people’s machines without them cloning your repo and fixing paths: article 08: Packaging and Distributing turns it into something a stranger installs with one command.
Further reading
- MCP spec: Authorization. The OAuth 2.1 profile described above: protected resource metadata, client registration, the
resourceparameter and scope challenges. - MCP spec: Streamable HTTP. The transport, including the security rules for
Originand binding to localhost. - Claude Code: Connect to tools via MCP.
claude mcp add, scopes,.mcp.json, variable expansion and OAuth. - Connect to local MCP servers. Claude Desktop’s config file and log locations.
- Add a connector that isn’t in the directory. Custom connectors in Claude, by plan, and the sign-in options.
- Authentication for connectors. What Claude’s OAuth client expects from your server: the
401, callback URLs, token refresh and Anthropic’s IP range. - Cursor: Model Context Protocol. Cursor’s
mcp.json, project and global, and its variable syntax.
Where this article comes from. This is a synthesis of the MCP specification and common practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.