An MCP server that adds two numbers has no opinion about who is calling it. One that posts to a social account has to answer a question before it can do anything at all: whose account? There are two workable answers. You can build a multi-tenant server that authenticates every caller and looks up the account they own, which is the shape Add GitHub OAuth to a FastMCP Server builds. Or you can build one process that acts as exactly one account, reads that account out of its own environment, and runs on the same machine as the client calling it.
This article builds the second kind. By the end you will have a
FastMCP server running on your Mac, launched by Claude
Code, that posts to one account and has no mechanism for reaching another. The
account comes entirely from environment variables set in the client’s config,
the env block it passes to each server it launches, so handing the code to a
colleague is a copy and a different env block. You will
also have run two instances from one codebase and watched the service confirm
that each wrote only to its own account, and you will have the vocabulary to name the
pattern when someone asks what you built.
What this pattern is called
The Model Context Protocol specification does not name this shape, which is why it is easy to build one without realizing there was a decision to make. In practice you will hear three names for it, and they are not synonyms. Each describes a different aspect of the same server.
| Name | What it describes | The claim it makes |
|---|---|---|
| User-scoped, or account-bound | Architecture | Identity and permissions are fixed to one account. The server is a proxy for one user, not a directory of them. |
| Local-first, or sidecar | Deployment | The server runs on the same machine as the MCP client, started by that client, with its credentials in the client’s own config. |
| Instance-specific | Configuration | The code is generic; this running copy is not. A colleague runs the same program against their account. |
Sidecar is borrowed from container deployment, where a helper process is deployed next to the main application and shares its lifecycle. Here the main application is the MCP client and the helper is the server the client launches and shuts down with it. The word says nothing about identity, which is why it is the wrong term to reach for when identity is the point.
If you are documenting one of these, user-scoped is the most precise of the three, because it names the property a reader needs to know. It tells them the logic is shared and the identity is local. The other two remain useful as adjectives about where it runs and how it is configured.
The distinction matters before you write code, because the two architectures answer different questions. A user-scoped server has no user table, no token store, no consent screen, and no way for one caller’s request to touch another caller’s data, because there is only ever one account in the process. In exchange it owns problems a multi-tenant server does not: a long-lived credential sitting in a config file, and the fact that anyone who can reach the server acts as you. The last step comes back to that trade.
The service is one you run yourself
The sidecar needs something to post to. Pointing it at a real social network would mean signing up, handing a credential to a tutorial, and publishing test posts to a live timeline, and it would mean a second account before you could demonstrate the one property that matters: that two instances cannot cross.
So the first thing you build is the service. Chirp is a single Python file with accounts, one credential per account, and a rule that a post is attributed to whoever the token belongs to. That is every property the sidecar needs and nothing else. It runs on your Mac, it stores its data in a JSON file you can delete, and creating a second account takes one command.
The point is not Chirp. The point is that when you swap it for a real service,
nothing above chirp_client.py changes: that one file gets different URLs and
the same three calls. Everything the article teaches about scoping survives the
swap, because none of it lives there.
What you will build
chirpd.py: the service, so the whole article runs on one machine with no signups.identity.py: the scope boundary, and the only place account data enters the process. A missing username stops the server at startup rather than at the first tool call.chirp_client.py: the three HTTP calls the sidecar makes, with a bounded timeout and every failure mapped to one error type.server.py: three tools —whoami,draft_post, andpublish_post— built by a factory that takes the account it should act as.- A
pytestsuite that proves two instances of the same code cannot write to each other’s account, and amakehelp screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5.29+ —
brew install uv; verify withuv --version. Validated here on uv 0.11.26 and Python 3.12.9. Step 1’s--bareflag needs 0.5.29. - Xcode Command Line Tools (
xcode-select --install) formake. - Claude Code or Claude Desktop (claude.com/claude-code), for the registration step. That is the only step needing an MCP client.
- Familiarity with the Model Context Protocol (modelcontextprotocol.io) and with FastMCP. Build an MCP Server with FastMCP and Python covers the basics this article assumes.
No account on any external service, and no network access beyond installing the dependencies.
Step 1: Scaffold the project and keep secrets out of git
The .gitignore comes first, before any file that could hold a credential
exists. A user-scoped server’s security claim is that its identity lives in the
environment rather than in git, and the fastest way to break it is to commit a
.env file on day one.
Create the files
mkdir -p ~/projects && cd ~/projects
uv init --bare --name chirp-sidecar \
--description "A user-scoped MCP sidecar for one Chirp account" \
user-scoped-mcp-server-macos
cd user-scoped-mcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
# Secrets: the sidecar's identity lives in the environment, never in the repo.
.env
.env.*
!.env.example
# The local service's data files, which hold account credentials.
chirp-*data.json
# macOS / logs
.DS_Store
*.log
Detailed breakdown
- The
.envand.env.*entries cover the file you will be tempted to create while developing. The!.env.exampleexception lets you commit a template with the variable names and no values, which is the useful half. chirp-data.jsonis ignored because Chirp stores account credentials in it. It is a local service, but a file full of passwords should not reach a repository whatever it is for.uv init --barewrites apyproject.tomland nothing else. The variants that scaffold amain.pyand a README leave files you would immediately delete.
Now add the dependencies. fastmcp is the server framework, httpx is the
async HTTP client, and mcp supplies the tool-annotation types. The service
itself needs nothing: it is written against the standard library.
uv add fastmcp httpx mcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "chirp-sidecar"
version = "0.1.0"
description = "A user-scoped MCP sidecar for one Chirp account"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=4.0.2",
"httpx>=0.28.1",
"mcp>=2.1.1",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
uv addresolves and writes the lower bounds shown here, so your versions may read higher. Nothing in this article depends on anything newer than the versions shown.mcpis declared even thoughfastmcpalready depends on it.server.pyimportsmcp.types.ToolAnnotationsdirectly, and a package you import by name belongs in your dependency list rather than being inherited from a library that might drop or re-vendor it.pytest-asynciois needed because the tools that touch the network areasync, and the tests drive them through a real MCP client.
Step 2: Build the service the sidecar will talk to
Chirp is deliberately small, and reading it pays off because the sidecar’s security rests on two of its behaviours. It issues one credential per account, and it attributes a post to whoever holds the token rather than to whatever name the request claims. A service that took the author’s name from the request body would make the whole scoping exercise theatre.
One design choice shapes the next step: the app password is returned exactly once, at account creation, and is never readable afterwards. That mirrors how real services hand out scoped credentials, and it is why the next step tells you to copy it somewhere before moving on.
Create the file
touch chirpd.py
Add the code: chirpd.py
"""Chirp: a small social service that exists so this project has one to talk to.
Chirp is not real. It is a single-file stand-in for the kind of service a
personal MCP sidecar usually fronts: it has accounts, it issues a credential per
account, and it refuses to let one account write as another. That is every
property the sidecar needs and nothing else, which is the point.
Four endpoints:
POST /accounts create an account, get its app password once
POST /session username + app password -> a bearer token
POST /posts write a post as the token's account
GET /users/<username> public profile, no credential needed
State lives in `chirp-data.json` next to this file, so an account you create
survives a restart. Run it with `make serve-chirp`, or:
uv run python chirpd.py --port 8787
"""
from __future__ import annotations
import argparse
import json
import secrets
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
DATA_FILE = Path(__file__).resolve().parent / "chirp-data.json"
MAX_POST_CHARS = 280
_LOCK = threading.Lock()
def load() -> dict:
"""Read the whole store. Missing or corrupt file means an empty service."""
try:
return json.loads(DATA_FILE.read_text())
except (FileNotFoundError, ValueError):
return {"accounts": {}, "posts": []}
def save(store: dict) -> None:
DATA_FILE.write_text(json.dumps(store, indent=2) + "\n")
def make_app_password() -> str:
"""Four groups of four, the shape most services use for a scoped credential."""
alphabet = "abcdefghijkmnpqrstuvwxyz23456789"
raw = "".join(secrets.choice(alphabet) for _ in range(16))
return "-".join(raw[i : i + 4] for i in range(0, 16, 4))
class ChirpHandler(BaseHTTPRequestHandler):
"""One method per route. No framework, so the whole service reads top to bottom."""
server_version = "chirpd/1.0"
def do_POST(self) -> None: # noqa: N802 - name fixed by BaseHTTPRequestHandler
path = urlparse(self.path).path
body = self._body()
if path == "/accounts":
return self._create_account(body)
if path == "/session":
return self._create_session(body)
if path == "/posts":
return self._create_post(body)
self._error(404, "not_found", f"No such route: {path}")
def do_GET(self) -> None: # noqa: N802 - name fixed by BaseHTTPRequestHandler
path = urlparse(self.path).path
if path.startswith("/users/"):
return self._get_user(path.removeprefix("/users/").strip("/"))
self._error(404, "not_found", f"No such route: {path}")
def _create_account(self, body: dict) -> None:
username = str(body.get("username") or "").strip().lower()
if not username.isalnum():
return self._error(400, "bad_username", "Username must be letters and digits only.")
with _LOCK:
store = load()
if username in store["accounts"]:
return self._error(409, "taken", f"Account {username!r} already exists.")
app_password = make_app_password()
store["accounts"][username] = {
"user_id": f"chirp_{secrets.token_hex(6)}",
"app_password": app_password,
"display_name": str(body.get("display_name") or username),
"created_at": _now(),
}
save(store)
account = load()["accounts"][username]
# The app password is returned once, here, and never again.
self._json(201, {"username": username, "user_id": account["user_id"], "app_password": app_password})
def _create_session(self, body: dict) -> None:
username = str(body.get("username") or "").strip().lower()
password = str(body.get("app_password") or "")
account = load()["accounts"].get(username)
if account is None or not secrets.compare_digest(
password.encode("utf-8"), account["app_password"].encode("utf-8")
):
return self._error(401, "bad_credentials", "Unknown username or wrong app password.")
self._json(
200,
{
"user_id": account["user_id"],
"username": username,
"token": f"chirp-token.{account['user_id']}",
},
)
def _create_post(self, body: dict) -> None:
token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
if not token.startswith("chirp-token."):
return self._error(401, "no_token", "Missing or malformed bearer token.")
user_id = token.removeprefix("chirp-token.")
with _LOCK:
store = load()
owner = next((u for u, a in store["accounts"].items() if a["user_id"] == user_id), None)
if owner is None:
return self._error(401, "no_token", "Token does not belong to any account.")
text = str(body.get("text") or "")
if not text.strip():
return self._error(400, "empty", "A post needs text.")
if len(text) > MAX_POST_CHARS:
return self._error(400, "too_long", f"{len(text)} characters; the limit is {MAX_POST_CHARS}.")
post = {
"post_id": f"post_{secrets.token_hex(4)}",
"author": owner,
"author_id": user_id,
"text": text,
"created_at": _now(),
}
store["posts"].append(post)
save(store)
self._json(201, post | {"url": f"http://{self.headers.get('Host', 'localhost')}/users/{owner}"})
def _get_user(self, username: str) -> None:
store = load()
account = store["accounts"].get(username.lower())
if account is None:
return self._error(404, "no_such_user", f"No account named {username!r}.")
posts = [p for p in store["posts"] if p["author"] == username.lower()]
self._json(
200,
{
"username": username.lower(),
"user_id": account["user_id"],
"display_name": account["display_name"],
"post_count": len(posts),
"posts": posts[-10:],
},
)
def _body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
try:
return json.loads(self.rfile.read(length) or b"{}") if length else {}
except ValueError:
return {}
def _json(self, status: int, payload: object) -> None:
raw = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def _error(self, status: int, code: str, message: str) -> None:
self._json(status, {"error": code, "message": message})
def log_message(self, fmt: str, *args) -> None:
"""Silence the default access log; the demos own the terminal."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def start_in_thread(port: int = 0) -> tuple[ThreadingHTTPServer, str]:
"""Serve on a daemon thread and return the server plus its base URL."""
server = ThreadingHTTPServer(("127.0.0.1", port), ChirpHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server, f"http://127.0.0.1:{server.server_address[1]}"
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Chirp service.")
parser.add_argument("--port", type=int, default=8787)
args = parser.parse_args()
server, url = start_in_thread(args.port)
print(f"chirpd listening on {url} (Ctrl-C to stop)", flush=True)
print(f"data file: {DATA_FILE}", flush=True)
try:
threading.Event().wait()
except KeyboardInterrupt:
print("\nchirpd stopped.")
finally:
server.shutdown()
server.server_close()
if __name__ == "__main__":
main()
Detailed breakdown
_create_postderives the author from the token, not the request body. It looks up which account ownsuser_id, and writes that name into the post. There is no field a caller could set to claim someone else’s name, which is what makes the scoping demonstration in Step 7 mean anything.secrets.compare_digestcompares the app password, not==. The difference is timing: a plain comparison returns faster on an early mismatch, which leaks information about the secret one character at a time. It costs nothing to get right and belongs in real code.start_in_thread(0)asks the kernel for a free port. The standalone server passes8787, but the tests and the scoping proof take the default of0, so they cannot collide with a Chirp you left running in another terminal.log_messageis overridden to do nothing.BaseHTTPRequestHandlerwrites an access log line to stderr for every request by default, which would interleave with the demos’ own output.DATA_FILEis a module-level global on purpose. Both the tests and the scoping proof repoint it at a throwaway path, which is why neither can damage the accounts you create by hand.- The startup
printcalls passflush=True. Python block-buffers stdout when it is not a terminal, so without the flush the startup line is invisible whenever the output is piped or captured. MAX_POST_CHARSis defined here and again inserver.py. The sidecar deliberately does not import the service, so the number is repeated rather than shared. Against a real service you would not have the choice, which is whydraft_posttreats its own copy as a fast pre-check and lets the service be the authority.- Two caveats, since this is a teaching service. The token is a predictable function of the account id rather than a random secret, and every request reads and rewrites the whole JSON file. Both are fine for one user on one machine and neither would survive contact with real traffic.
Step 3: Run Chirp and create an account
Chirp needs to be running before anything else works, and it holds the terminal while it runs, so this step uses two. The account you create here is the one the sidecar will act as for the rest of the article.
In the first terminal, start the service:
uv run python chirpd.py --port 8787
chirpd listening on http://127.0.0.1:8787 (Ctrl-C to stop)
data file: /Users/you/projects/user-scoped-mcp-server-macos/chirp-data.json
Leave that running. In a second terminal, in the same directory, create an account:
curl -s -X POST http://127.0.0.1:8787/accounts \
-H 'content-type: application/json' \
-d '{"username":"alice"}' | python3 -m json.tool
{
"username": "alice",
"user_id": "chirp_f07d78077a33",
"app_password": "dg3r-5m39-sm36-987j"
}
Copy that app password now. It is shown once and Chirp has no endpoint that
returns it again; if you lose it, rm -f chirp-data.json wipes the data file and
you start over (Step 11 wraps that as make reset). Your user_id and password will differ from these, since both are
generated. Running the same command twice returns 409 taken rather than a
second password, which is Chirp declining to let one username exist twice.
Keep those two values to hand. They are most of the configuration of the server
you are about to build. The third piece is CHIRP_ALLOW_PUBLISH, a separate
opt-in deciding whether the server may write at all; Step 7 starts with it off,
so leave it unset for now.
CHIRP_USERNAME=alice
CHIRP_APP_PASSWORD=dg3r-5m39-sm36-987j
Step 4: Make the account the only variable
This is the file that makes the server user-scoped, and everything else in the
project is deliberately ignorant of which account it serves. AccountConfig
reads the environment, validates it, and freezes it. The error it raises names
the client’s env block, the environment map an MCP client passes to each
server it launches; Step 10 shows one in full.
If the environment does not describe a usable account, the server never starts. That is deliberate. The alternative is to start anyway and fail on the first tool call, which is worse: an MCP client that connects successfully advertises the server’s tools to the model, and the model then calls a tool that cannot work. Failing at startup turns a confusing mid-conversation error into a connection error the client shows you directly.
Create the file
touch identity.py
Add the code: identity.py
"""The scope boundary: who this sidecar acts as.
Everything account-specific enters the process here and nowhere else. The rest
of the project is generic; run two copies with two different environments and
you get two servers that cannot touch each other's account.
`AccountConfig.from_env()` is deliberately strict. A sidecar with no username
must fail at startup, not on the first tool call, because a server that starts
successfully is advertised to the MCP client as working.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from urllib.parse import urlparse
DEFAULT_CHIRP_URL = "http://127.0.0.1:8787"
# Hosts allowed to receive a credential over plaintext HTTP.
PLAINTEXT_OK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
class ConfigError(RuntimeError):
"""The environment does not describe a usable account."""
@dataclass(frozen=True)
class AccountConfig:
"""The one account this process is allowed to act as."""
username: str
app_password: str
chirp_url: str = DEFAULT_CHIRP_URL
allow_publish: bool = False
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> AccountConfig:
"""Build the config from environment variables, or raise `ConfigError`."""
env = os.environ if env is None else env
username = env.get("CHIRP_USERNAME", "").strip().lstrip("@").lower()
app_password = env.get("CHIRP_APP_PASSWORD", "").strip()
chirp_url = env.get("CHIRP_URL", DEFAULT_CHIRP_URL).strip().rstrip("/")
missing = [
name
for name, value in (("CHIRP_USERNAME", username), ("CHIRP_APP_PASSWORD", app_password))
if not value
]
if missing:
raise ConfigError(
"This server is user-scoped and has no account to act as. "
f"Set {' and '.join(missing)} in the MCP client's env block."
)
if not username.isalnum():
raise ConfigError(f"CHIRP_USERNAME must be letters and digits only, got {username!r}.")
parsed = urlparse(chirp_url)
host, scheme = parsed.hostname or "", parsed.scheme
if scheme not in {"http", "https"}:
raise ConfigError(f"CHIRP_URL must be an http(s) URL, got {chirp_url!r}.")
if scheme == "http" and host not in PLAINTEXT_OK_HOSTS:
raise ConfigError(
f"Refusing to send an app password over plaintext HTTP to {host!r}. "
"Use https, or point CHIRP_URL at a service on this machine."
)
return cls(
username=username,
app_password=app_password,
chirp_url=chirp_url,
allow_publish=_flag(env.get("CHIRP_ALLOW_PUBLISH")),
)
def describe(self) -> dict[str, object]:
"""The scope, safe to show a caller. Never includes the app password."""
return {
"username": self.username,
"chirp_url": self.chirp_url,
"publish_allowed": self.allow_publish,
}
def _flag(raw: str | None) -> bool:
"""Read a boolean env var. Absent, empty, and `0`/`false`/`no` are all off."""
return (raw or "").strip().lower() in {"1", "true", "yes", "on"}
Detailed breakdown
from_envtakes an optionalenvdict. Defaulting toos.environkeeps the production path a no-argument call, while letting the tests and the scoping proof in Step 8 pass a dictionary instead of mutating global process state. That one parameter is what makes two instances constructible in a single test.- The error names the missing variables.
Set CHIRP_USERNAME and CHIRP_APP_PASSWORDis actionable; “configuration error” is not. This message is the first thing you will see if the client config is wrong, so it earns the handful of extra lines. CHIRP_ALLOW_PUBLISHis a separate opt-in from the credentials. The same environment mechanism that supplies identity also supplies capability, so you can register a read-only instance of this server by not setting the flag. A server that can read your account but not write to it is a reasonable default for a model to be pointed at.CHIRP_URLexists so a second instance can point at a different service, and an override that accepts any URL would let a typo send your app password to an arbitrary host in the clear. Localhost is exempt because there is no network to eavesdrop on. Against a real service this rule is what keeps a mistyped host from becoming a credential leak.describe()is the allowlist for what a tool may return. It exists so thatwhoamicannot accidentally serialize the whole frozen dataclass, app password included, when someone adds a field later.
Step 5: Talk to the service
Three calls are enough for this sidecar: read a profile, exchange credentials for a token, and write a post. Keeping them in one small class means the tools in the next step contain no HTTP at all, and it gives the tests a single place to intercept.
The transport parameter is the reason this file is easy to test. httpx lets
you hand a client an alternative transport, and passing a mock one swaps the
entire network layer without patching any module. Step 9 uses it to assert on
the exact requests the sidecar sends, which is where the scoping guarantee
actually lives.
Create the file
touch chirp_client.py
Add the code: chirp_client.py
"""An HTTP client for the three Chirp calls the sidecar makes.
Every failure becomes a `ChirpError`, so nothing above this file has to know
what `httpx` raises. The `transport` parameter is the test seam: passing an
`httpx.MockTransport` swaps the whole network layer without patching anything.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
DEFAULT_TIMEOUT = 10.0
class ChirpError(RuntimeError):
"""A Chirp call failed. Carries the service's error code when it sent one."""
def __init__(self, message: str, *, code: str | None = None, status: int | None = None):
super().__init__(message)
self.code = code
self.status = status
@dataclass(frozen=True)
class Session:
"""An authenticated session, bound to exactly one account."""
user_id: str
username: str
token: str
class ChirpClient:
"""Calls against one Chirp host, with a bounded timeout."""
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT, transport: httpx.AsyncBaseTransport | None = None):
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._transport = transport
async def get_profile(self, username: str) -> dict[str, Any]:
"""Read a public profile. Unauthenticated, so it needs no credential."""
return await self._request("GET", f"/users/{username}")
async def create_session(self, username: str, app_password: str) -> Session:
"""Exchange a username and app password for a bearer token."""
body = await self._request("POST", "/session", json={"username": username, "app_password": app_password})
return Session(user_id=body["user_id"], username=body["username"], token=body["token"])
async def create_post(self, session: Session, text: str) -> dict[str, Any]:
"""Write one post as the session's account."""
return await self._request(
"POST",
"/posts",
json={"text": text},
headers={"Authorization": f"Bearer {session.token}"},
)
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
"""One round trip, with every failure turned into a `ChirpError`."""
url = f"{self._base_url}{path}"
try:
async with httpx.AsyncClient(timeout=self._timeout, transport=self._transport) as client:
response = await client.request(method, url, json=json, headers=headers)
except httpx.TimeoutException as exc:
raise ChirpError(f"{path} timed out after {self._timeout:g}s.") from exc
except httpx.HTTPError as exc:
raise ChirpError(f"Could not reach Chirp at {self._base_url}: {exc}.") from exc
if response.status_code >= 400:
code, message = _error_of(response)
raise ChirpError(f"{path} failed ({response.status_code} {code}): {message}", code=code, status=response.status_code)
try:
return response.json()
except ValueError as exc:
raise ChirpError(f"{path} returned a non-JSON body.") from exc
def _error_of(response: httpx.Response) -> tuple[str, str]:
"""Chirp errors are `{"error": code, "message": text}`; tolerate anything else."""
try:
body = response.json()
except ValueError:
return "unknown", response.text[:200] or response.reason_phrase
if not isinstance(body, dict):
return "unknown", str(body)[:200]
return str(body.get("error") or "unknown"), str(body.get("message") or response.reason_phrase)
Detailed breakdown
get_profileneeds no credential. It is the only call here that works unauthenticated, which is what letswhoamiin the next step confirm the configured account exists without ever sending the app password. A read-only instance of this server can therefore do something useful with a credential that is never exercised.create_posttakes aSession, not a username. The token in that session came back from the login Chirp itself performed, so the destination account is whatever those credentials actually opened. A username typo cannot redirect a write to someone else’s account; it can only fail to log in.- Every failure becomes a
ChirpError. Timeouts, connection failures, HTTP error statuses, and non-JSON bodies all arrive as one type carrying the service’s own error code. The next step turns that into aToolErrorthe model can read, and nothing above this file has to know whathttpxraises. - The except clauses are ordered narrowest first.
TimeoutExceptionis a subclass ofhttpx.HTTPError, so reversing them would swallow every timeout into the generic branch and lose the message that says how long it waited. - The timeout is explicit.
httpxdefaults to five seconds, and naming the value in the module beats inheriting whatever a future version picks for a call a model can trigger.
Step 6: Expose three tools with honest annotations
The server is built by a factory instead of being declared at module scope. That
is the pattern in code: create_server(config) takes
the account it should act as, so the module is generic and the running instance
is not. It also makes two instances constructible in one process, which Step 8
relies on.
The three tools are ordered by consequence. whoami reads only the configured
identity. draft_post validates text without touching the network.
publish_post writes, and only when the environment has said it may. Each
carries annotations describing those properties, which is how a client decides
whether to auto-approve a call or ask you first. See
Design Great MCP Tools: Annotations and Semantics on macOS
for what each hint means and how clients use it.
Create the file
touch server.py
Add the code: server.py
"""A user-scoped MCP sidecar for one Chirp account.
The server is built by a factory rather than declared at module scope, because
the whole point of a user-scoped sidecar is that the *code* is generic and the
*instance* is specific. `create_server(config)` takes the account it should act
as; `main()` reads that account from the environment and refuses to start
without one.
Three tools, in increasing order of consequence:
- `whoami` reads the configured identity and needs no credential.
- `draft_post` checks the text against Chirp's limit, entirely offline.
- `publish_post` writes to the account, and only when the environment has
explicitly allowed writes.
"""
from __future__ import annotations
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations
from chirp_client import ChirpClient, ChirpError
from identity import AccountConfig, ConfigError
MAX_POST_CHARS = 280
def create_server(config: AccountConfig, *, client: ChirpClient | None = None) -> FastMCP:
"""Build a server bound to exactly one account."""
chirp = client or ChirpClient(config.chirp_url)
mcp = FastMCP(f"chirp-{config.username}", mask_error_details=True)
@mcp.tool(
annotations=ToolAnnotations(
title="Show this server's account",
read_only_hint=True,
idempotent_hint=True,
open_world_hint=True,
)
)
async def whoami() -> dict[str, Any]:
"""Report which Chirp account this server acts as, and whether it may post."""
scope = config.describe()
try:
profile = await chirp.get_profile(config.username)
except ChirpError as exc:
raise ToolError(f"Could not read the profile for {config.username}: {exc}") from exc
scope["user_id"] = profile["user_id"]
scope["post_count"] = profile["post_count"]
return scope
@mcp.tool(
annotations=ToolAnnotations(
title="Draft a post without publishing",
read_only_hint=True,
idempotent_hint=True,
open_world_hint=False,
)
)
def draft_post(text: str) -> dict[str, Any]:
"""Check post text against Chirp's length limit and return what would be sent."""
length = len(text)
if not text.strip():
return {"valid": False, "length": length, "reason": "a post needs text"}
if length > MAX_POST_CHARS:
return {"valid": False, "length": length, "reason": f"{length} characters, {MAX_POST_CHARS} allowed"}
return {
"valid": True,
"length": length,
"remaining": MAX_POST_CHARS - length,
"account": config.username,
"text": text,
}
@mcp.tool(
annotations=ToolAnnotations(
title="Publish a post to this account",
read_only_hint=False,
destructive_hint=False,
idempotent_hint=False,
open_world_hint=True,
)
)
async def publish_post(text: str) -> dict[str, Any]:
"""Publish `text` as a post on this server's account."""
if not config.allow_publish:
raise ToolError(
"Publishing is disabled for this server. Set CHIRP_ALLOW_PUBLISH=1 "
"in its env block to enable it."
)
draft = draft_post(text)
if not draft["valid"]:
raise ToolError(f"Not published: {draft['reason']}.")
try:
session = await chirp.create_session(config.username, config.app_password)
if session.username != config.username:
raise ToolError(
f"Refusing to write: session belongs to {session.username}, "
f"not the configured {config.username}."
)
result = await chirp.create_post(session, text)
except ChirpError as exc:
raise ToolError(f"Publish failed: {exc}") from exc
return {
"post_id": result["post_id"],
"account": result["author"],
"user_id": result["author_id"],
"url": result.get("url", ""),
}
return mcp
def main() -> None:
"""Run the sidecar over stdio, or exit with a readable configuration error."""
try:
config = AccountConfig.from_env()
except ConfigError as exc:
raise SystemExit(f"chirp-sidecar: {exc}")
create_server(config).run()
if __name__ == "__main__":
main()
Detailed breakdown
- The server’s name includes the username.
chirp-aliceis what the server reports in its initialize handshake, so it names the account in logs and debug output. Clients list servers under the name you register them with, so give two registrations distinct names as well. - The order of checks inside
publish_postis not arbitrary. The publish flag is tested first, so a read-only instance never sends a credential anywhere. Validation runs next, so an over-long post costs no round trip. Only then does the login happen, and the write comes last. draft_postreturns a bad post as a result, not an error. A tool error tells the model something went wrong; a result withvalid: falseand the exact count tells it what to do next, which is to shorten the text by a known amount.publish_postraises for the same input, because there the caller asked for an action that cannot be completed.publish_postcallsdraft_postrather than repeating its rules. They are the same checks, and one of the easier ways to ship a bug is to let a validator and a writer disagree about what is allowed.- The username comparison after
create_sessionis the one redundant check worth keeping. The credentials already determine the account, so it can only fire if the configured username and the credentials disagree, which happens when someone edits one env var and not the other. It converts a surprising successful post from the wrong account into a refusal. mask_error_details=Truehides unexpected exception text from the client, while messages raised deliberately asToolErrorstill come through. Without it, an unhandled error could return a stack trace containing local paths.main()catchesConfigErrorand exits. The client’s connection log then shows one line naming the missing variable instead of a traceback.- The
clientparameter is the second test seam.create_serverbuilds aChirpClientfrom the config unless it is handed one, which lets Step 9 substitute a fake without a network. It is distinct fromChirpClient’s owntransportparameter: this one replaces the client, that one replaces the transport underneath it.
Step 7: Drive the sidecar and watch it post
Back in the second terminal, with Chirp still running in the first, the fastest way to see the whole thing work is a harness that builds one server from the environment and calls every tool once. FastMCP can connect a client to a server object in-process, so this exercises the real MCP layer, including tool listing and annotations, without spawning a subprocess.
Read the harness as a readout of your shell’s environment. It takes no arguments and has no configuration of its own, which is the point: change an environment variable, run it again, and you are looking at a different server.
Create the file
touch demo.py
Add the code: demo.py
"""Drive one sidecar instance and print what its scope allows.
Reads the account from the environment exactly as `server.py` does, so the
output is a readout of this shell's environment and nothing else.
uv run python demo.py
"""
import asyncio
import json
import logging
from fastmcp import Client
from identity import AccountConfig, ConfigError
from server import create_server
# A deliberately refused tool call is not a server fault; keep it out of stderr
# so this demo prints only its own output.
logging.getLogger("fastmcp").setLevel(logging.CRITICAL)
async def report(config: AccountConfig) -> None:
"""Call every tool once and print the result, refusals included."""
print(f"== instance for {config.username} (chirp {config.chirp_url}, publish={config.allow_publish})")
async with Client(create_server(config)) as client:
for tool in await client.list_tools():
print(f" tool {tool.name:13} read_only={tool.annotations.read_only_hint}")
res = await client.call_tool("whoami", {}, raise_on_error=False)
if res.is_error:
print("\n whoami failed:", res.content[0].text)
return
print("\n whoami ", json.dumps(res.data, sort_keys=True))
res = await client.call_tool("draft_post", {"text": "Shipped a user-scoped sidecar."})
print(" draft_post ", json.dumps(res.data, sort_keys=True))
res = await client.call_tool("draft_post", {"text": "x" * 281})
print(" draft_post valid =", res.data["valid"], "reason =", res.data["reason"])
text = f"Hello from {config.username}, posted by a user-scoped MCP sidecar."
res = await client.call_tool("publish_post", {"text": text}, raise_on_error=False)
if res.is_error:
print(" publish_post refused:", res.content[0].text)
else:
print(" publish_post ", json.dumps(res.data, sort_keys=True))
def main() -> None:
try:
config = AccountConfig.from_env()
except ConfigError as exc:
raise SystemExit(f"demo: {exc}")
asyncio.run(report(config))
if __name__ == "__main__":
main()
Detailed breakdown
Client(create_server(config))connects in-process. No subprocess, no stdio pipe, and no port. The client still speaks the real protocol, so the annotations printed here are the ones a real client would receive.raise_on_error=Falseturns a refusal into a result you can inspect instead of an exception that ends the script. Bothwhoamiandpublish_postuse it, because both have failure modes this demo is meant to show rather than crash on.- The FastMCP logger is silenced. A
ToolErrorraised on purpose is logged at error level by the server, which is correct in production and pure noise in a demo whose point is that the refusal happened.
Start with the publish flag unset, so the first run cannot write anything:
export CHIRP_USERNAME=alice
export CHIRP_APP_PASSWORD=dg3r-5m39-sm36-987j
uv run python demo.py
== instance for alice (chirp http://127.0.0.1:8787, publish=False)
tool whoami read_only=True
tool draft_post read_only=True
tool publish_post read_only=False
whoami {"chirp_url": "http://127.0.0.1:8787", "post_count": 0, "publish_allowed": false, "user_id": "chirp_f07d78077a33", "username": "alice"}
draft_post {"account": "alice", "length": 30, "remaining": 250, "text": "Shipped a user-scoped sidecar.", "valid": true}
draft_post valid = False reason = 281 characters, 280 allowed
publish_post refused: Publishing is disabled for this server. Set CHIRP_ALLOW_PUBLISH=1 in its env block to enable it.
whoami reached Chirp and read the account, so the identity is real. The
refusal came from the missing flag, before any credential left the process. Now
turn publishing on and run it again:
export CHIRP_ALLOW_PUBLISH=1
uv run python demo.py
== instance for alice (chirp http://127.0.0.1:8787, publish=True)
tool whoami read_only=True
tool draft_post read_only=True
tool publish_post read_only=False
whoami {"chirp_url": "http://127.0.0.1:8787", "post_count": 0, "publish_allowed": true, "user_id": "chirp_f07d78077a33", "username": "alice"}
draft_post {"account": "alice", "length": 30, "remaining": 250, "text": "Shipped a user-scoped sidecar.", "valid": true}
draft_post valid = False reason = 281 characters, 280 allowed
publish_post {"account": "alice", "post_id": "post_b331039a", "url": "http://127.0.0.1:8787/users/alice", "user_id": "chirp_f07d78077a33"}
The post is written. Ask Chirp rather than taking the sidecar’s word for it:
curl -s http://127.0.0.1:8787/users/alice | python3 -m json.tool
{
"username": "alice",
"user_id": "chirp_f07d78077a33",
"display_name": "alice",
"post_count": 1,
"posts": [
{
"post_id": "post_b331039a",
"author": "alice",
"author_id": "chirp_f07d78077a33",
"text": "Hello from alice, posted by a user-scoped MCP sidecar.",
"created_at": "2026-09-04T14:07:56Z"
}
]
}
Your ids and timestamp will differ, and post_count was 0 in the whoami
above because that tool ran before the write in the same pass. The author
field is the one to look at: Chirp derived it from the token, not from anything
the sidecar claimed.
Step 8: Prove that two instances cannot cross
Everything so far is an assertion: the server is scoped to one account. This
step turns it into an observation. The same create_server function is called
twice with two configurations, each built from a dictionary standing in for an
MCP client’s env block, and then the service is asked what it actually stored.
Reading the answer from Chirp rather than from the sidecar’s return value is the part that makes this evidence. A bug where the sidecar reported the right account but wrote to the wrong one would pass a check that only read the tool result.
Create the file
touch swap.py
Add the code: swap.py
"""The scoping proof: one codebase, two environments, two accounts.
Nothing here reaches into the server's internals. It creates two Chirp accounts,
builds two configs from two dictionaries that stand in for two MCP client `env`
blocks, hands each to the same `create_server`, and then asks Chirp itself what
it stored.
"""
import asyncio
import logging
import httpx
from fastmcp import Client
import chirpd
from identity import AccountConfig
from server import create_server
logging.getLogger("fastmcp").setLevel(logging.CRITICAL)
USERNAMES = ("alice", "bob")
async def signup(url: str, username: str) -> str:
"""Create an account on the running service and return its app password."""
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(f"{url}/accounts", json={"username": username})
return response.json()["app_password"]
async def publish_as(username: str, app_password: str, url: str) -> dict:
"""Start a sidecar scoped to `username` and publish one post from it."""
config = AccountConfig.from_env(
{
"CHIRP_USERNAME": username,
"CHIRP_APP_PASSWORD": app_password,
"CHIRP_URL": url,
"CHIRP_ALLOW_PUBLISH": "1",
}
)
async with Client(create_server(config)) as client:
return (await client.call_tool("publish_post", {"text": f"hello from {username}"})).data
async def profile(url: str, username: str) -> dict:
async with httpx.AsyncClient(timeout=5) as client:
return (await client.get(f"{url}/users/{username}")).json()
async def main() -> None:
# A throwaway service on its own port, so this never touches your real data file.
original, chirpd.DATA_FILE = chirpd.DATA_FILE, chirpd.DATA_FILE.with_name("chirp-swap-data.json")
chirpd.DATA_FILE.unlink(missing_ok=True)
server, url = chirpd.start_in_thread()
try:
for username in USERNAMES:
password = await signup(url, username)
result = await publish_as(username, password, url)
print(f"{username:8} -> post {result['post_id']} as {result['user_id']}")
print("\nWhat Chirp actually stored:")
for username in USERNAMES:
data = await profile(url, username)
texts = [p["text"] for p in data["posts"]]
print(f" {username:8} {data['user_id']} {texts}")
ids = {(await profile(url, u))["user_id"] for u in USERNAMES}
print(f"\nDistinct accounts written: {len(ids)}")
finally:
server.shutdown()
server.server_close()
chirpd.DATA_FILE.unlink(missing_ok=True)
chirpd.DATA_FILE = original
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The two dictionaries are the only variable. They differ in exactly one
key,
CHIRP_USERNAME, plus the password that key implies. Nothing else in the process changes between the two runs, so any difference in the output is attributable to that. - It starts its own Chirp on its own port and its own data file. Repointing
chirpd.DATA_FILEbefore starting means running this never adds accounts to the service you have been using by hand, and thefinallyblock puts the global back so an interactive session that imported the module is unaffected. publish_asgoes through the MCP client, not throughcreate_postdirectly. Calling the internals would prove the HTTP client works; calling the tool proves the path a model would actually take is scoped.- The distinct-account count is computed from Chirp’s answer. It reads each profile back from the service, so it cannot be right by accident if the sidecar mis-addressed a write.
Run it. It needs no arguments and no running service, since it starts its own:
uv run python swap.py
alice -> post post_c160fd4d as chirp_a09c6fb6e304
bob -> post post_5d95d5bb as chirp_e70f50b6cc07
What Chirp actually stored:
alice chirp_a09c6fb6e304 ['hello from alice']
bob chirp_e70f50b6cc07 ['hello from bob']
Distinct accounts written: 2
Every id here is generated per run, so yours will differ. What will not differ is the last line: two accounts, from one codebase, distinguished only by an environment variable. That is the pattern working, and it is the thing the three names in the opening section all describe.
Step 9: Test the boundary, not just the happy path
The tests worth writing here are the ones that would catch a scoping failure, because that is the defect class with a real consequence. A post that fails to publish is an annoyance. A post that publishes from the wrong account is the thing the architecture exists to prevent.
The sidecar tests use httpx.MockTransport rather than a running Chirp. A
running service answers requests; a mock
transport lets a test inspect them. Asserting that the write carried the token
the session returned requires seeing the request headers, and that is the
guarantee. The service gets its own file of tests that do run it, because
its rules are the foundation everything else assumes.
Create the files
mkdir -p tests
touch pytest.ini tests/__init__.py tests/test_identity.py tests/test_server.py tests/test_chirpd.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Detailed breakdown
asyncio_mode = autoletsasync def test_*functions run without a@pytest.mark.asynciodecorator on each one. Every async test here lives intests/test_server.py, and all nine of its tests are async, so the decorator noise adds up.- The
DeprecationWarningfilter keeps the output readable when a dependency deprecates something mid-release-cycle. Remove it if you would rather see them.
Add the code: tests/test_identity.py
"""The scope boundary is the thing most worth testing: it is the security claim."""
import pytest
from identity import DEFAULT_CHIRP_URL, AccountConfig, ConfigError
GOOD = {"CHIRP_USERNAME": "alice", "CHIRP_APP_PASSWORD": "abcd-efgh-ijkl-mnpq"}
def test_defaults_to_the_local_service_and_no_publishing():
config = AccountConfig.from_env(GOOD)
assert config.chirp_url == DEFAULT_CHIRP_URL
assert config.allow_publish is False
@pytest.mark.parametrize(
"raw,expected",
[("1", True), ("true", True), ("YES", True), ("0", False), ("", False), (None, False)],
)
def test_publish_flag_is_opt_in(raw, expected):
env = dict(GOOD) | ({"CHIRP_ALLOW_PUBLISH": raw} if raw is not None else {})
assert AccountConfig.from_env(env).allow_publish is expected
def test_username_is_normalised():
assert AccountConfig.from_env(dict(GOOD, CHIRP_USERNAME="@Alice")).username == "alice"
@pytest.mark.parametrize("missing", ["CHIRP_USERNAME", "CHIRP_APP_PASSWORD"])
def test_missing_credentials_name_themselves(missing):
env = {k: v for k, v in GOOD.items() if k != missing}
with pytest.raises(ConfigError, match=missing):
AccountConfig.from_env(env)
def test_a_username_with_punctuation_is_rejected():
with pytest.raises(ConfigError, match="letters and digits"):
AccountConfig.from_env(dict(GOOD, CHIRP_USERNAME="alice.smith"))
def test_plaintext_http_to_a_remote_host_is_refused():
with pytest.raises(ConfigError, match="plaintext HTTP"):
AccountConfig.from_env(dict(GOOD, CHIRP_URL="http://chirp.example.com"))
def test_plaintext_http_to_localhost_is_allowed():
config = AccountConfig.from_env(dict(GOOD, CHIRP_URL="http://127.0.0.1:8787/"))
assert config.chirp_url == "http://127.0.0.1:8787"
def test_describe_never_leaks_the_app_password():
assert "abcd-efgh-ijkl-mnpq" not in repr(AccountConfig.from_env(GOOD).describe())
Detailed breakdown
test_missing_credentials_name_themselvesmatches on the variable name, so it fails if someone reduces the error to a generic message. The quality of that message is a feature, and this is how it stays one.test_publish_flag_is_opt_inincludesNoneas a distinct case from"". An unset variable and an empty one reach_flagdifferently, and both must be off.test_describe_never_leaks_the_app_passwordguards a future change, not current behaviour. It fails the day someone adds the password todescribe(), which is the only realistic way that leak would happen.
Add the code: tests/test_server.py
"""Tool behaviour against a mock Chirp, so no test needs the service running.
`httpx.MockTransport` is injected into the client, which means these tests cover
the requests the sidecar actually sends, including the guarantee that a post is
written with the token the session returned.
"""
import json
import httpx
import pytest
from fastmcp import Client
from chirp_client import ChirpClient
from identity import AccountConfig
from server import MAX_POST_CHARS, create_server
IDS = {"alice": "chirp_alice0000", "bob": "chirp_bob00000"}
def config_for(username: str, **overrides) -> AccountConfig:
return AccountConfig.from_env(
{"CHIRP_USERNAME": username, "CHIRP_APP_PASSWORD": "abcd-efgh-ijkl-mnpq", **overrides}
)
def fake_chirp(seen: list[httpx.Request], *, session_username: str | None = None) -> ChirpClient:
"""A Chirp that logs every request and answers the three calls we make."""
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
path = request.url.path
if path.startswith("/users/"):
username = path.removeprefix("/users/")
return httpx.Response(200, json={"username": username, "user_id": IDS[username], "display_name": username, "post_count": 3, "posts": []})
if path == "/session":
username = json.loads(request.content)["username"]
returned = session_username or username
return httpx.Response(200, json={"user_id": IDS[username], "username": returned, "token": f"chirp-token.{IDS[username]}"})
if path == "/posts":
user_id = request.headers["Authorization"].removeprefix("Bearer chirp-token.")
author = next(u for u, i in IDS.items() if i == user_id)
return httpx.Response(201, json={"post_id": "post_0001", "author": author, "author_id": user_id, "text": json.loads(request.content)["text"], "url": f"http://chirp.test/users/{author}"})
return httpx.Response(404, json={"error": "not_found", "message": path})
return ChirpClient("http://chirp.test", transport=httpx.MockTransport(handler))
async def test_whoami_reports_the_scope_and_the_account_id():
server = create_server(config_for("alice"), client=fake_chirp([]))
async with Client(server) as client:
data = (await client.call_tool("whoami", {})).data
assert data == {
"username": "alice",
"chirp_url": "http://127.0.0.1:8787",
"publish_allowed": False,
"user_id": IDS["alice"],
"post_count": 3,
}
async def test_draft_post_makes_no_network_calls():
seen: list[httpx.Request] = []
server = create_server(config_for("alice"), client=fake_chirp(seen))
async with Client(server) as client:
data = (await client.call_tool("draft_post", {"text": "hello"})).data
assert data["valid"] is True
assert data["remaining"] == MAX_POST_CHARS - 5
assert seen == []
@pytest.mark.parametrize(
"text,reason",
[("x" * (MAX_POST_CHARS + 1), "281 characters, 280 allowed"), (" ", "a post needs text")],
)
async def test_draft_post_reports_a_bad_post_as_a_result_not_an_error(text, reason):
server = create_server(config_for("alice"), client=fake_chirp([]))
async with Client(server) as client:
result = await client.call_tool("draft_post", {"text": text}, raise_on_error=False)
assert result.is_error is False
assert result.data["valid"] is False
assert result.data["reason"] == reason
async def test_exactly_the_limit_is_allowed():
server = create_server(config_for("alice"), client=fake_chirp([]))
async with Client(server) as client:
data = (await client.call_tool("draft_post", {"text": "x" * MAX_POST_CHARS})).data
assert data["valid"] is True and data["remaining"] == 0
async def test_publishing_is_refused_until_the_environment_enables_it():
seen: list[httpx.Request] = []
server = create_server(config_for("alice"), client=fake_chirp(seen))
async with Client(server) as client:
result = await client.call_tool("publish_post", {"text": "hi"}, raise_on_error=False)
assert result.is_error is True
assert "CHIRP_ALLOW_PUBLISH" in result.content[0].text
# The refusal happens before any credential leaves the process.
assert seen == []
async def test_publish_writes_with_the_token_the_session_returned():
seen: list[httpx.Request] = []
server = create_server(config_for("alice", CHIRP_ALLOW_PUBLISH="1"), client=fake_chirp(seen))
async with Client(server) as client:
data = (await client.call_tool("publish_post", {"text": "hello world"})).data
assert seen[-1].headers["Authorization"] == f"Bearer chirp-token.{IDS['alice']}"
assert data["account"] == "alice"
assert data["user_id"] == IDS["alice"]
async def test_two_instances_of_the_same_code_write_to_different_accounts():
for username in ("alice", "bob"):
server = create_server(config_for(username, CHIRP_ALLOW_PUBLISH="1"), client=fake_chirp([]))
async with Client(server) as client:
data = (await client.call_tool("publish_post", {"text": f"hi from {username}"})).data
assert data["user_id"] == IDS[username]
async def test_a_session_for_the_wrong_account_aborts_the_write():
seen: list[httpx.Request] = []
server = create_server(
config_for("alice", CHIRP_ALLOW_PUBLISH="1"), client=fake_chirp(seen, session_username="bob")
)
async with Client(server) as client:
result = await client.call_tool("publish_post", {"text": "hi"}, raise_on_error=False)
assert result.is_error is True
assert "bob" in result.content[0].text
# The post call never happened.
assert [r.url.path for r in seen] == ["/session"]
async def test_publish_rejects_text_the_service_would_reject():
seen: list[httpx.Request] = []
server = create_server(config_for("alice", CHIRP_ALLOW_PUBLISH="1"), client=fake_chirp(seen))
async with Client(server) as client:
result = await client.call_tool("publish_post", {"text": "x" * 281}, raise_on_error=False)
assert result.is_error is True
assert seen == []
Detailed breakdown
seenis a plain list the handler appends to. Every assertion about what the sidecar sent reads from it afterwards, which keeps the mock itself free of test-specific logic.test_publish_writes_with_the_token_the_session_returnedasserts on the header. That is the actual scoping mechanism: the write is authorised by a token the service issued for those credentials, so no argument the caller controls can redirect it.test_a_session_for_the_wrong_account_aborts_the_writechecks which calls happened, not only that an error came back. An implementation that wrote the post and then noticed the mismatch would produce the same error and a real post, so the list of observed paths is the assertion that matters.- Three tests assert
seen == []. A refusal that still made a network call would be a leak of intent at best and a credential at worst, and the empty list is how you prove nothing went out. session_usernameis the mock’s only injectable behaviour. Keeping the fake configurable in exactly one dimension stops it from growing into a second implementation of Chirp.
Add the code: tests/test_chirpd.py
"""Chirp's own guarantees, checked against a real instance on a temp data file."""
import httpx
import pytest
import chirpd
@pytest.fixture()
def chirp(tmp_path, monkeypatch):
"""A running Chirp with its own data file, torn down after each test."""
monkeypatch.setattr(chirpd, "DATA_FILE", tmp_path / "chirp-data.json")
server, url = chirpd.start_in_thread()
yield url
server.shutdown()
server.server_close()
def signup(url: str, username: str) -> str:
return httpx.post(f"{url}/accounts", json={"username": username}, timeout=5).json()["app_password"]
def token_for(url: str, username: str, password: str) -> str:
return httpx.post(f"{url}/session", json={"username": username, "app_password": password}, timeout=5).json()["token"]
def test_an_app_password_is_four_groups_of_four(chirp):
password = signup(chirp, "alice")
assert [len(part) for part in password.split("-")] == [4, 4, 4, 4]
def test_two_accounts_get_different_ids_and_passwords(chirp):
alice, bob = signup(chirp, "alice"), signup(chirp, "bob")
assert alice != bob
ids = {httpx.get(f"{chirp}/users/{u}", timeout=5).json()["user_id"] for u in ("alice", "bob")}
assert len(ids) == 2
def test_a_username_cannot_be_taken_twice(chirp):
signup(chirp, "alice")
assert httpx.post(f"{chirp}/accounts", json={"username": "alice"}, timeout=5).status_code == 409
def test_the_wrong_app_password_is_rejected(chirp):
signup(chirp, "alice")
response = httpx.post(f"{chirp}/session", json={"username": "alice", "app_password": "nope"}, timeout=5)
assert response.status_code == 401
assert response.json()["error"] == "bad_credentials"
def test_a_post_is_attributed_to_the_token_holder(chirp):
password = signup(chirp, "alice")
signup(chirp, "bob")
token = token_for(chirp, "alice", password)
response = httpx.post(f"{chirp}/posts", json={"text": "mine"}, headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert response.json()["author"] == "alice"
# Bob's timeline is untouched, which is the guarantee the sidecar relies on.
assert httpx.get(f"{chirp}/users/bob", timeout=5).json()["post_count"] == 0
def test_a_post_without_a_token_is_refused(chirp):
signup(chirp, "alice")
assert httpx.post(f"{chirp}/posts", json={"text": "hi"}, timeout=5).status_code == 401
def test_the_service_enforces_its_own_length_limit(chirp):
password = signup(chirp, "alice")
token = token_for(chirp, "alice", password)
response = httpx.post(f"{chirp}/posts", json={"text": "x" * 281}, headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert response.status_code == 400
assert response.json()["error"] == "too_long"
def test_an_unknown_user_is_a_404(chirp):
assert httpx.get(f"{chirp}/users/nobody", timeout=5).status_code == 404
Detailed breakdown
- The
chirpfixture repointsDATA_FILEattmp_path.monkeypatchrestores it afterwards, so a test run cannot read or damage the accounts you created by hand, and each test starts from an empty service. test_a_post_is_attributed_to_the_token_holderis the load-bearing one. It creates two accounts, posts as one, and asserts the other’s post count is still zero. If Chirp ever attributed a post by request body instead of by token, this fails and the sidecar’s whole guarantee is void.- These use synchronous
httpxcalls. The service is being driven as an external system rather than through the sidecar, so there is nothing to gain from async here, and the tests read as a sequence of HTTP requests.
Run the suite. It needs no running service, since the fixture starts one:
uv run pytest -q
................................ [100%]
32 passed in 4.85s
Step 10: Install the sidecar into Claude Code
This is the step that makes it a sidecar rather than a script. The MCP client
launches server.py as a child process, talks to it over stdio, and supplies
the environment. That environment block is where the account identity lives, and
it is the reason the same code on a colleague’s machine acts as a different
person.
Register it from inside the project directory, so the paths resolve:
claude mcp add chirp \
--env CHIRP_USERNAME=alice \
--env CHIRP_APP_PASSWORD=dg3r-5m39-sm36-987j \
--env CHIRP_ALLOW_PUBLISH=1 \
-- uv run --directory "$PWD" python server.py
Claude Desktop uses a JSON config file instead of a command, and Register a FastMCP Server with Claude Desktop and Claude Code on macOS covers both clients in detail. The entry it writes has the same shape:
{
"mcpServers": {
"chirp": {
"command": "/opt/homebrew/bin/uv",
"args": ["run", "--directory", "/Users/you/projects/user-scoped-mcp-server-macos", "python", "server.py"],
"env": {
"CHIRP_USERNAME": "alice",
"CHIRP_APP_PASSWORD": "dg3r-5m39-sm36-987j",
"CHIRP_ALLOW_PUBLISH": "1"
}
}
}
}
Four details in that config are each a way the pattern fails quietly.
The absolute path to uv is required for Claude Desktop, which is a GUI app
and never reads your shell profile, so a bare uv is not on its PATH; Claude
Code inherits your shell and does not need it. --directory is required for
both: without it, uv resolves the project from the client’s working directory
rather than yours, and the server starts against the wrong dependency set or not
at all. Leaving CHIRP_ALLOW_PUBLISH out registers a read-only instance, which
is a reasonable way to start. And the file now holds a credential in plaintext,
so it needs the same care as any credential file. If that is not acceptable, the
Keychain approach in
Call an External API from a FastMCP Tool on macOS
replaces the env var with a security find-generic-password lookup at startup.
Chirp must be running for the tools to work, so leave chirpd.py up in its
terminal. Registering prints where the entry was written:
Added stdio MCP server chirp with command: uv run --directory /Users/you/projects/user-scoped-mcp-server-macos python server.py to local config
File modified: /Users/you/.claude.json [project: /Users/you/projects/user-scoped-mcp-server-macos]
Confirm the client can actually start it. This is the check that matters, because a sidecar that fails to launch looks identical to one that has no tools:
claude mcp list
chirp: uv run --directory /Users/you/projects/user-scoped-mcp-server-macos python server.py - ✔ Connected
✔ Connected means the client launched the process, completed the MCP
handshake, and got a tool list back. Note that the server appears as chirp,
the name you registered it under, not the chirp-alice it reports internally.
claude mcp get chirp shows the whole entry, env block included, which is the
quickest way to see that the credential really is sitting in a config file.
Now ask the model to post something. It calls whoami first to see which
account it is acting as, then publish_post, which is the tool call you
approve:
whoami {"chirp_url": "http://127.0.0.1:8787", "post_count": 0, "publish_allowed": true, "user_id": "chirp_b852d4da28a6", "username": "alice"}
publish_post {"account": "alice", "post_id": "post_ff5a02f6", "url": "http://127.0.0.1:8787/users/alice", "user_id": "chirp_b852d4da28a6"}
That is the promise from the opening delivered: a server launched by the client, acting as one account, writing through a credential the client handed it. Fetch the profile again and the post is there, attributed by Chirp to the token:
curl -s http://127.0.0.1:8787/users/alice | python3 -m json.tool
{
"username": "alice",
"user_id": "chirp_b852d4da28a6",
"display_name": "alice",
"post_count": 1,
"posts": [
{
"post_id": "post_ff5a02f6",
"author": "alice",
"author_id": "chirp_b852d4da28a6",
"text": "Posted by Claude Code through the sidecar.",
"created_at": "2026-09-04T18:47:36Z"
}
]
}
The startup check from Step 4 is what protects you when that env block is wrong. Run the registered command yourself with the variables cleared and it refuses to start rather than presenting a broken tool list:
env -u CHIRP_USERNAME -u CHIRP_APP_PASSWORD uv run --directory "$PWD" python server.py
chirp-sidecar: This server is user-scoped and has no account to act as. Set CHIRP_USERNAME and CHIRP_APP_PASSWORD in the MCP client's env block.
Running it with valid variables is less useful than it sounds: the server starts, prints a FastMCP banner to stderr, and then blocks reading stdin for JSON-RPC that a terminal will never send. Ctrl-C does not stop it, because the transport is parked in a blocking read. Close the terminal, or let the client own its lifecycle, which is the arrangement the whole pattern assumes.
When you are finished, remove the registration. It persists in the client’s config with the credential in it, and stop Chirp with Ctrl-C in its terminal:
claude mcp remove chirp
Step 11: Add a Makefile with a help screen
Ten commands across two terminals is too many to remember. The
Makefile gives each one a name, and running make with no arguments prints
the list rather than silently running whatever target happens to be first.
The split that matters is between the targets that need a running Chirp and the
ones that do not. signup, demo and serve talk to the service you started in Step 3.
test and swap start their own on a throwaway data file, so they work whether
or not anything else is running.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# The Chirp service this sidecar talks to. Override PORT if 8787 is taken.
PORT ?= 8787
CHIRP_URL ?= http://127.0.0.1:$(PORT)
# Not USER: that is always set in the shell, so `?=` would never fire.
CHIRP_USER ?= alice
.PHONY: help install test serve-chirp signup swap demo serve clean reset
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-13s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
test: ## Run the test suite
uv run pytest -q
serve-chirp: ## Run the Chirp service in this terminal (Ctrl-C to stop)
uv run python chirpd.py --port $(PORT)
signup: ## Create a Chirp account: make signup CHIRP_USER=alice
@curl -sS -X POST $(CHIRP_URL)/accounts \
-H 'content-type: application/json' \
-d '{"username":"$(CHIRP_USER)"}' | python3 -m json.tool
swap: ## Prove the scoping: same code, two environments, two accounts
uv run python swap.py
demo: ## Drive the sidecar using this shell's CHIRP_* variables
uv run python demo.py
serve: ## Run the sidecar over stdio, using this shell's environment
uv run python server.py
reset: ## Delete the Chirp data file, wiping all accounts and posts
rm -f chirp-data.json
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpis what makes a baremakeprint the list. Without it,makeruns the first target in the file, which is a surprising way to start a server.- The help text lives in
##comments on each target line, so a new target appears in the help screen automatically. A hand-maintained list drifts the first time someone is in a hurry. PORT,CHIRP_URLandCHIRP_USERuse?=, so each can be overridden on the command line:make signup CHIRP_USER=bob, ormake serve-chirp PORT=8888if something already holds 8787. The variable isCHIRP_USERrather thanUSERfor a specific reason:USERis already in your environment, make imports the environment, and?=only assigns when a variable is unset. AUSER ?= alicedefault would silently never apply, andmake signupwould create an account named after your macOS login.demoandserveset no variables. They inherit the shell’s environment on purpose, because the point of the pattern is that the environment is the configuration. A target that hardcoded a credential would undermine the lesson and put a secret in a tracked file.resetdeletes accounts as well as posts, since both live in the same file. It is the way out of a lost app password.signupreports a connection failure rather than swallowing it.curl -sSkeeps errors while dropping the progress meter, so running it with Chirp stopped printscurl: (7) Failed to connectbefore the JSON parse error, which names the actual problem.
Confirm the default target prints the help screen:
make
help Show this help screen
install Sync runtime and dev dependencies
test Run the test suite
serve-chirp Run the Chirp service in this terminal (Ctrl-C to stop)
signup Create a Chirp account: make signup CHIRP_USER=alice
swap Prove the scoping: same code, two environments, two accounts
demo Drive the sidecar using this shell's CHIRP_* variables
serve Run the sidecar over stdio, using this shell's environment
reset Delete the Chirp data file, wiping all accounts and posts
clean Remove caches
Step 12: Know what user-scoping does not protect
The pattern gives a real guarantee and it is narrow: this process can only act as one account. Being clear about the edges is what keeps it from being oversold, and each of these has a mitigation that belongs in a different article.
Anyone who can reach the server acts as you. There is no per-caller identity, because there are no callers other than the client on your machine. That is fine for a stdio sidecar, and it stops being fine the moment the server is exposed over HTTP. If you deploy this behind a network port, it needs authentication of its own, which is the problem Verify Signed JWTs with JWKS in a FastMCP Server on macOS and Fine-Grained Authorization for a FastMCP Server on macOS solve.
The model decides what to post. publish_post takes text from the caller,
and the caller is a language model that may have read a web page, an email, or a
tool result written by someone else. Prompt injection that reaches a posting
tool is a publishing incident. CHIRP_ALLOW_PUBLISH and the client’s approval
prompt are the two controls in this build;
Harden an MCP Server: A Threat Model and Defenses on macOS
covers the rest.
The credential is long-lived and sits in a config file. Chirp’s app passwords never expire and there is no revocation endpoint, which is a simplification a real service would not make. Against a real one, use a per-machine credential so revocation is targeted, and treat the client config the way you would treat any file holding a secret.
A local service proves wiring, not policy. Step 9 shows the sidecar writes
with the token its own session returned, and tests/test_chirpd.py shows Chirp
attributes posts by token rather than by request body. Neither shows that a real
service enforces anything, because Chirp is not that service. When you swap it
in, the enforcement you are relying on becomes theirs, so read their
documentation on what a scoped credential can actually do.
Recap
The article set out to build an MCP server that posts to one account and cannot
reach another, with the account supplied by the environment. That is working:
swap.py runs the same create_server twice and the service reports two
distinct accounts, and Step 10 has Claude Code launch the sidecar and post
through it.
The shape generalizes past Chirp. Any service where you hold a personal credential fits it: a calendar, a task tracker, a home automation hub. The three parts that carry over are a single module where account data enters the process, a startup that fails loudly when that module has nothing to work with, and a capability flag separate from the credentials so a read-only instance is one env var away.
And the naming, since the question that started this has a practical answer. Call it user-scoped when you are describing what it guarantees, sidecar when you are describing where it runs, and instance-specific when you are explaining to a colleague why they need their own env block. The first is the one that belongs in your README.
Next improvements
- Point it at something real. Replace
chirp_client.pywith a client for a service you actually use. If the sidecar needs changes beyond that file, the boundary was in the wrong place. - Move the credential to the Keychain. Reading the app password with
security find-generic-passwordat startup keeps it out of the client config file, using the pattern in Call an External API from a FastMCP Tool on macOS. - Return typed results. The tools return plain dictionaries. Declaring Pydantic models gives clients an output schema to validate against, as in Return Structured Output from a FastMCP Server on macOS.
- Add a delete tool. Chirp has no way to remove a post, which makes
destructive_hint=Trueundemonstrated. Adding one is the natural next exercise in tool annotations. - Log what was published. A user-scoped server acting on a model’s instructions should keep its own record of what it sent, so a surprising post can be traced without relying on the service’s own timeline.