mcp-starter-template
mcp-starter-template
Эталонный каркас MCP-сервера, реализующий паттерны безопасности, которые большинство публичных примеров MCP пропускают: сквозная аутентификация пользователя (никогда не общий служебный аккаунт), инструменты, доступные только для чтения по умолчанию с явным согласием на запись, режим пробного запуска (dry-run) для пишущих инструментов и лимит расходов/частоты на сессию со структурированными отказами вместо молчаливого бездействия или падений. Каждое защитное ограничение подкреплено автоматизированным тестом, а не только docstring.
Создан как элемент портфолио после аудита и удаления мёртвых обработчиков инструментов из продакшен-форка MCP, в котором не было ни одного из этих защитных механизмов.
Родственный проект — mcp-issue-tracker — использует ту же самую архитектуру безопасности (сквозная аутентификация, записи через разрешённый список, dry-run, ограничение частоты, журнал аудита), применённую к реальному локальному домену трекера задач — тот же паттерн, доказанный дважды, а не один раз.
Почему это существует
Большинство публичных примеров MCP-серверов подключают ассистента напрямую к сервисному аккаунту с полными правами и без каких-либо ограждений. Именно так ассистент, отвечающий на разумный вопрос, в итоге раскрывает данные, которые спрашивающий не должен видеть, или молча выполняет запись, которую никто не одобрял. Этот репозиторий — пример более безопасного подхода по умолчанию, достаточно компактного, чтобы прочитать его целиком за один присест.
Защитные механизмы и что каждый из них предотвращает
Защитный механизм | Где | Что предотвращает |
Сквозная аутентификация |
| Выполнение вызова инструмента под общим/универсальным учётным данным. Каждый вызов определяет личность конкретного вызывающего, и все последующие проверки используют именно эту личность, а не учётную запись администратора или сервисного аккаунта. Предотвращает ситуацию, когда «ассистент видит всё, что видит сервисный аккаунт, независимо от того, кто спросил». |
Только чтение по умолчанию + явный список разрешённых для записи |
| Выполнение недавно добавленного или неправильно сконфигурированного пишущего инструмента до того, как кто-то явно проверил и включил его. Инструмент может быть вызван как записывающий только если его имя есть в |
Режим dry-run |
| Срабатывание реального побочного эффекта пишущего инструмента на нижестоящем сервисе, пока оператор ещё проверяет поведение. В режиме dry-run реальный API-клиент не вызывается вообще — это проверяется в тестах через шпионаж за самим методом клиента, а не только через изучение ответа. Предотвращает ситуацию: «мы тестировали в проде, потому что dry-run втайне всё равно писал». |
Лимит частоты/расходов на сессию |
| Неограниченный или вышедший из-под контроля клиент, сжигающий бюджет или долбящий нижестоящий API. Как только оконный бюджет сессии (вызовы или единицы стоимости) исчерпан, каждый последующий вызов в этом окне отклоняется со структурированной ошибкой и |
Структурированный журнал аудита |
| Невозможность восстановить инцидент безопасности после факта. Каждый вызов — разрешённый или отклонённый, чтение или запись, dry-run или реальный — записывается как одна запись в формате JSON-lines и одна строка в SQLite: временная метка, сессия, пользователь, инструмент, чтение/запись, флаг dry-run, флаг разрешения, задержка. Предотвращает ситуацию: «мы на самом деле не знаем, что произошло». |
Архитектура
┌─────────────────────────────┐
MCP client ───────▶ │ transport adapter │
(stdio / HTTP) │ mcp_app.py / http_app.py │
└──────────────┬───────────────┘
│ token, session_id, tool_name, args
▼
┌─────────────────────────────┐
│ MCPStarterServer │ server.py — single
│ .call_tool() │ choke point every
└──────────────┬───────────────┘ call passes through
1) resolve tool ────┤
2) authenticate ────┤──▶ AuthMiddleware ──▶ MockIdentityProvider
3) allowlist check ─┤──▶ ToolRegistry
4) rate/spend check ┤──▶ SessionLimiter
5) execute ─────────┤──▶ tool handler (search_docs / create_ticket)
6) audit log ───────┴──▶ AuditLogger ──▶ audit.jsonl + SQLiteПромежуточный слой аутентификации (
auth.py) преобразует bearer-токен в объектUserс помощьюMockIdentityProvider(identity.py) — с явной пометкой «только для разработки», с двумя разными тестовыми пользователями (alice/engineering,bob/sales) и администратором. Отсутствующие или нераспознанные токены отклоняются; запасной личности нет.Реестр инструментов (
registry.py) — единственное место, где хранится классификация каждого инструмента по чтению/записи; при регистрации она сверяется с секциейtools:вserver.yaml— расхождение между тем, что объявляет код, и тем, что говорит конфиг, не даст серверу запуститься. Пишущий инструмент становится вызываемым только после того, как его имя попадает вallowed_write_tools; при этом он остаётся видимым вlist_tools()в любом случае, чтобы ревьюер мог видеть всю поверхность, а не только то, что сейчас включено.Обёртка dry-run: обработчик каждого пишущего инструмента принимает
dry_run: bool, и дляcreate_ticketпри значенииtrueникогда не обращается кTicketSystemClient.create(заглушке нижестоящего API) — вместо этого возвращает синтетический идентификаторDRYRUN-....dryrun.pyформатирует строку аудита[DRY RUN].Лимитер частоты/расходов (
limiter.py) — счётчик фиксированного окна для каждогоsession_id:calls_per_minиcost_per_session(стоимость инструмента берётся из реестра) сбрасываются вместе каждыеwindow_seconds. Отклонённые вызовы сами по себе не потребляют бюджет.Журнал аудита (
audit.py) пишет JSON-lines в файл и дублирует каждую запись в таблицу SQLiteaudit_log, соответствующую модели данных из спецификации, так что его можно читать как текст или запрашивать через SQL.
Два транспорта оборачивают одно и то же ядро MCPStarterServer:
mcp_app.py— настоящий MCP stdio-сервер на официальном MCP Python SDK (FastMCP). Поскольку stdio — это единый локальный процесс без заголовков запроса,tokenиsession_idпередаются как явные аргументы инструментов — распространённое документированное упрощение для локальных/dev MCP-серверов. Именно с ним будет общаться реальный MCP-клиент (Claude Desktop, CLImcpи т.п.).http_app.py— HTTP-транспорт на FastAPI, где токен берётся из настоящего заголовкаAuthorization: Bearer <token>, а сессия — изX-Session-Id, то есть в форме, которую использовал бы настоящий мультитенантный деплой.
Примеры инструментов
search_docs(query) -> list[DocResult]— только чтение. Выполняет поиск по небольшому статическому корпусу в памяти, отфильтрованному по документам, видимым команде вызывающего пользователя (или общедоступным документам компании). Именно это делает сквозную аутентификацию доказуемой: один и тот же запрос отalice(engineering) иbob(sales) возвращает разные результаты.create_ticket(title, body) -> TicketId— запись, ограничено списком разрешённых. Заменяет реальный API тикетов (TicketSystemClient); dry-run перехватывает вызов до того, как этот клиент будет затронут.
Модель данных
audit_log:timestamp, session_id, user_id, tool_name, read_or_write, dry_run, allowed, latency_ms, error_code, detail— таблица SQLite + файл JSON-lines, записывается при каждом вызове.конфиг
tool_registry(секцияtools:вserver.yaml):read_only, cost_units, descriptionдля каждого имени инструмента.session_limits: оконный лимит сессии в памяти (call_count, cost_used, сброс черезwindow_seconds), управляется параметромrate_limit:вserver.yaml.
Контракт ошибок
Каждый отказ — это структурированная ошибка MCPError — {code, message, retry_after?, details?} — никогда не голое исключение и не молчаливое бездействие:
код | когда |
| токен отсутствует или не распознан |
| вызван пишущий инструмент, отсутствующий в |
| сессия превысила |
| неизвестное имя инструмента |
| обработчик вызвал |
По HTTP они сопоставляются с 401 / 403 / 429 / 404 / 400 соответственно, с тем же телом {code, message, ...} в поле detail ответа.
Установка
Требуется Python 3.10+ (разработано и протестировано на 3.10; спецификация требовала 3.11+ — см. Отклонения ниже, почему использована 3.10).
git clone https://github.com/HamzaOuadid/mcp-starter-template.git
cd mcp-starter-template
pip install -e ".[dev]"Использование
Вывод реестра инструментов (проверка безопасности)
mcp-starter toolsРеальный вывод из этого репозитория:
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).Запуск проработанного демо «что это предотвращает»
Это результат этапа 4: симуляция сценария с двумя тестовыми пользователями от начала до конца и демонстрация сохранения границы прав с использованием реального server.yaml из этого репозитория (dry_run: true, пустой allowed_write_tools).
mcp-starter demoФактический вывод реального запуска с 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) и bob (sales) видят непересекающиеся наборы документов плюс общий общедоступный справочник (all-001) — граница прав сохраняется при использовании одного и того же инструмента и запроса. Токен None отклоняется сразу. create_ticket отклоняется, потому что список разрешённых по умолчанию пуст. 6-й вызов в сессии с лимитом 5 вызовов в минуту отклоняется со структурированной ошибкой.
Запустите его с добавлением пишущего инструмента в список разрешённых (всё ещё dry-run, так как это значение по умолчанию в конфиге), чтобы увидеть форму ответа dry-run:
mcp-starter demo --allow-writes=== 3. Write tool default posture ===
create_ticket allowed (allowlisted): TicketId(ticket_id='DRYRUN-8ffc09d5', dry_run=True)Ни один реальный тикет не был создан — TicketSystemClient.created остаётся пустым в режиме dry-run; это напрямую проверяется в tests/test_dry_run.py через шпионаж за самим методом клиента.
Запуск 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", ...}}Dev-токены: token-alice (engineering), token-bob (sales), token-admin (engineering, установлен флаг администратора).
Запуск настоящего MCP stdio-сервера
mcp-starter serve-stdioЭто запускает настоящий FastMCP stdio-сервер — укажите MCP-клиенту (например, mcp dev из CLI mcp или конфигу Claude Desktop) на python -m mcp_starter.mcp_app. Инструменты: search_docs(query, token, session_id), create_ticket(title, body, token, session_id), list_tools().
Конфигурация
Отредактируйте 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Чтобы реально включить создание тикетов: добавьте create_ticket в allowed_write_tools и установите dry_run: false. Каждое из этих действий по отдельности оставляет инструмент либо невидимым для записи, либо имитируемым.
Тестирование
pytest tests/ -vРеальный вывод из этого репозитория (40 тестов, все проходят):
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 ========================Покрытие по областям:
Сквозная аутентификация (
test_auth_passthrough.py) — два имитированных пользователя, один и тот же инструмент, разные результаты; отсутствующий/недействительный/пустой токен отклоняется, никогда не подставляется значение по умолчанию; неизвестное имя инструмента корректно завершается ошибкой, а не падением.Реестр / список разрешённых (
test_registry_allowlist.py) — классификация каждого инструмента доступна для проверки; все инструменты записи по умолчанию отключены; регистрация отклоняет инструменты, отсутствующие в конфигурации или чьи классификации в коде и конфигурации расходятся; неизвестное имя инструмента даёт корректныйTOOL_NOT_FOUND.Пробный запуск (dry-run) (
test_dry_run.py) — напрямую шпионит заTicketSystemClient.create, чтобы убедиться, что он действительно никогда не вызывается в режиме dry-run, а не только в том, что ответ выглядит синтетическим; используетcaplog, чтобы подтвердить, что маркер[DRY RUN]действительно логируется; подтверждает, что реальный клиент вызывается, когда dry-run выключен и инструмент находится в списке разрешённых; повторяет комбинацию dry-run + разрешённая запись несколько раз для защиты от регрессий; подтверждает, что флаг не влияет на инструменты только для чтения.Ограничение частоты запросов (
test_rate_limit.py) — всплеск из N+1 отвергает ровно (N+1)-й вызов; вызовы продолжают отклоняться в течение остатка окна (не только тот, что его вызвал) с помощью фиктивных часов; лимит стоимости соблюдается независимо от количества вызовов; сеансы изолированы друг от друга; отклонённая запись сама не расходует бюджет лимита частоты.Журнал аудита (
test_audit_log.py) — JSONL и SQLite обе фиксируют полную сессию с достаточной детализацией, чтобы восстановить кто/что/разрешено/dry-run; строки SQLite фильтруются по сессии; отказы из-за ограничения частоты также попадают в журнал, а не только успешные операции.Оба транспорта (
test_http_transport.py,test_mcp_stdio.py) — те же защитные механизмы действуют при работе черезTestClientиз FastAPI и через асинхронныеcall_tool/list_toolsреального сервераFastMCP, а не только через независимое от транспорта ядро.CLI (
test_cli.py) —toolsиdemo(с--allow-writesи без него) выполняются полностью без ошибок черезtyper.testing. CliRunner.
Отклонения от спецификации и почему
Python 3.10, а не 3.11+. В окружении разработки/CI используется 3.10; ничто в этой кодовой базе не задействует возможности, доступные только в 3.11, поэтому нижняя граница
requires-pythonбыла смягчена, а не стала блокироваться обновлением интерпретатора. CI закрепляет 3.10 в соответствии с реально протестированной версией.SQLite, а не PostgreSQL, для журнала аудита. Спецификация допускает любой вариант; Docker/Postgres в этом окружении недоступны. Схема аудита (таблица
audit_logвaudit.py) написана на чистом SQL без синтаксиса, специфичного для SQLite, поэтому переход на Postgres позже — это замена драйвера (sqlite3.connect→psycopg2/asyncpg) плюсAUTOINCREMENT→SERIAL/IDENTITY, а не перепроектирование.Сквозная аутентификация через stdio использует явный аргумент
token, а не заголовок транспорта. Транспорт stdio в MCP — это единый локальный процесс без заголовков на каждый запрос, так что перехватывать нечего, в отличие от HTTP, где заголовокAuthorizationдаёт HTTP-транспорту (http_app.py) реальные учётные данные для каждого запроса. Явная передача токена сохраняет тот же эффект (определённая, не используемая по умолчанию идентичность, управляющая каждым вызовом) и тестируемость на обоих транспортах; это документированное упрощение, а не утверждение, что у stdio есть «настоящая» многопользовательская аутентификация. В производственном многопользовательском развёртывании следует использовать HTTP-транспорт либо stdio-транспорт, обёрнутый аутентифицирующим прокси, который внедряет реальные учётные данные выше по потоку от этого кода.Никаких OAuth/JWT/mTLS в
MockIdentityProvider. Это статический словарь токен→пользователь, явно предназначенный только для разработки, согласно примечанию о рисках в самой спецификации. Замена на реальную проверку означает реализацию поиска токена вAuthMiddleware.authenticateчерез реальный IdP; остальная часть конвейера (реестр, ограничитель, dry-run, аудит) не затрагивается, поскольку она зависит только от получения объектаUser.Исключено: git-тег v0.1. Веха 4 предусматривает создание тега для релиза
v0.1. Этот репозиторий ведётся по коммитам на пользовательскую историю, а не по PR на веху, поэтому создание тега оставлено мейнтейнеру: он сделает это, когда ветка попадёт в основную ветку с зелёным CI (git tag v0.1.0 && git push --tags), а не самостоятельное тегирование репозитория, который никуда не пушился.Исключено: отсутствуют постоянные таблицы
session_limits/tool_registry. В модели данных спецификацииsession_limitsиtool_registryперечислены как таблицы наряду сaudit_log. Классификацияtool_registryхранится вserver.yaml(возможно, это лучший единый источник истины, чем таблица БД, к которой рецензенту пришлось бы обращаться), аsession_limitsживёт только в памяти (limiter.py), что корректно для однопроцессного старта, но не переживёт перезапуск и не масштабируется между процессами — это следует отметить как первое, что нужно исправить (например, счётчики на базе Redis), прежде чем запускать это в конфигурации с более чем одним серверным процессом.Два примерных инструмента, а не три и более. Спецификация запрашивает «2-3» — поставлено ровно два (один на чтение, один на запись), поскольку третий инструмент только для чтения не стал бы проверять какой-либо защитный механизм, не покрытый уже первыми двумя.
Структура проекта
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Лицензия
MIT — см. 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