Skip to main content
Glama
HamzaOuadid

mcp-starter-template

by HamzaOuadid

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

auth.py, identity.py

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

registry.py, server.yaml

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 allowed_write_tools; todo lo demás es inerte. Evita que «añadimos una herramienta y olvidamos que podía borrar cosas».

Modo dry-run

tools/tickets.py, dryrun.py, server.py

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

limiter.py

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 retry_after, no solo la llamada que lo provocó. Evita que «un error en el cliente se convierta en una factura de API sin límite».

Registro de auditoría estructurado

audit.py

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 + SQLite
  • Middleware de autenticación (auth.py) resuelve un token de portador a un User mediante MockIdentityProvider (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ón tools: de server.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á en allowed_write_tools; sigue siendo visible en list_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: bool y, para create_ticket, nunca toca TicketSystemClient.create (la API posterior de sustitución) cuando es verdadero — devuelve un id sintético DRYRUN-... en su lugar. dryrun.py formatea la línea de auditoría [DRY RUN].

  • Limitador de tasa/gasto (limiter.py) es un contador de ventana fija por session_id: calls_per_min y cost_per_session (el coste de la herramienta proviene del registro) se restablecen juntos cada window_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 SQLite audit_log que 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, token y session_id son 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 CLI mcp, etc.).

  • http_app.py — un transporte HTTP FastAPI donde el token proviene de una cabecera real Authorization: Bearer <token> y la sesión de X-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 de alice (engineering) y bob (sales) devuelve resultados diferentes.

  • create_ticket(title, body) -> TicketIdescritura, 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ón tools: de server.yaml): read_only, cost_units, description por nombre de herramienta.

  • session_limits: ventana por sesión en memoria (call_count, cost_used, reinicio en window_seconds), controlada por rate_limit: en server.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

UNAUTHENTICATED

token ausente o no reconocido

WRITE_NOT_ALLOWED

herramienta de escritura llamada pero no en allowed_write_tools

RATE_LIMIT_EXCEEDED

la sesión superó calls_per_min o cost_per_session

TOOL_NOT_FOUND

nombre de herramienta desconocido

INVALID_ARGUMENTS

el manejador lanzó TypeError con los argumentos dados

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 tools

Salida 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 demo

Salida 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 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", ...}}

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-stdio

Esto 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: 5

Para 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/ -v

Salida 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 un TOOL_NOT_FOUND limpio.

  • Simulación (dry-run) (test_dry_run.py) — observa TicketSystemClient.create directamente para afirmar que realmente nunca se invoca en modo simulado, no solo que la respuesta parece sintética; usa caplog para confirmar que el marcador [DRY RUN] se registra de verdad; confirma que el cliente real 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 del TestClient de FastAPI y a través del call_tool/list_tools asíncronos del servidor real FastMCP, no solo a través del núcleo independiente del transporte.

  • CLI (test_cli.py) — tools y demo (con y sin --allow-writes) se ejecutan de principio a fin sin errores mediante typer.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-python se 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_log en audit.py) es SQL puro sin sintaxis exclusiva de SQLite, por lo que migrar a Postgres más adelante es un cambio de controlador (sqlite3.connectpsycopg2/asyncpg) además de AUTOINCREMENTSERIAL/IDENTITY, no un rediseño.

  • El paso de autenticación sobre stdio usa un argumento token explí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 encabezado Authorization de 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 de AuthMiddleware.authenticate contra un IdP real; el resto del pipeline (registro, limitador, modo simulado, auditoría) no se ve afectado porque solo depende de obtener un User.

  • 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 enumera session_limits y tool_registry como tablas junto a audit_log. La clasificación de tool_registry vive en server.yaml (posiblemente una mejor fuente única de verdad que una tabla de base de datos que un revisor tendría que consultar), y session_limits solo 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 limits

Licencia

MIT — consulta 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