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.

YOUR LAPTOP CLAUDE DESKTOP claude_desktop_config.json 1. starts the process: command + args JSON-RPC over stdin and stdout SERVER.PY a child process, stdio CLAUDE CODE .mcp.json 2. POST http://127.0.0.1:8000/mcp Authorization: Bearer 3f9c… SERVER.PY --HTTP 127.0.0.1:8000 CLAUDE DESKTOP custom connector you add the URL ANTHROPIC'S CLOUD 160.79.104.0/21 3. POST + OAuth token DEPLOYED SERVER mcp.kitebase.example 127.0.0.1:8000 nothing listening there "localhost" here is Anthropic's machine Routes 1 and 2 start on your laptop, so a local server works. Route 3 starts in Anthropic's cloud: the server needs a public HTTPS URL, and it has to check who is calling, because anyone can reach it.
Where each connection starts. Only route 3 needs a public URL.

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:

ScopeSaved inWho sees it
local (default)~/.claude.json, under this projectYou, in this project
project.mcp.json in the project rootEveryone who clones the repo
user~/.claude.json, top levelYou, 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, not 0.0.0.0. 127.0.0.1 accepts connections only from your own machine; 0.0.0.0 accepts them from your whole network, coffee-shop Wi-Fi included.
  • The SDK checks the Host header. Bound to localhost, it only answers requests addressed to 127.0.0.1 or localhost, and returns 421 Misdirected Request to 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 pass transport_security=TransportSecuritySettings(allowed_hosts=["mcp.kitebase.example"]) to mcp.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:

YOU a browser tab HOST Claude Code KITEBASE MCP mcp.kitebase.example AUTH SERVER auth.kitebase.example POST /mcp, no token 401, resource_metadata="…" GET /.well-known/ oauth-protected-resource/mcp authorization_servers: auth.kitebase.example GET /.well-known/oauth-authorization-server its authorize and token URLs opens sign-in page you sign in and allow kitebase:read redirect to localhost callback with ?code=… POST /token: code + code_verifier + resource access token, audience mcp.kitebase.example/mcp Authorization: Bearer eyJ… tools/call get_ticket 200, KITE-142: in_progress, priya FIND ISSUER OAUTH 2.1 + PKCE EVERY REQUEST The MCP server never sees your password. It only points to the auth server and checks each token: signed by that issuer, not expired, meant for this server. The SDK's AuthSettings serves the 401 and the metadata; your TokenVerifier does the check.
The MCP server only points to the auth server and checks tokens. The sign-in happens elsewhere.

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 resource parameter, and the server must reject tokens issued for anyone else.
  • No passing tokens along. If get_ticket calls 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, with error="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:

  1. Send the right token with the wrong Host header. You get 421 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'
    
  2. In TeamTokenVerifier, change scopes=[SCOPE] to scopes=[], restart the server, and send the same request without the Host header. The token is valid, but you get 403 with error="insufficient_scope".
  3. Copy host-configs/.mcp.json into a project, fix the paths, and run claude mcp list there. After you approve them, kitebase connects over stdio and kitebase-remote over 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 python in 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://localhost as 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.json with a literal Bearer 3f9c... is a leaked secret. Use ${KITEBASE_MCP_TOKEN}.
  • Binding to 0.0.0.0 on 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 command plus args. Use absolute paths and the venv’s Python, restart the host after changes, and put secrets in env.
  • Claude Desktop keeps local servers in claude_desktop_config.json. Claude Code uses claude mcp add with a scope, and .mcp.json for the team.
  • Streamable HTTP is the same server as a web service at one /mcp URL. Bind to 127.0.0.1 locally, 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 401 and 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

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.


Auto-marks when you reach the end. Click to toggle.