Skip to main content
Glama
cdotta

remote-mcp-starter

by cdotta

remote-mcp-starter

CI

A remote MCP server you can actually put in production. Any agent — a claude.ai custom connector, Claude Code, Cursor, a Telegram bot — connects to it over plain HTTPS with an Authorization: Bearer <token> header, and gets a per-user, tenant-isolated view of your data. It was extracted from a production bullet-journal app and stripped down to a single trivial domain ("notes") so the plumbing is the point. Swap the notes domain for yours; keep the auth, transport, isolation, error, and logging layers exactly as they are.

Most MCP examples are local stdio toys: one process, one user, no auth, no network. This is the other thing — the one you reach for when you've already found those examples and are still asking "how do I run a real remote MCP server with auth and multi-tenancy?"

What you get

  • Stateless Streamable HTTP — no session store; any instance serves any request. Fits serverless and horizontal scale-out.

  • PAT auth — Personal Access Tokens via Better Auth's apiKey plugin, hashed at rest, raw key shown exactly once.

  • Per-request tenant isolation — every query is scoped to the calling user; cross-tenant access is indistinguishable from a missing row.

  • Agent-friendly errors — validation and not-found failures come back as readable text an LLM can self-correct on, not opaque JSON-RPC error codes.

  • Log discipline — one structured line per request and per tool call; tokens, arguments, and content are never logged.

  • A real integration suite — 36 tests, zero mocks, running the whole stack in-process against an ephemeral Postgres schema.

Stack: Hono · Better Auth · @modelcontextprotocol/sdk >=1.26 · Prisma 7 + Postgres · TypeScript (ESM, Node >=22).


Quick start

You need Node >=22, pnpm, and Docker (for local Postgres).

git clone <your-fork-url> remote-mcp-starter
cd remote-mcp-starter
pnpm install

cp .env.example .env
# Generate a real secret and paste it into BETTER_AUTH_SECRET:
openssl rand -base64 32

docker compose up -d      # Postgres 17 on host port 5433
pnpm exec prisma db push  # create tables (also runs prisma generate)
pnpm dev                  # tsx watch on http://localhost:3000

docker-compose.yml publishes Postgres on 5433 (not 5432) so it won't collide with a Postgres you may already run. .env.example already points DATABASE_URL there.

Drive it with curl

The flow is: sign up (browser-style, session cookie) → mint a PAT with that cookie → use the PAT as a Bearer token for MCP. Agents only ever hold the PAT.

# 1. Sign up. Better Auth sets a `better-auth.session_token` cookie.
curl -i -X POST http://localhost:3000/api/auth/sign-up/email \
  -H 'Content-Type: application/json' \
  -c cookies.txt \
  -d '{"name":"Ada","email":"ada@example.com","password":"password12345"}'

# 2. Mint a PAT (cookie-authenticated). The raw key is returned ONCE — save it.
curl -X POST http://localhost:3000/api/tokens \
  -H 'Content-Type: application/json' -b cookies.txt \
  -d '{"name":"my-laptop"}'
# => {"key":"PfGE...cWPF","id":"LhMN...","name":"my-laptop","start":"PfGEBZ","createdAt":"..."}

export PAT="PfGE...cWPF"   # paste the "key" value from above

Now talk MCP. Note the Accept header: the Streamable HTTP transport requires the client to advertise both JSON and SSE on POST, even though this server is configured to always answer with a single JSON response.

# initialize
curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $PAT" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2024-11-05","capabilities":{},
        "clientInfo":{"name":"curl","version":"0"}}}'

# tools/list  ->  ping, list_notes, create_note, update_note, delete_note
curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

# tools/call create_note
curl -X POST http://localhost:3000/mcp \
  -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
        "name":"create_note","arguments":{"content":"Buy milk"}}}'

A call with no (or a bad) token returns a bare HTTP 401 with an empty body — no JSON-RPC envelope. GET /mcp returns 405 with Allow: POST (there is no SSE stream to negotiate in stateless mode).

Run the suite anytime (needs docker compose up -d):

pnpm test        # 36 passing integration tests
pnpm typecheck   # tsc on src + tests

Related MCP server: OmniAudit MCP

Connect an agent

Remote MCP needs three things that local stdio doesn't: a reachable HTTPS URL, a Bearer token, and the HTTP (Streamable HTTP) transport instead of a stdio subprocess. Point any of these at https://<your-host>/mcp.

Claude Code

claude mcp add --transport http notes https://your-app.fly.dev/mcp \
  --header "Authorization: Bearer $PAT"

claude.ai custom connector — Settings → Connectors → Add custom connector. Set the URL to https://your-app.fly.dev/mcp and supply the PAT as the bearer token / Authorization: Bearer <PAT> header.

Generic MCP client config (Cursor, and anything that reads the standard mcpServers shape):

{
  "mcpServers": {
    "notes": {
      "type": "http",
      "url": "https://your-app.fly.dev/mcp",
      "headers": { "Authorization": "Bearer PfGE...cWPF" }
    }
  }
}

For local development the URL is http://localhost:3000/mcp. In production it must be HTTPS — you are shipping a bearer token on every request.


The decisions

This is the part worth reading. Each choice below is a small thing that's easy to get wrong and annoying to debug.

Per-request server factory; identity from a closure, never from authInfo

createMcpServer(userId) in src/mcp.ts builds a fresh McpServer per request, with userId captured in the closure. Every tool handler reads that closure — it is the single source of truth for "who is calling." We deliberately never read the id from the SDK's per-call authInfo field, which is unreliable in stateless (session-less) mode. Constructing a new server and transport per request isn't just tidy: the SDK (>=1.26) throws if you reuse a stateless Streamable HTTP transport across requests, and a per-request instance is defense-in-depth against identity bleed between callers (the class of bug behind CVE-2026-25536). One request, one server, one user.

Stateless Streamable HTTP

The transport is created with sessionIdGenerator: undefined (no session persistence) and enableJsonResponse: true (answer with a single JSON body, not an SSE stream). Stateless means there's no server-side session to store or route back to, so any instance can serve any request — which is exactly what you want on serverless or behind a load balancer with N replicas. What you give up: server-initiated messages. No SSE push, no long-lived streaming, no notifications/* from server to client. For a request/response tool server (which is most of them), that's a non-loss.

The transport pitfall: Web-Standard vs. Node

There are two similarly named transports in the SDK. src/server.ts imports WebStandardStreamableHTTPServerTransport from .../server/webStandardStreamableHttp.js — it speaks the Web Fetch Request/Response API. Hono hands you exactly that as c.req.raw, so transport.handleRequest(c.req.raw) returns a Response you return directly. The other one, in .../server/streamableHttp.js, is the Node (IncomingMessage/ServerResponse) wrapper and expects a completely different argument shape. Import the wrong one and nothing type-errors until it fails at runtime. On a Fetch-based framework (Hono, and most edge runtimes), use the Web-Standard transport.

Auth is a boundary, not a tool

Look at the order in POST /mcp: verifyPat() runs before createMcpServer is ever called. On any failure it throws a bare 401 Response, so an unauthenticated caller is rejected before a single line of protocol code runs and gets a plain HTTP 401 with an empty body — not a JSON-RPC error object. This is deliberate. Protocol machinery shouldn't execute for traffic that hasn't earned it, and the MCP spec places authorization at the HTTP layer, so a transport-level 401 is the correct, spec-aligned answer. Unauthenticated callers never learn anything about the protocol behind the door.

Better Auth apiKey: hashing and the referenceId gotcha

PATs are Better Auth API keys. disableKeyHashing is left at its default, so keys are hashed at rest — the raw token exists only in the HTTP response at creation time and is never recoverable. customAPIKeyGetter overrides the plugin's default x-api-key header so tokens arrive the way MCP clients send them: Authorization: Bearer <token> (anything else returns null, keeping the cookie-session path fully separate). The sharp edge: in Better Auth 1.5+ the key's owner is result.key.referenceId, not result.key.userId (which no longer exists) — and result.key can be null even when result.valid is true. verifyPat() guards both conditions before trusting the id. The Prisma Apikey.referenceIdUser relation is hand-added (the Better Auth CLI won't emit it) so deleting a user cascades to their keys.

Tenant isolation & IDOR

Every query in src/notes.ts is userId-scoped. Writes and deletes are expressed as updateMany({ where: { id, userId, deletedAt: null } }) so an id owned by another tenant simply matches zero rows. When that happens — or when a row is genuinely missing — the layer throws the same 404. We never distinguish "doesn't exist" from "exists but isn't yours," because doing so would leak the existence of other tenants' data (an IDOR information leak). At the tool layer that 404 becomes one generic envelope: "Note not found or not owned by you." Deletes are soft (deletedAt timestamp), so rows are hidden from reads but never physically removed; the composite @@index([userId, deletedAt]) matches the hot "my non-deleted notes" query.

Agent-friendly errors

Tools don't throw JSON-RPC protocol errors for expected failures. They return the MCP isError envelope with a flat, human-readable message — a Zod validation failure becomes "Validation failed: content must be at most 500 characters", not a nested error object. An LLM client can read that and self-correct on the next turn. Success envelopes carry both a content text block (JSON-stringified) and a structuredContent object, so clients that want structured output and clients that only read text both work. See withToolLogging, errorEnvelope, ok, and formatZodErrors in src/mcp.ts.

Log discipline

Exactly one structured line per HTTP request:

mcp method=POST user=<userId> status=200 ms=12

and one per tool call:

mcp_tool tool=create_note user=<userId> status=ok ms=4

That's the whole log surface. The Authorization header, the request body, tool arguments, tool results, and note content are never logged. Journals and notes are sensitive; the logs are safe to ship to any aggregator.

Testing: no mocks, ephemeral schema

The suite in tests/ exercises the real stack. signUp() hits the real Better Auth endpoint, createPat() mints a real hashed key, and mcpCall() sends real JSON-RPC over app.request("/mcp", …) — Hono's in-process request driver, so every route, Better Auth, the transport, and Prisma all run, but no TCP port is opened (src/server.ts exports app and only calls serve() when it's the process entry point). Nothing is stubbed; a separate direct Prisma client asserts row-level facts like "the raw key is not in the database."

Each pnpm test run provisions its own throwaway Postgres schema, mcp_test_<pid>, inside the docker-compose database, pushes the Prisma schema into it, and drops it on teardown — so tests never read or clobber your public dev data. The only manual prep is docker compose up -d.

One caveat worth knowing: vitest 4 runs test workers in forked child processes, and env mutations made in globalSetup don't reliably cross the fork boundary. So the ephemeral-schema connection info is written to a temp file and re-loaded per worker in setupFiles (see tests/integration/setup.ts, worker-setup.ts, schema-config.ts). The suite pins pool: "forks" with maxWorkers: 1 so the shared schema has a single writer and the logs stay linear.

The 36 tests cover: the MCP protocol surface (initialize, tools/list, every tool happy path, validation → isError, soft-delete visibility), the auth boundary (401-before-JSON-RPC, 405 on GET), the full PAT lifecycle (create → verify → revoke → denied, hashed-at-rest, raw-key-shown-once), tenant isolation / IDOR across users, and the token-management REST API.


Make it yours

The notes domain is a placeholder. To model your own resource, change these files in this order and leave everything else alone:

  1. prisma/schema.prisma — replace the Note model with yours (keep the userId FK to User and, if you want reversible deletes, a nullable deletedAt plus a @@index([userId, deletedAt])). Run pnpm exec prisma db push (or pnpm migrate for a versioned migration).

  2. src/notes.ts — rename to your domain and rewrite the query functions. Keep the invariants: every query userId-scoped, the shared 404 for missing-or-not-owned, and Zod validation at the boundary.

  3. src/mcp.ts — swap the registerTool calls for your tools. Reuse withToolLogging, ok, and errorEnvelope as-is.

  4. tests/ — point the existing suites at your tools and models.

Do not touch the auth boundary (src/auth.ts, the verifyPat-first order in src/server.ts), the transport wiring, the envelope/error helpers, or the logging format. Those are the reusable spine; the domain is the only replaceable part.


Deploy notes

Any Node >=22 host works — it's a plain Hono app behind @hono/node-server. pnpm build runs prisma generate, compiles to dist/, and copies the generated client; pnpm start runs node dist/server.js.

The app this was extracted from runs on Fly.io with a managed Postgres (Neon). The essentials for any target:

  • Env vars: DATABASE_URL, BETTER_AUTH_SECRET (openssl rand -base64 32), BETTER_AUTH_URL (the server's public origin — Better Auth validates the Origin header of state-changing auth requests against it), and PORT.

  • HTTPS is mandatory. You ship a bearer token on every request; terminate TLS at the platform's proxy and never expose the plain-HTTP port.

  • Sign-up is open by default. Anyone who can reach /api/auth/sign-up/email can create an account — there is no email verification or rate limiting out of the box. If your instance isn't meant to be open-registration, gate it before going live (Better Auth's rateLimit and email-verification options, or disable sign-up entirely and provision accounts out of band).

  • Scale out freely. Stateless mode means there is no session affinity — every replica can serve every request, so you can run N instances behind a plain round-robin load balancer.


Security checklist

  • HTTPS only in production — bearer tokens travel on every request.

  • PATs hashed at rest (disableKeyHashing left default) — raw key shown once, at creation.

  • Revocation via DELETE /api/tokens/:id (ownership pre-checked; returns the same result for missing and not-owned keys).

  • No token logging — never log the Authorization header, request bodies, tool args, or content.

  • Tenant scoping everywhere — every query filtered by userId; cross-tenant access returns the same 404 as a missing row.

  • 401 before protocol — verify the PAT before constructing any MCP server; unauthenticated callers get a bare 401, never a JSON-RPC envelope.

  • Pin the SDK >=1.26 — earlier versions don't guard stateless transport reuse (CVE-2026-25536).


Contributing

Issues and PRs welcome. Before opening a PR: pnpm typecheck && pnpm test must be green (start Postgres first with docker compose up -d). Keep the auth, transport, isolation, error, and logging layers intact — changes there need a clear rationale and test coverage.

License

MIT — see LICENSE. Extracted from a production bullet-journal app and distilled down to the plumbing. Built on Hono, Better Auth, Prisma, and the Model Context Protocol TypeScript SDK.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cdotta/remote-mcp-starter'

If you have feedback or need assistance with the MCP directory API, please join our Discord server