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.

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.

-
license - not tested
-
quality - not tested
B
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.

Related MCP Connectors

  • A paid remote MCP for CLI tool MCP, built to return verdicts, receipts, usage logs, and audit-ready

  • Hosted MCP server for agent governance: MCP config audits, injection scans, scope-policy checks.

  • The official MCP Server from Mia-Platform to interact with Mia-Platform Console

View all MCP Connectors

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/HamzaOuadid/mcp-starter-template'

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