DEV Community

Cover image for Local vs remote MCP servers: how each connects, step by step
Dave
Dave

Posted on Originally published at quirkyagents.com

Local vs remote MCP servers: how each connects, step by step

An MCP server reaches your AI app in one of two ways. A local server is a program on your own machine. A remote server lives at a URL and signs you in with OAuth. I wanted to know exactly what happens in each case, so I tested it: a local server (doceval's) started by Claude Code, and the three remote servers I use (Upwork, Notion and Todoist). Where something comes from the MCP spec instead of a test, I link the spec.

For what MCP is and how one tool call works, see my previous post.

Local: a child process on two pipes

The app starts the server as a program on your machine and talks to it through two pipes: it writes requests into the server's input and reads replies from its output. I ran doceval's server under Claude Code (claude -p with a test config) and checked the process list every quarter of a second:

A local MCP server's life: started once when the session starts, reused for every call, stopped when the session ends, and gone within two seconds after a crash

  1. Session starts. Claude Code starts the server as its own child process, once, even when the prompt uses no tools.
  2. Every call reuses it. A session that called the tool twice started the server once.
  3. Session ends. Claude stops the server before exiting itself.
  4. Crash. After kill -9 on Claude, which allows no clean-up, the server was gone within two seconds. Its input pipe closed, and it read that as "the session is over".

Each session starts its own copy, so three open sessions run three copies of every local server.

Credentials come from the environment. The MCP spec says local (stdio) servers should take credentials from the environment instead of using OAuth. In practice that's the env block of the app's MCP config, a plain-text file. Claude Code fills in ${VAR} from your shell: a config with "SECRET": "${MY_TEST_SECRET}" handed the server my shell's value, so the secret doesn't have to sit in the file.

Remote: five parties

Who In my case
You the person signing in
Browser where you type your password
Client Claude Code
MCP server mcp.upwork.com/mcp: runs the tools, checks tokens
Sign-in server issues tokens. The spec calls it the authorization server

The MCP server and the sign-in server can belong to the same company and still be separate roles. Todoist's MCP server lives at ai.todoist.net, and its sign-in server at todoist.com.

Remote: the first connection, step by step

The remote MCP sign-in flow in nine steps across Claude Code, the MCP server, the sign-in server and your browser

1. Claude calls the server with no token, and gets a 401. I sent all three servers a request with no token. Each one answered 401 Unauthorized, with a www-authenticate header pointing to a public file. Upwork's points to mcp.upwork.com/.well-known/oauth-protected-resource/mcp.

2. That file names the sign-in server. For Upwork it's https://mcp.upwork.com, and for Todoist it's https://todoist.com.

3. The sign-in server's own public file lists its addresses. These are where you log in and where codes get swapped for tokens. Upwork's login page and token address are on www.upwork.com. All three list PKCE with S256 (more in step 5).

4. Claude identifies itself with a URL. In my stored credentials, the app ID Claude used with all three servers is the same: https://claude.ai/oauth/claude-code-client-metadata. That's a public description of the Claude Code app, which the sign-in server fetches. The spec prefers this method and marks the older automatic registration as deprecated. The description says "token_endpoint_auth_method": "none": Claude Code has no app secret, and every install shares one public ID.

5. Claude makes a one-time secret: PKCE. With no app secret, something else has to prove that the app finishing the login is the one that started it. Claude makes a random verifier, keeps it, and puts only its hash (the challenge) into the login link.

6. You log in on the service's own page. Claude opens your browser at the sign-in server's login page. Per the spec, the link carries the app ID, the return address (Claude's description lists http://localhost/callback), the challenge, and which server the token is for. Your password goes only to the service. Claude never sees it.

7. A one-time code comes back through your browser. After you approve, the sign-in server sends your browser to Claude's local return address with a short-lived, single-use code. The spec also requires the client to check that the response came from the sign-in server it expected.

8. Claude swaps the code for tokens. It sends the code plus the verifier from step 5. The sign-in server hashes the verifier and compares it with the challenge from step 6. Only a match gets tokens, so a stolen code is useless without the verifier. Back come an access token (short-lived, used on every request) and a refresh token (used only to get new access tokens).

9. Claude stores both. On my machine they're in ~/.claude/.credentials.json, an owner-only file, with fields for each server: accessToken, refreshToken, expiresAt, clientId, issuer. My MCP config file holds only each server's type and URL. The tokens are still on disk, readable by anything running as me, but they expire, work for one server, can be revoked, and never contained my password.

Remote: every request after that

The token travels on every request, as Authorization: Bearer <access token>. The spec requires it on every HTTP request, requires the server to check that the token was issued for it, and forbids passing tokens on to anyone else.

How a server checks a token depends on the kind. There are two:

  • Opaque: a random string that means nothing by itself. The server looks it up in its own database, or asks the sign-in server (a standard call named introspection).
  • Signed (a JWT): the token carries its own contents and a signature, and the server checks it with a public key.

All three of my access tokens are opaque to me: single strings, not the three dot-separated parts of a JWT, and none of the three sign-in servers publishes a list of public keys. How each service checks its tokens happens on its side, out of view. Notion's sign-in server does publish an introspection address, the standard way to look a token up.

How a signed token is checked (tested locally)

None of my three servers uses JWTs, so I built one on my machine to see how the checking works. A JWT is three parts joined by dots: header.contents.signature.

How a JWT is checked: the server hashes the header and contents, opens the signature with the public key, and compares; changed contents or a different key fail

  • Signing (by the sign-in server): hash header.contents (anyone can compute a hash; no key needed), then transform the hash with the private key. The result is the signature.
  • Checking (by the MCP server): open the signature with the public key and compare it with your own hash of the text you received. If they match, the token is genuine and unchanged.
  • My results: the real token passed. Changing the user ID in the contents failed. Re-signing it with a different key failed too.
  • Which public key: the header names the key (kid), and the sign-in server publishes its public keys at a fixed address. Several keys can be published at once, so an old key keeps working while tokens signed with it expire. A careful MCP server fetches keys only from the sign-in server it already trusts, never from an address written inside the token.
  • One key, many tokens: a key pair usually signs tokens for every user for a long time, until it's rotated. What's new on each login is the token.

A JWT is readable by anyone who holds it. The contents are only encoded, so signing stops tampering without hiding anything. Secrecy comes from HTTPS and from where the token is stored.

Refresh, revoke, and no sessions

  • Refresh: when the access token expires, Claude sends the refresh token to the sign-in server and gets a new access token, with no login.
  • Revoke: all three sign-in servers publish a revocation address, and a service can also cancel tokens on its own side. After that the next refresh fails, and you're back at step 1.
  • No sessions: the 2026-07-28 spec retired the initialize handshake and the Mcp-Session-Id header. Each request now carries its own protocol version and client details, so any copy of a server can answer it. The state lives at the edges: tokens on your disk, and token records at the sign-in server.

What the tokens can't protect against

If an attacker steals a sign-in server's private signing key, they can make tokens the MCP server will accept. That happened in 2023, when a group Microsoft calls Storm-0558 forged tokens with a stolen Microsoft signing key to read email. OAuth limits the damage of a leak, with short-lived tokens, tokens bound to one server, revocation and key rotation, but a breach of the service itself is beyond what any token can fix.

Who does what

Piece Made by Kept by
App ID (a URL) the app's maker public
PKCE verifier Claude Claude, until step 8
One-time code sign-in server passes through your browser, used once
Access token sign-in server Claude, sent on every request
Refresh token sign-in server Claude, plus a record at the sign-in server
Your password you the sign-in server only
Local server secrets you the server's environment

Top comments (0)