
We built a small internal tool: an MCP server so Claude Code could search and manage MusicTech Lab's own resources directory, startups, investors, open source projects, straight from the terminal. It worked well enough that a colleague asked the obvious question: "can I use this too?"
That single request turned into a much bigger project than expected, not because sharing a tool is hard, but because "sharing it with Claude Code" and "sharing it with Claude Desktop" turned out to be two almost unrelated engineering problems.
The original setup was about as simple as MCP servers get: a local process talking over stdio, one person, one machine, a JWT cached in a file on disk. Claude Code would spawn it, log in once, and reuse that session indefinitely.
Sharing it meant something different: a real remote deployment other people could point their own Claude client at, without installing Python or Poetry locally.
Once the server lived on its own droplet, updating it by hand (rebuild locally, rsync, SSH in, restart) stopped being sustainable. So it got a proper GitHub Actions pipeline: lint and test on every pull request, then on push to main, build a Docker image, push it to GitHub Container Registry, and deploy it to the droplet over SSH as a dedicated, non-root deploy user.
The pipeline caught a bug worth calling out, because it's the kind that stays invisible for months if you don't go looking for it.
services:
mcp-server:
image: ghcr.io/musictechlab/mtl-website-mcp:latest
build: . # looked like a harmless local-dev convenience
Keeping build: alongside image: meant that if the registry pull ever failed (a stale credential, a network blip), Docker Compose silently fell back to rebuilding from whatever source happened to already be sitting on the server, stale code, no error, no failed deploy. The GitHub Actions run would show green. The droplet just wouldn't be running what was actually merged.
The second lesson came from looking sideways instead of inward: two sibling MusicTechLab repos already had CI/CD pipelines solving the exact same registry-auth problem, using a fresh, job-scoped login on every deploy instead of a long-lived credential sitting on the server. Copying a proven internal pattern beat inventing a new one.
With the shared server and CI/CD in place, Claude Code worked great: claude mcp add --transport http with a --header "Authorization: Bearer ..." flag, done.
Claude Desktop's "Add custom connector" flow had a different idea entirely. It always performs OAuth 2.0 dynamic client registration against the server's origin before it will connect, no exceptions, no fallback for a static header. The first attempt to connect failed immediately:
Couldn't register with MTL Website's sign-in service. You can try again, or add an OAuth Client ID in the connector settings.
There was no sign-in service to register with. There was no OAuth at all.
The MCP Python SDK turned out to already ship the hard part: an OAuthAuthorizationServerProvider protocol that, once implemented, gets FastMCP to automatically mount /.well-known/oauth-authorization-server, /authorize, /token, and /register.
Loading diagram...
The design decision that mattered most: /authorize redirects to a real staff login page, not an auto-approve stub. Since a signed-in connection also gets write access to production data, gating it behind actual credentials, not just "anyone who finds the URL", was worth the extra page.

async def load_access_token(self, token: str) -> AccessToken | None:
# Legacy static token: keeps Claude Code's existing setup working, unchanged.
if self.legacy_token and secrets.compare_digest(token, self.legacy_token):
return AccessToken(token=token, client_id="legacy", scopes=["mcp"], expires_at=None)
access_token = self._access_tokens.get(token)
if access_token is None or (access_token.expires_at and access_token.expires_at < time.time()):
return None
return access_token
code_verifier against the code_challenge stored on the authorization code) is handled entirely by the SDK. The provider only needs to store the challenge; it never has to hash or compare anything itself.Once the token exchange succeeds, Claude Desktop's own callback page confirms it before handing control back to the app:

A nice side effect of gating /authorize behind a real login: the resulting session already carries the backend JWT obtained during that login. Desktop users get read and write access immediately after signing in, no separate login step inside the chat, while Claude Code's static-token path is completely unaffected and still calls the login tool manually, exactly as before.
| Claude Code | Claude Desktop | |
|---|---|---|
| Setup | claude mcp add --header "Bearer ..." | Settings → Connectors → Add custom connector |
| Auth mechanism | Static shared token | OAuth 2.1, dynamic client registration, PKCE |
| Write access | Manual mtl_website_login call | Automatic, seeded from the OAuth login |
| Session isolation | Per-connection, in-memory | Per-connection, in-memory |
Neither path is a workaround for the other. They're both first-class ways into the same server, chosen by whichever client is asking.
This is what it looks like in practice: Claude Desktop searching real production data, no separate login tool call anywhere in sight, because the OAuth sign-in already handled it.

What happened next wasn't something we built at all: the MCP search matched loosely, pulling in some Series A and D rounds alongside the real Series B deals. Claude noticed on its own, and rather than accept the noisy results, it spun up six parallel agents to scan the underlying investment records directly and filter for the true round_type == "Series B" rows.

That's the part worth sitting with: the MCP server just exposes data, search included, warts and all. The judgment about whether that data is trustworthy, and the initiative to go verify it, comes from the model on the other end of the connection.
"Add authentication" sounds like one task. In practice it was three different problems stacked on top of each other: isolating sessions so a shared server doesn't leak one user's login into another's, building a deploy pipeline that fails loudly instead of quietly serving stale code, and implementing a real OAuth authorization server because one specific client simply doesn't support anything less.
If you're building an MCP server and only need it to work from Claude Code, a static bearer token is genuinely enough, don't build more than that. The moment Claude Desktop needs to connect, budget for OAuth from the start; there's no smaller version of that requirement to reach for instead.
Building something similar or facing technical challenges? We've been there.
Let's talk — no sales pitch, just honest engineering advice.
MusicXML: Standard for Music Notation
What is MusicXML, how does it work, and what are its applications in music and education? A guide to the open standard for sheet music exchange.
Only a few books but dozens of ideas
Key takeaways from Bill Gates' recommended book list, including Getting Things Done, Deep Work, and practical ideas for time management and productivity.
Technical Partner
Technical partner at MusicTech Lab with 15+ years in software development. Builder, problem solver, blues guitarist, long-distance swimmer, and cyclist.
Get music tech insights, case studies, and industry news delivered to your inbox.