Skip to main content
Glama
HamzaOuadid

mcp-starter-template

by HamzaOuadid

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.

Related MCP server: Kroki MCP

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) -> TicketId — write, 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.connect → psycopg2/asyncpg) plus AUTOINCREMENT → SERIAL/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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    A secure MCP server example that demonstrates how to deploy to Google Cloud Run with authentication and identity token protection. Serves as a tutorial template for building production-ready MCP servers in the cloud.
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    A production-ready MCP server scaffold that features built-in authentication, Docker support, and a comprehensive CI/CD release pipeline. It provides a standardized template for deploying servers with multi-transport support and configurable read-only modes.
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    A production-ready foundation for building secure, observable MCP servers with built-in authentication, rate limiting, and reference tools like database-query and semantic-search.
    15
    42 npm
    MIT