mcp-starter-template
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., "@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.
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 installed
Maintenance
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
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/HamzaOuadid/mcp-starter-template'
If you have feedback or need assistance with the MCP directory API, please join our Discord server