remote-mcp-starter
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@remote-mcp-starterlist my notes"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
remote-mcp-starter
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
apiKeyplugin, 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:3000docker-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 aboveNow 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 + testsRelated 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.referenceId → User 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=12and one per tool call:
mcp_tool tool=create_note user=<userId> status=ok ms=4That'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:
prisma/schema.prisma— replace theNotemodel with yours (keep theuserIdFK toUserand, if you want reversible deletes, a nullabledeletedAtplus a@@index([userId, deletedAt])). Runpnpm exec prisma db push(orpnpm migratefor a versioned migration).src/notes.ts— rename to your domain and rewrite the query functions. Keep the invariants: every queryuserId-scoped, the shared404for missing-or-not-owned, and Zod validation at the boundary.src/mcp.ts— swap theregisterToolcalls for your tools. ReusewithToolLogging,ok, anderrorEnvelopeas-is.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 theOriginheader of state-changing auth requests against it), andPORT.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/emailcan 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'srateLimitand 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 (
disableKeyHashingleft 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
Authorizationheader, 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.
This server cannot be installed
Maintenance
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
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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