mcp-starter-template
mcp-starter-template
Un scaffold de servidor MCP de referencia que codifica patrones de seguridad que la mayoría de los ejemplos públicos de MCP omiten: passthrough de autenticación por usuario (nunca una cuenta de servicio compartida), herramientas de solo lectura por defecto con opt-in explícito para escritura, un modo dry-run para herramientas de escritura, y un límite de gasto/tasa por sesión con denegaciones estructuradas en lugar de no-ops silenciosos o fallos. Cada protección está respaldada por una prueba automatizada, no solo por un docstring.
Construido como pieza de portafolio después de auditar y eliminar los manejadores de herramientas muertos de un fork MCP en producción que no tenía ninguna de estas protecciones.
Un proyecto hermano, mcp-issue-tracker, reutiliza esta misma arquitectura de seguridad (passthrough de autenticación, escrituras controladas por lista de permitidos, dry-run, límite de tasa, registro de auditoría) aplicada a un dominio real y local de seguimiento de incidencias: mismo patrón, probado dos veces en lugar de una.
Por qué existe esto
La mayoría de los ejemplos públicos de servidores MCP conectan a un asistente directamente a una cuenta de servicio con permisos completos y sin protecciones. Así es como un asistente que responde a una pregunta razonable termina filtrando datos que quien pregunta no debería ver, o ejecutando silenciosamente una escritura que nadie aprobó. Este repositorio es el aspecto que tiene un valor predeterminado más seguro, lo bastante pequeño como para leerse entero de una sentada.
Salvaguardas, y qué evita cada una
Salvaguarda | Dónde | Qué evita |
Passthrough de autenticación |
| Que una llamada de herramienta se ejecute bajo una credencial compartida o global. Cada llamada resuelve la identidad de este llamante concreto y cada comprobación posterior usa esa identidad, no una cuenta de administrador o de servicio. Evita que «el asistente vea todo lo que la cuenta de servicio puede ver, sin importar quién lo pidió». |
Solo lectura por defecto + lista de permitidos de escritura explícita |
| Que una herramienta de escritura recién añadida o mal configurada se ejecute antes de que alguien la haya revisado y habilitado explícitamente. Una herramienta solo es invocable como escritura si su nombre está en |
Modo dry-run |
| Que el efecto secundario real posterior de una herramienta de escritura se dispare mientras un operador sigue validando el comportamiento. En modo dry-run, el cliente de API real nunca se llama — verificado en las pruebas espiando el propio método del cliente, no solo inspeccionando la respuesta. Evita que «probamos en producción porque dry-run seguía escribiendo en secreto». |
Límite de tasa/gasto por sesión |
| Que un cliente sin límite o descontrolado queme gasto o golpee una API posterior. Una vez agotado el presupuesto de ventana de una sesión (llamadas o unidades de coste), cada llamada posterior en esa ventana se rechaza con un error estructurado y un |
Registro de auditoría estructurado |
| Que un incidente de seguridad no se pueda reconstruir después de los hechos. Cada llamada — permitida o denegada, de lectura o escritura, en dry-run o real — se escribe como un registro de JSON-lines y una fila de SQLite: timestamp, sesión, usuario, herramienta, lectura/escritura, indicador de dry-run, indicador de permitido, latencia. Evita que «en realidad no sabemos qué pasó». |
Arquitectura
┌─────────────────────────────┐
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 + SQLiteMiddleware de autenticación (
auth.py) resuelve un token de portador a unUsermedianteMockIdentityProvider(identity.py) — claramente marcado como solo desarrollo, inicializado con dos usuarios de prueba distintos (alice/engineering,bob/sales) además de un administrador. Los tokens ausentes o no reconocidos se rechazan; no hay identidad de respaldo.Registro de herramientas (
registry.py) es el único lugar donde reside la clasificación de lectura/escritura de cada herramienta, contrastada en el momento del registro contra la seccióntools:deserver.yaml— un desajuste entre lo que el código declara y lo que dice la configuración impide el arranque. Una herramienta de escritura solo es invocable una vez que su nombre está enallowed_write_tools; sigue siendo visible enlist_tools()de cualquier manera, para que un revisor pueda ver la superficie completa, no solo lo que está habilitado actualmente.Envoltorio dry-run: el manejador de cada herramienta de escritura toma un
dry_run: booly, paracreate_ticket, nunca tocaTicketSystemClient.create(la API posterior de sustitución) cuando es verdadero — devuelve un id sintéticoDRYRUN-...en su lugar.dryrun.pyformatea la línea de auditoría[DRY RUN].Limitador de tasa/gasto (
limiter.py) es un contador de ventana fija porsession_id:calls_per_minycost_per_session(el coste de la herramienta proviene del registro) se restablecen juntos cadawindow_seconds. Las llamadas denegadas no consumen presupuesto por sí mismas.Registro de auditoría (
audit.py) escribe JSON-lines en un archivo y replica cada registro en una tabla SQLiteaudit_logque coincide con el modelo de datos de la especificación, de modo que se puede seguir como texto o consultar con SQL.
Dos transportes envuelven el mismo núcleo MCPStarterServer:
mcp_app.py— un servidor MCP stdio real construido sobre el MCP Python SDK oficial (FastMCP). Dado que stdio es un único proceso local sin cabeceras por petición,tokenysession_idson argumentos explícitos de herramienta — una simplificación común y documentada para servidores MCP locales/de desarrollo. Con esto hablaría un cliente MCP real (Claude Desktop, el CLImcp, etc.).http_app.py— un transporte HTTP FastAPI donde el token proviene de una cabecera realAuthorization: Bearer <token>y la sesión deX-Session-Id, la forma que usaría un despliegue multiusuario real.
Herramientas de ejemplo
search_docs(query) -> list[DocResult]— solo lectura. Busca en un pequeño corpus estático en memoria, filtrado a los documentos visibles para el equipo del usuario que llama (o documentos de toda la empresa). Esto es lo que hace demostrable el passthrough de autenticación: la misma consulta dealice(engineering) ybob(sales) devuelve resultados diferentes.create_ticket(title, body) -> TicketId— escritura, controlada por lista de permitidos. Sustituye a una API de tickets real (TicketSystemClient); dry-run intercepta antes de que ese cliente se toque jamás.
Modelo de datos
audit_log:timestamp, session_id, user_id, tool_name, read_or_write, dry_run, allowed, latency_ms, error_code, detail— tabla SQLite + archivo JSON-lines, escrito en cada llamada.config de
tool_registry(seccióntools:deserver.yaml):read_only, cost_units, descriptionpor nombre de herramienta.session_limits: ventana por sesión en memoria (call_count, cost_used, reinicio enwindow_seconds), controlada porrate_limit:enserver.yaml.
Contrato de errores
Cada rechazo es un MCPError estructurado — {code, message, retry_after?, details?} — nunca una excepción desnuda ni un no-op silencioso:
código | cuándo |
| token ausente o no reconocido |
| herramienta de escritura llamada pero no en |
| la sesión superó |
| nombre de herramienta desconocido |
| el manejador lanzó |
Sobre HTTP, estos se corresponden con 401 / 403 / 429 / 404 / 400 respectivamente, con el mismo cuerpo {code, message, ...} en el detail de la respuesta.
Instalación
Requiere Python 3.10+ (desarrollado y probado en 3.10; la especificación pedía 3.11+ — consulta Desviaciones más abajo para saber por qué se usó 3.10 en su lugar).
git clone https://github.com/HamzaOuadid/mcp-starter-template.git
cd mcp-starter-template
pip install -e ".[dev]"Uso
Listar el registro de herramientas (revisión de seguridad)
mcp-starter toolsSalida real de este repositorio:
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).Ejecutar la demostración práctica de «qué evita esto»
Este es el entregable del hito 4: simular el escenario de dos usuarios de prueba de principio a fin y mostrar que el límite de permisos se mantiene, usando el server.yaml real incluido en este repositorio (dry_run: true, allowed_write_tools vacío).
mcp-starter demoSalida real de una ejecución contra el server.yaml de este repositorio (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) y bob (sales) ven conjuntos de documentos disjuntos además del manual compartido de toda la empresa (all-001) — el límite de permisos se mantiene desde la misma herramienta y consulta. Un token None se rechaza directamente. create_ticket se rechaza porque la lista de permitidos está vacía por defecto. La sexta llamada en una sesión de 5 llamadas por minuto se deniega con un error estructurado.
Ejecútalo con la herramienta de escritura en la lista de permitidos (sigue en dry-run, ya que es el valor predeterminado de la configuración) para ver la forma de la respuesta en dry-run:
mcp-starter demo --allow-writes=== 3. Write tool default posture ===
create_ticket allowed (allowlisted): TicketId(ticket_id='DRYRUN-8ffc09d5', dry_run=True)No se creó ningún ticket real — TicketSystemClient.created permanece vacío en modo dry-run; esto se comprueba directamente en tests/test_dry_run.py espiando el propio método del cliente.
Ejecutar el transporte HTTP
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", ...}}Tokens de desarrollo: token-alice (engineering), token-bob (sales), token-admin (engineering, con el indicador de administrador activado).
Ejecutar el servidor MCP stdio real
mcp-starter serve-stdioEsto inicia un servidor stdio FastMCP real — apunta un cliente MCP (p. ej. mcp dev del CLI mcp, o la configuración de Claude Desktop) a python -m mcp_starter.mcp_app. Herramientas: search_docs(query, token, session_id), create_ticket(title, body, token, session_id), list_tools().
Configuración
Edita 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: 5Para habilitar de verdad la creación de tickets: añade create_ticket a allowed_write_tools y establece dry_run: false. Solo uno de los dos lo mantiene invisible para escrituras o simulado.
Pruebas
pytest tests/ -vSalida real de este repositorio (40 pruebas, todas pasan):
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 ========================Cobertura por área:
Paso de autenticación (
test_auth_passthrough.py) — dos usuarios simulados, la misma herramienta, resultados diferentes; token ausente/no válido/vacío rechazado, nunca predeterminado; nombre de herramienta desconocido falla limpiamente en lugar de bloquearse.Registro / lista de permitidos (
test_registry_allowlist.py) — la clasificación de cada herramienta es inspeccionable; todas las herramientas de escritura están deshabilitadas por defecto; el registro rechaza herramientas que faltan en la configuración o cuya clasificación código/configuración no coinciden; un nombre de herramienta desconocido produce unTOOL_NOT_FOUNDlimpio.Simulación (dry-run) (
test_dry_run.py) — observaTicketSystemClient.createdirectamente para afirmar que realmente nunca se invoca en modo simulado, no solo que la respuesta parece sintética; usacaplogpara confirmar que el marcador[DRY RUN]se registra de verdad; confirma que el cliente real sí se llama una vez que el modo simulado está desactivado y la herramienta está en la lista de permitidos; repite la combinación de modo simulado + escritura permitida varias veces para protegerse contra regresiones; confirma que las herramientas de solo lectura no se ven afectadas por el indicador.Límite de tasa (
test_rate_limit.py) — una ráfaga de N+1 rechaza exactamente el (N+1)-ésimo; las llamadas siguen siendo rechazadas durante el resto de la ventana (no solo la que lo disparó) mediante un reloj falso; el tope de costo se aplica independientemente del número de llamadas; las sesiones están aisladas entre sí; una escritura denegada no consume presupuesto de tasa por sí misma.Registro de auditoría (
test_audit_log.py) — tanto JSONL como SQLite capturan una sesión completa con suficiente detalle para reconstruir quién/qué/permitido/modo simulado; las filas de SQLite se pueden filtrar por sesión; las denegaciones por límite de tasa también se capturan en el rastro, no solo los éxitos.Ambos transportes (
test_http_transport.py,test_mcp_stdio.py) — las mismas salvaguardas se mantienen cuando se usan a través delTestClientde FastAPI y a través delcall_tool/list_toolsasíncronos del servidor realFastMCP, no solo a través del núcleo independiente del transporte.CLI (
test_cli.py) —toolsydemo(con y sin--allow-writes) se ejecutan de principio a fin sin errores mediantetyper.testing. CliRunner.
Desviaciones de la especificación, y por qué
Python 3.10, no 3.11+. El entorno de desarrollo/CI incluye 3.10; nada en esta base de código utiliza una función exclusiva de 3.11, por lo que el requisito mínimo de
requires-pythonse relajó en lugar de bloquearse por una actualización del intérprete. CI fija 3.10 para coincidir con lo que realmente se prueba.SQLite, no PostgreSQL, para el registro de auditoría. La especificación permite cualquiera de los dos; Docker/Postgres no están disponibles en este entorno. El esquema de auditoría (tabla
audit_logenaudit.py) es SQL puro sin sintaxis exclusiva de SQLite, por lo que migrar a Postgres más adelante es un cambio de controlador (sqlite3.connect→psycopg2/asyncpg) además deAUTOINCREMENT→SERIAL/IDENTITY, no un rediseño.El paso de autenticación sobre stdio usa un argumento
tokenexplícito, no un encabezado de transporte. El transporte stdio de MCP es un único proceso local sin encabezados por solicitud, por lo que no hay nada que interceptar de la manera en que el encabezadoAuthorizationde HTTP proporciona al transporte HTTP (http_app.py) una credencial real por solicitud. Pasar el token explícitamente mantiene el efecto (una identidad resuelta y no predeterminada que controla cada llamada) idéntico y comprobable en ambos transportes; es una simplificación documentada, no una afirmación de que stdio tiene autenticación multiusuario "real". Un despliegue multiusuario en producción debería ejecutar el transporte HTTP, o un transporte stdio envuelto por un proxy de autenticación que inyecte la credencial real antes de este código.Sin OAuth/JWT/mTLS en
MockIdentityProvider. Es un diccionario estático token→usuario, claramente solo para desarrollo según la propia nota de riesgo de la especificación. Cambiarlo por una verificación real implica implementar la búsqueda de token deAuthMiddleware.authenticatecontra un IdP real; el resto del pipeline (registro, limitador, modo simulado, auditoría) no se ve afectado porque solo depende de obtener unUser.Recorte: etiqueta git v0.1. El hito 4 requiere etiquetar una versión
v0.1. Este repositorio es de un commit por historia de usuario en lugar de un PR por hito, por lo que el etiquetado se deja al mantenedor para que lo haga una vez que esto llegue a una rama predeterminada con CI en verde (git tag v0.1.0 && git push --tags) en lugar de autoetiquetar un repositorio que nunca se ha enviado a ningún sitio.Recorte: sin tablas persistentes
session_limits/tool_registry. El modelo de datos de la especificación enumerasession_limitsytool_registrycomo tablas junto aaudit_log. La clasificación detool_registryvive enserver.yaml(posiblemente una mejor fuente única de verdad que una tabla de base de datos que un revisor tendría que consultar), ysession_limitssolo está en memoria (limiter.py), lo cual es correcto para un iniciador de un solo proceso pero no sobrevivirá a un reinicio ni se escalará entre procesos; señalar como lo primero a corregir (por ejemplo, contadores respaldados por Redis) antes de ejecutar esto detrás de más de un proceso de servidor.Dos herramientas de ejemplo, no tres o más. La especificación pide "2-3" — se entregan exactamente dos (una de lectura, una de escritura), ya que una tercera herramienta de solo lectura no ejercitaría una salvaguarda que las dos primeras no cubran ya.
Estructura del proyecto
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 limitsLicencia
MIT — consulta 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