mcp-starter-template

mcp-starter-template

A reference MCP server scaffold with per-user auth passthrough, read-only defaults, dry-run mode, and rate/spend capping, enabling developers to build secure MCP integrations with structured audit logging.

Category
Visit Server

README

mcp-starter-template

A reference MCP server scaffold encoding security patterns most public MCP examples skip: per-user auth passthrough (never a shared service account), read-only tools by default with explicit write opt-in, a dry-run mode for write tools, and a per-session spend/rate cap with structured denials instead of silent no-ops or crashes. Every guardrail is backed by an automated test, not just a docstring.

Built as a portfolio piece after auditing and stripping dead tool handlers out of a production MCP fork that had none of these guardrails.

A sibling project, mcp-issue-tracker, reuses this exact security architecture (auth passthrough, allowlist-gated writes, dry-run, rate limiting, audit trail) applied to a real, local issue-tracker domain — same pattern, proven twice rather than once.

Why this exists

Most public MCP server examples wire an assistant straight to a service account with full permissions and no guardrails. That's how an assistant answering a reasonable question ends up leaking data the asker shouldn't see, or silently executing a write nobody approved. This repo is what the safer default looks like, small enough to read end to end in one sitting.

Guardrails, and what each one prevents

Guardrail Where What it prevents
Auth passthrough auth.py, identity.py A tool call ever running under a shared/blanket credential. Every call resolves this specific caller's identity and every downstream check uses that identity, not an admin/service account. Prevents "assistant sees everything the service account can see, regardless of who asked."
Read-only by default + explicit write allowlist registry.py, server.yaml A newly-added or mis-configured write tool executing before anyone has explicitly reviewed and enabled it. A tool is only ever callable as a write if its name is in allowed_write_tools; everything else is inert. Prevents "we added a tool and forgot it could delete things."
Dry-run mode tools/tickets.py, dryrun.py, server.py A write tool's real downstream side effect firing while an operator is still validating behaviour. In dry-run, the real API client is never called at all — verified in tests by spying on the client method itself, not just by inspecting the response. Prevents "we tested in prod because dry-run secretly still wrote."
Per-session rate/spend cap limiter.py An unbounded or runaway client burning spend or hammering a downstream API. Once a session's window budget (calls or cost units) is exhausted, every subsequent call in that window is rejected with a structured error and a retry_after, not just the one call that tripped it. Prevents "a bug in the client turned into an unbounded API bill."
Structured audit log audit.py A security incident being unreconstructable after the fact. Every call — allowed or denied, read or write, dry-run or real — is written as one JSON-lines record and one SQLite row: timestamp, session, user, tool, read/write, dry-run flag, allowed flag, latency. Prevents "we don't actually know what happened."

Architecture

                         ┌─────────────────────────────┐
   MCP client  ───────▶  │      transport adapter      │
 (stdio / HTTP)          │  mcp_app.py  /  http_app.py  │
                         └──────────────┬───────────────┘
                                        │ token, session_id, tool_name, args
                                        ▼
                         ┌─────────────────────────────┐
                         │      MCPStarterServer        │   server.py — single
                         │        .call_tool()          │   choke point every
                         └──────────────┬───────────────┘   call passes through
                    1) resolve tool ────┤
                    2) authenticate ────┤──▶ AuthMiddleware ──▶ MockIdentityProvider
                    3) allowlist check ─┤──▶ ToolRegistry
                    4) rate/spend check ┤──▶ SessionLimiter
                    5) execute ─────────┤──▶ tool handler (search_docs / create_ticket)
                    6) audit log ───────┴──▶ AuditLogger ──▶ audit.jsonl + SQLite
  • Auth middleware (auth.py) resolves a bearer token to a User via MockIdentityProvider (identity.py) — clearly labeled dev-only, seeded with two distinct test users (alice/engineering, bob/sales) plus an admin. Missing or unrecognized tokens are rejected; there is no fallback identity.
  • Tool registry (registry.py) is the single place every tool's read/write classification lives, cross-checked at registration time against server.yaml's tools: section — a mismatch between what the code declares and what the config says refuses to start up. A write tool is only callable once its name is in allowed_write_tools; it's still visible in list_tools() either way, so a reviewer can see the full surface area, not just what's currently enabled.
  • Dry-run wrapper: each write tool's handler takes a dry_run: bool and, for create_ticket, never touches TicketSystemClient.create (the stand-in downstream API) when it's true — it returns a synthetic DRYRUN-... id instead. dryrun.py formats the [DRY RUN] audit line.
  • Rate/spend limiter (limiter.py) is a fixed-window counter per session_id: calls_per_min and cost_per_session (tool cost comes from the registry) reset together every window_seconds. Denied calls don't themselves consume budget.
  • Audit log (audit.py) writes JSON-lines to a file and mirrors every record into a SQLite audit_log table matching the spec's data model, so it can be tailed as text or queried with SQL.

Two transports wrap the same MCPStarterServer core:

  • mcp_app.py — a real MCP stdio server built on the official MCP Python SDK (FastMCP). Since stdio is a single local process with no per-request headers, token and session_id are explicit tool arguments — a common, documented simplification for local/dev MCP servers. This is what an actual MCP client (Claude Desktop, the mcp CLI, etc.) would talk to.
  • http_app.py — a FastAPI HTTP transport where the token comes from a real Authorization: Bearer <token> header and the session from X-Session-Id, the shape a genuine multi-tenant deployment would use.

Example tools

  • search_docs(query) -> list[DocResult]read-only. Searches a small static in-memory corpus, filtered to docs visible to the calling user's team (or company-wide docs). This is what makes auth passthrough provable: the same query from alice (engineering) and bob (sales) returns different results.
  • create_ticket(title, body) -> TicketIdwrite, allowlist-gated. Stands in for a real ticketing API (TicketSystemClient); dry-run intercepts before that client is ever touched.

Data model

  • audit_log: timestamp, session_id, user_id, tool_name, read_or_write, dry_run, allowed, latency_ms, error_code, detail — SQLite table + JSON-lines file, written on every call.
  • tool_registry config (server.yaml tools: section): read_only, cost_units, description per tool name.
  • session_limits: in-memory per-session window (call_count, cost_used, reset at window_seconds), driven by rate_limit: in server.yaml.

Error contract

Every rejection is a structured MCPError{code, message, retry_after?, details?} — never a bare exception or a silent no-op:

code when
UNAUTHENTICATED token missing or not recognized
WRITE_NOT_ALLOWED write tool called but not in allowed_write_tools
RATE_LIMIT_EXCEEDED session exceeded calls_per_min or cost_per_session
TOOL_NOT_FOUND unknown tool name
INVALID_ARGUMENTS handler raised TypeError on the given arguments

Over HTTP these map to 401 / 403 / 429 / 404 / 400 respectively, with the same {code, message, ...} body in the response's detail.

Install

Requires Python 3.10+ (developed and tested on 3.10; the spec called for 3.11+ — see Deviations below for why 3.10 was used instead).

git clone https://github.com/HamzaOuadid/mcp-starter-template.git
cd mcp-starter-template
pip install -e ".[dev]"

Usage

List the tool registry (security review)

mcp-starter tools

Real output from this repo:

  create_ticket    WRITE [DISABLED (not allowlisted)] cost=5   Create a ticket in the downstream ticket system (write, allowlist-gated).
  search_docs      read-only                        cost=1   Search internal docs visible to the calling user's team (read-only).

Run the worked "what this prevents" demo

This is the milestone 4 deliverable: simulate the two-test-user scenario end to end and show the permission boundary holding, using the real server.yaml shipped in this repo (dry_run: true, empty allowed_write_tools).

mcp-starter demo

Actual output from a real run against this repo's server.yaml (rate_limit.calls_per_min: 5):

=== 1. Per-user auth passthrough: same tool, same query, different results ===
  alice (engineering): sees docs ['eng-001', 'eng-002', 'all-001']
  bob (sales): sees docs ['sales-001', 'sales-002', 'all-001']

=== 2. Missing/invalid identity is rejected, not defaulted ===
  token=None -> ok=False error={'code': 'UNAUTHENTICATED', 'message': 'Missing or invalid identity token; call rejected.'}

=== 3. Write tool default posture ===
  create_ticket denied: {'code': 'WRITE_NOT_ALLOWED', 'message': "Tool 'create_ticket' is a write tool and is not in allowed_write_tools. Add it to server.yaml's allowlist to enable it."}

=== 4. Rate limit: burst of calls past the cap ===
  call 1/6: allowed
  call 2/6: allowed
  call 3/6: allowed
  call 4/6: allowed
  call 5/6: allowed
  call 6/6: DENIED (RATE_LIMIT_EXCEEDED)

=== Audit log written to <repo>\demo_audit.jsonl ===
  {"allowed": true, "detail": "", "dry_run": false, "error_code": null, "latency_ms": 0.0, "read_or_write": "read", "session_id": "demo-burst-session", ...}
  {"allowed": true, ...}
  {"allowed": false, "error_code": "RATE_LIMIT_EXCEEDED", "detail": "Session 'demo-burst-session' exceeded its rate/spend cap (5 calls or 10 cost units per 60s window).", ...}

alice (engineering) and bob (sales) see disjoint doc sets plus the shared company-wide handbook (all-001) — the permission boundary holds from the exact same tool and query. A None token is rejected outright. create_ticket is refused because the allowlist is empty by default. The 6th call in a 5-call-per-minute session is denied with a structured error.

Run it with the write tool allowlisted (still dry-run, since that's the config default) to see the dry-run response shape:

mcp-starter demo --allow-writes
=== 3. Write tool default posture ===
  create_ticket allowed (allowlisted): TicketId(ticket_id='DRYRUN-8ffc09d5', dry_run=True)

No real ticket was created — TicketSystemClient.created stays empty in dry-run mode; this is asserted directly in tests/test_dry_run.py by spying on the client method itself.

Run the HTTP transport

mcp-starter serve-http --port 8000
curl http://127.0.0.1:8000/tools

curl -X POST http://127.0.0.1:8000/tools/search_docs/call \
  -H "Authorization: Bearer token-alice" \
  -H "X-Session-Id: demo-1" \
  -H "Content-Type: application/json" \
  -d '{"arguments": {"query": ""}}'

# Write tool, denied by default (empty allowlist):
curl -i -X POST http://127.0.0.1:8000/tools/create_ticket/call \
  -H "Authorization: Bearer token-alice" \
  -H "X-Session-Id: demo-1" \
  -H "Content-Type: application/json" \
  -d '{"arguments": {"title": "Broken build", "body": "CI red on main"}}'
# -> HTTP 403, {"detail":{"code":"WRITE_NOT_ALLOWED", ...}}

Dev tokens: token-alice (engineering), token-bob (sales), token-admin (engineering, admin flag set).

Run the real MCP stdio server

mcp-starter serve-stdio

This starts a real FastMCP stdio server — point an MCP client (e.g. the mcp CLI's mcp dev, or Claude Desktop's config) at python -m mcp_starter.mcp_app. Tools: search_docs(query, token, session_id), create_ticket(title, body, token, session_id), list_tools().

Configuration

Edit server.yaml:

dry_run: true                 # write tools log-and-simulate instead of executing
allowed_write_tools: []       # empty = no write tool is callable, by design
rate_limit:
  calls_per_min: 5
  cost_per_session: 10
  window_seconds: 60
tools:
  search_docs:
    read_only: true
    cost_units: 1
  create_ticket:
    read_only: false
    cost_units: 5

To actually enable ticket creation: add create_ticket to allowed_write_tools and set dry_run: false. Either one alone keeps it either invisible-to-writes or simulated.

Testing

pytest tests/ -v

Real output from this repo (40 tests, all passing):

tests/test_audit_log.py::test_audit_jsonl_reconstructs_a_session PASSED
tests/test_audit_log.py::test_audit_sqlite_table_matches_data_model PASSED
tests/test_audit_log.py::test_query_filters_by_session PASSED
tests/test_audit_log.py::test_rate_limit_denial_is_also_audited PASSED
tests/test_auth_passthrough.py::test_two_users_see_different_results_from_same_tool PASSED
tests/test_auth_passthrough.py::test_missing_token_is_rejected_not_defaulted PASSED
tests/test_auth_passthrough.py::test_invalid_token_is_rejected PASSED
tests/test_auth_passthrough.py::test_empty_string_token_is_rejected PASSED
tests/test_auth_passthrough.py::test_unknown_tool_name_does_not_crash PASSED
tests/test_cli.py::test_tools_command_lists_both_example_tools PASSED
tests/test_cli.py::test_demo_command_runs_full_scenario PASSED
tests/test_cli.py::test_demo_command_with_allow_writes_flag PASSED
tests/test_dry_run.py::test_dry_run_never_invokes_the_real_downstream_client PASSED
tests/test_dry_run.py::test_dry_run_logs_the_would_be_action_with_marker PASSED
tests/test_dry_run.py::test_dry_run_off_with_allowlist_actually_calls_downstream PASSED
tests/test_dry_run.py::test_dry_run_plus_write_tool_never_executes_even_when_allowlisted_repeatedly PASSED
tests/test_dry_run.py::test_read_only_tool_is_unaffected_by_dry_run_flag PASSED
tests/test_http_transport.py::test_list_tools_endpoint PASSED
tests/test_http_transport.py::test_auth_header_passthrough_two_users_differ PASSED
tests/test_http_transport.py::test_missing_auth_header_returns_401 PASSED
tests/test_http_transport.py::test_write_not_allowed_returns_403 PASSED
tests/test_http_transport.py::test_rate_limit_returns_429 PASSED
tests/test_http_transport.py::test_unknown_tool_returns_404 PASSED
tests/test_mcp_stdio.py::test_stdio_server_lists_all_three_tools PASSED
tests/test_mcp_stdio.py::test_stdio_server_two_users_differ PASSED
tests/test_mcp_stdio.py::test_stdio_server_write_tool_denied_by_default PASSED
tests/test_mcp_stdio.py::test_stdio_server_missing_token_rejected PASSED
tests/test_rate_limit.py::test_burst_of_n_plus_one_rejects_the_last_call PASSED
tests/test_rate_limit.py::test_calls_keep_being_rejected_until_window_resets PASSED
tests/test_rate_limit.py::test_cost_cap_is_enforced_independent_of_call_count PASSED
tests/test_rate_limit.py::test_sessions_are_isolated_from_each_other PASSED
tests/test_rate_limit.py::test_rate_limit_via_server_returns_structured_error PASSED
tests/test_rate_limit.py::test_denied_write_does_not_consume_rate_budget PASSED
tests/test_registry_allowlist.py::test_registry_describes_every_tool_classification PASSED
tests/test_registry_allowlist.py::test_all_write_tools_default_to_disabled PASSED
tests/test_registry_allowlist.py::test_write_tool_not_in_allowlist_is_denied PASSED
tests/test_registry_allowlist.py::test_write_tool_in_allowlist_becomes_enabled PASSED
tests/test_registry_allowlist.py::test_registration_refuses_undeclared_tool PASSED
tests/test_registry_allowlist.py::test_registration_refuses_classification_mismatch PASSED
tests/test_registry_allowlist.py::test_unknown_tool_call_is_tool_not_found PASSED

======================== 40 passed, 1 warning in 6.91s ========================

Coverage by concern:

  • Auth passthrough (test_auth_passthrough.py) — two mock users, same tool, different results; missing/invalid/empty token rejected, never defaulted; unknown tool name fails cleanly instead of crashing.
  • Registry / allowlist (test_registry_allowlist.py) — every tool's classification is inspectable; all write tools default to disabled; registration refuses tools missing from config or whose code/config classification disagree; unknown tool name is a clean TOOL_NOT_FOUND.
  • Dry-run (test_dry_run.py) — spies on TicketSystemClient.create directly to assert it's genuinely never invoked in dry-run, not just that the response looks synthetic; uses caplog to confirm the [DRY RUN] marker is actually logged; confirms the real client is called once dry-run is off and the tool is allowlisted; repeats the dry-run + allowlisted-write combination multiple times to guard against regression; confirms read-only tools are unaffected by the flag.
  • Rate limiting (test_rate_limit.py) — burst of N+1 rejects exactly the (N+1)th; calls keep being rejected for the rest of the window (not just the one that tripped it) via a fake clock; cost cap enforced independent of call count; sessions are isolated from each other; a denied write doesn't itself consume rate budget.
  • Audit log (test_audit_log.py) — JSONL and SQLite both capture a full session in enough detail to reconstruct who/what/allowed/dry-run; SQLite rows are filterable by session; rate-limit denials are captured in the trail too, not just successes.
  • Both transports (test_http_transport.py, test_mcp_stdio.py) — the same guardrails hold when driven through FastAPI's TestClient and through the real FastMCP server's async call_tool/list_tools, not just through the transport-agnostic core.
  • CLI (test_cli.py) — tools and demo (with and without --allow-writes) run end to end without error via typer.testing. CliRunner.

Deviations from the spec, and why

  • Python 3.10, not 3.11+. The dev/CI environment ships 3.10; nothing in this codebase uses a 3.11-only feature, so the requires-python floor was relaxed rather than blocking on an interpreter upgrade. CI pins 3.10 to match what's actually tested.
  • SQLite, not PostgreSQL, for the audit log. The spec allows either; Docker/Postgres aren't available in this environment. The audit schema (audit_log table in audit.py) is plain SQL with no SQLite-only syntax, so migrating to Postgres later is a driver swap (sqlite3.connectpsycopg2/asyncpg) plus AUTOINCREMENTSERIAL/IDENTITY, not a redesign.
  • Auth passthrough over stdio uses an explicit token argument, not a transport header. MCP's stdio transport is a single local process with no per-request headers, so there's nothing to intercept the way HTTP's Authorization header gives the HTTP transport (http_app.py) a real per-request credential. Passing the token explicitly keeps the effect (a resolved, non-default identity gating every call) identical and testable on both transports; it's a documented simplification, not a claim that stdio has "real" multi-user auth. A production multi-user deployment should run the HTTP transport, or a stdio transport wrapped by an authenticating proxy that injects the real credential upstream of this code.
  • No OAuth/JWT/mTLS in MockIdentityProvider. It's a static token→user dict, clearly dev-only per the spec's own risk note. Swapping in real verification means implementing AuthMiddleware.authenticate's token lookup against a real IdP; the rest of the pipeline (registry, limiter, dry-run, audit) is unaffected since it only depends on getting back a User.
  • Cut: v0.1 git tag. Milestone 4 calls for tagging a v0.1 release. This repo is commit-per-user-story rather than PR-per-milestone, so tagging is left for the maintainer to do once this lands on a default branch with CI green (git tag v0.1.0 && git push --tags) rather than self-tagging a repo that was never pushed anywhere.
  • Cut: no persistent session_limits/tool_registry tables. The spec's data model lists session_limits and tool_registry as tables alongside audit_log. tool_registry classification lives in server.yaml (arguably a better single source of truth than a DB table a reviewer would have to query), and session_limits is in-memory only (limiter.py), which is correct for a single-process starter but won't survive a restart or scale across processes — call out as the first thing to fix (e.g. Redis-backed counters) before running this behind more than one server process.
  • Two example tools, not three-plus. The spec asks for "2-3" — shipped exactly two (one read, one write), since a third read-only tool wouldn't exercise a guardrail the first two don't already cover.

Project layout

src/mcp_starter/
  identity.py    mock identity provider (dev-only) + User model
  auth.py        auth passthrough middleware
  config.py      server.yaml loading/validation (pydantic)
  registry.py    tool registry: classification + allowlist enforcement
  limiter.py     per-session fixed-window rate/spend limiter
  audit.py       JSONL + SQLite structured audit logging
  dryrun.py      "[DRY RUN]" audit-line formatting
  errors.py      structured MCPError + error codes
  server.py      MCPStarterServer.call_tool — the orchestration core
  mcp_app.py     real MCP stdio server (official MCP Python SDK)
  http_app.py    FastAPI HTTP transport (Authorization header passthrough)
  cli.py         `mcp-starter` CLI: tools / demo / serve-http / serve-stdio
  tools/
    docs.py      search_docs (read-only example tool)
    tickets.py   create_ticket (write example tool) + TicketSystemClient
tests/           37 tests across every guardrail and both transports
server.yaml      tool classification, allowlist, dry-run, rate limits

License

MIT — see LICENSE.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured