mcp-starter-template
Click on "Deploy 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., "@mcp-starter-templatesearch the docs for rate limiting"
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.
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 |
| 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 |
| 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 |
Dry-run mode |
| 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 |
| 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 |
Structured audit log |
| 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 + SQLiteAuth middleware (
auth.py) resolves a bearer token to aUserviaMockIdentityProvider(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 againstserver.yaml'stools: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 inallowed_write_tools; it's still visible inlist_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: booland, forcreate_ticket, never touchesTicketSystemClient.create(the stand-in downstream API) when it's true — it returns a syntheticDRYRUN-...id instead.dryrun.pyformats the[DRY RUN]audit line.Rate/spend limiter (
limiter.py) is a fixed-window counter persession_id:calls_per_minandcost_per_session(tool cost comes from the registry) reset together everywindow_seconds. Denied calls don't themselves consume budget.Audit log (
audit.py) writes JSON-lines to a file and mirrors every record into a SQLiteaudit_logtable 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,tokenandsession_idare explicit tool arguments — a common, documented simplification for local/dev MCP servers. This is what an actual MCP client (Claude Desktop, themcpCLI, etc.) would talk to.http_app.py— a FastAPI HTTP transport where the token comes from a realAuthorization: Bearer <token>header and the session fromX-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 fromalice(engineering) andbob(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_registryconfig (server.yamltools:section):read_only, cost_units, descriptionper tool name.session_limits: in-memory per-session window (call_count, cost_used, reset atwindow_seconds), driven byrate_limit:inserver.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 |
| token missing or not recognized |
| write tool called but not in |
| session exceeded |
| unknown tool name |
| handler raised |
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 toolsReal 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 demoActual 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 8000curl 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-stdioThis 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: 5To 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/ -vReal 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 cleanTOOL_NOT_FOUND.Dry-run (
test_dry_run.py) — spies onTicketSystemClient.createdirectly to assert it's genuinely never invoked in dry-run, not just that the response looks synthetic; usescaplogto 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'sTestClientand through the realFastMCPserver's asynccall_tool/list_tools, not just through the transport-agnostic core.CLI (
test_cli.py) —toolsanddemo(with and without--allow-writes) run end to end without error viatyper.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-pythonfloor 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_logtable inaudit.py) is plain SQL with no SQLite-only syntax, so migrating to Postgres later is a driver swap (sqlite3.connect→psycopg2/asyncpg) plusAUTOINCREMENT→SERIAL/IDENTITY, not a redesign.Auth passthrough over stdio uses an explicit
tokenargument, 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'sAuthorizationheader 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 implementingAuthMiddleware.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 aUser.Cut: v0.1 git tag. Milestone 4 calls for tagging a
v0.1release. 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_registrytables. The spec's data model listssession_limitsandtool_registryas tables alongsideaudit_log.tool_registryclassification lives inserver.yaml(arguably a better single source of truth than a DB table a reviewer would have to query), andsession_limitsis 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 limitsLicense
MIT — see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
An MCP server that provides an API to LLMs to manage their JumpCloud resources.
- StytchOAuthdev.stytch.mcp
The Stytch MCP server is a reference implementation that demonstrates remote MCP server authentication and authorization using Stytch Connected Apps. It provides OAuth 2.1-compliant authorization (including PKCE), Dynamic Client Registration, and validates Stytch-issued access tokens to enable AI agents to securely interact with external services through permissioned access, supporting scopes like openid, email, profile, and manage:project_data.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA 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.-
- AlicenseNot gradedqualityDmaintenanceA 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
- AlicenseAqualityCmaintenanceA production-ready foundation for building secure, observable MCP servers with built-in authentication, rate limiting, and reference tools like database-query and semantic-search.1542 npmMIT
- AlicenseNot gradedqualityDmaintenanceA production-ready MCP server template with OAuth 2.1, RBAC, and audit logging for building secure, observable tool servers.MIT