A reference MCP server for the most awkward kind of upstream: a web service with no public API, where the only way in is the same session cookie a browser uses. This pattern shows up everywhere: internal tools, legacy SaaS, vendor portals built before "API" was a checkbox. It has its own failure modes that token-bearer MCP servers never see.
This repo is the pattern, sanitized and self-contained. The fictional target is ReadShelf, a personal reading-list manager. It exists only as a Hono mock in this repo. The architecture is what matters.
src/
├── cookie-jar.ts # session jar; env or file source; age tracking; expiry warnings
├── fetch-client.ts # 5xx exp backoff, 401 → re-auth + retry, 429 Retry-After
├── tools.ts # 6 MCP tools wrapping ReadShelf operations
├── schemas.ts # zod response validators (schema-drift detection)
├── errors.ts # AuthError / RateLimitError / SchemaDriftError / NetworkError / TargetUnreachableError
├── logger.ts # JSON logger with mandatory cookie redaction
├── server.ts # MCP server factory
└── index.ts # stdio CLI entry
mock-target/
├── server.ts # Hono app pretending to be ReadShelf
└── fixtures.ts # 5 articles, 3 books, 2 papers
tests/ # vitest suite: cookie jar, fetch client, tools, e2e
1. Cookie jar. The session cookie is the credential. The jar loads it from
either READSHELF_SESSION_COOKIE (env, preferred for serverless) or from a
timestamp file on disk (long-running process). It tracks READSHELF_COOKIE_SET_AT
so the operator gets a warning before the cookie silently expires. The cookie
value is never written to the timestamp file, only the ISO timestamp.
2. Fetch client. Every upstream call goes through one resilient request().
It applies three retry policies in priority order:
5xx→ up to three retries with exponential backoff (1s, 2s, 4s).401→ optionally calls a re-auth hook, then retries once. Repeated 401 surfaces asAuthErrorso the MCP client knows to refresh the cookie.429→ honorsRetry-After(clamped at 30s by default), then retries.
After a successful 2xx, the JSON body is parsed against a zod schema. A
mismatch throws SchemaDriftError carrying a field-level diff, much easier
to triage than Cannot read property of undefined from inside a handler.
3. Tools. Six MCP tools wrap the upstream operations:
list_reading_list, add_to_list, mark_finished, search_library,
get_item_details, plus get_cookie_status for diagnostics. Every handler
catches the typed errors above and rewrites them into a stable user-facing
text response.
git clone https://github.com/addiplus/mcp-cookie-auth-reference
cd mcp-cookie-auth-reference
npm install
# Terminal 1: boot the mock ReadShelf target
npm run mock-target
# Terminal 2: grab a session cookie, then run the MCP server
COOKIE=$(curl -si -X POST http://localhost:4178/login \
-H 'Content-Type: application/json' \
-d '{"username":"demo","password":"demo"}' \
| grep -i '^set-cookie:' | sed -E 's/.*session_id=([^;]+).*/\1/')
READSHELF_SESSION_COOKIE="$COOKIE" \
READSHELF_BASE_URL=http://localhost:4178 \
npm startSee examples/quickstart.md for the full walkthrough.
The jar accepts either:
| Source | Env vars | Notes |
|---|---|---|
| env | READSHELF_SESSION_COOKIE, READSHELF_COOKIE_SET_AT |
Preferred for serverless. |
| file | READSHELF_SESSION_COOKIE, READSHELF_COOKIE_TIMESTAMP_FILE |
First-seen timestamp persisted. |
get_cookie_status returns:
Cookie source: env
Age: 12 minute(s)
Expires in: 20148 minute(s)
Status: OK
By default the jar warns at 7 days and reports isExpired: true past 14 days.
Override with the warnDays and maxAgeDays constructor options.
| Error | When |
|---|---|
AuthError |
401 after re-auth retry. Cookie likely expired. |
RateLimitError |
429 with no actionable Retry-After. |
SchemaDriftError |
2xx body fails zod parse (field-level issues attached). |
NetworkError |
5xx after retry budget, or unparseable JSON. |
TargetUnreachableError |
DNS / ECONNREFUSED / timeout, no HTTP status. |
Every error carries operation and url for log triage. None embed cookies.
logger.ts emits one JSON line per record. Any field whose key matches
/cookie/i is replaced with [REDACTED N chars] before serialization, even
when nested. The cookie-jar and fetch-client are tested for cookie leakage
into log output (see tests/fetch-client.test.ts).
npm test
tests/ contains:
cookie-jar.test.ts: env/file load, age thresholds, expiry warnings, redaction.fetch-client.test.ts: happy path, 401 → re-auth → retry, 429 + Retry-After, 500 → 3 retries → fail, schema drift, transport failure.tools.test.ts: every tool's happy path against an injected fetch.integration.test.ts: stands up the mock target on a random port, registers the tools, and runs end-to-end calls including a real cookie round-trip.
If you're building MCP servers for the open web, OAuth and bearer tokens cover most cases. If you're building MCP servers for real internal tools and legacy systems, you'll meet the cookie-auth pattern eventually. Worth having the design written down once, in a form that compiles.
MIT, see LICENSE.
Author: Joe Smith. Public work at github.com/addiplus.