Skip to main content
Glama
mcp-starter-template

mcp-starter-template

by HamzaOuadid

mcp-starter-template

一个参考性的 MCP 服务器脚手架,编码了大多数公开 MCP 示例所忽略的安全模式:按用户身份验证透传(绝不使用共享服务账户)、默认为只读工具并显式选择启用写入、写入工具的试运行模式,以及每会话支出/速率上限,以结构化拒绝代替静默无操作或崩溃。每道防护措施都有自动化测试支撑,而不仅仅是文档字符串。

这是在审计并剔除了一个生产级 MCP 分支中的无用工具处理器后,作为作品集项目而构建的;该分支原本没有任何这些防护措施。

一个姊妹项目 mcp-issue-tracker 复用了这一套完全相同的安全架构(身份验证透传、基于允许列表的写入、试运行、速率限制、审计跟踪),应用于一个真实的本地问题跟踪器领域——同样的模式,被验证了两次,而非仅一次。

为什么存在

大多数公开的 MCP 服务器示例会将助手直接连接到一个拥有完全权限且无任何防护措施的服务账户。正因如此,一个回答合理问题的助手最终可能会泄露提问者本不应看到的数据,或悄悄执行一项无人批准的写入操作。本仓库展示了更安全的默认形态,其规模足以让你一次性通读完毕。

Related MCP server: Kroki MCP

防护措施及其各自防范的问题

防护措施

位置

防范的问题

身份验证透传

auth.py, identity.py

工具调用永远不会在共享/全面凭据下运行。每次调用都会解析该特定调用者的身份,所有下游检查都使用该身份,而非管理员/服务账户。防止出现“无论谁发出请求,助手都能看到服务账户能看到的所有内容”的情况。

默认只读 + 显式写入允许列表

registry.py, server.yaml

一个新增或配置错误的写入工具在任何人明确审查并启用之前就被执行。只有当工具名称出现在 allowed_write_tools 中时,它才能作为写入工具被调用;其他工具一律不生效。防止出现“我们添加了一个工具,却忘了它可能删除东西”的情况。

试运行模式

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

当操作员仍在验证行为时,写入工具的真实下游副作用就被触发。在试运行模式下,真实的 API 客户端根本不会被调用——测试中通过监视客户端方法本身来验证,而不仅仅是检查响应。防止出现“我们误以为试运行仍然会写入,结果在真实环境中测试了”的情况。

每会话速率/支出上限

limiter.py

防止无界或失控的客户端消耗支出或冲击下游 API。一旦会话的窗口预算(调用次数或成本单位)耗尽,该窗口内的每次后续调用都会以结构化错误和 retry_after 被拒绝,而不仅仅是触发限额的那一次调用。防止出现“客户端的一个 bug 变成了无上限的 API 账单”的情况。

结构化审计日志

audit.py

防止事后无法重建安全事件。每次调用——无论是允许还是拒绝,读取还是写入,试运行还是真实——都会写成一条 JSON-lines 记录和一行 SQLite 记录:时间戳、会话、用户、工具、读/写、试运行标志、允许标志、延迟。防止出现“我们实际上不知道发生了什么”的情况。

架构

                         ┌─────────────────────────────┐
   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)通过 MockIdentityProvider(identity.py)将不记名令牌解析为 User——明确标记为仅限开发环境,预置了两个不同的测试用户(alice/工程团队,bob/销售团队)以及一个管理员。缺失或无法识别的令牌会被拒绝;不存在回退身份。

  • 工具注册表(registry.py)是每个工具读/写分类的唯一存放位置,在注册时会与 server.yaml 的 tools: 部分进行交叉检查——如果代码声明的内容与配置不一致,将拒绝启动。写入工具只有在其名称出现在 allowed_write_tools 中时才可被调用;无论如何,它仍然在 list_tools() 中可见,因此审查者可以看到完整的表面范围,而不仅仅是当前已启用的内容。

  • 试运行包装器:每个写入工具的处理函数接受 dry_run: bool 参数,对于 create_ticket,当该参数为真时,绝不会触碰 TicketSystemClient.create(替代的下游 API)——而是返回一个合成的 DRYRUN-... id。dryrun.py 负责格式化 [DRY RUN] 审计行。

  • 速率/支出限制器(limiter.py)是一个按 session_id 计的固定窗口计数器:calls_per_min 和 cost_per_session(工具成本来自注册表)在每个 window_seconds 内一起重置。被拒绝的调用本身不消耗预算。

  • 审计日志(audit.py)将 JSON-lines 写入文件,并将每条记录镜像到符合规范数据模型的 SQLite audit_log 表中,因此既可以作为文本进行 tail 操作,也可以用 SQL 查询。

两个传输层包装了同一个 MCPStarterServer 核心:

  • mcp_app.py —— 一个基于官方 MCP Python SDK(FastMCP)构建的真实 MCP stdio 服务器。由于 stdio 是单个本地进程,没有按请求的请求头,token 和 session_id 是显式的工具参数——这是本地/开发 MCP 服务器常见且有文档记载的简化方式。实际的 MCP 客户端(Claude Desktop、mcp CLI 等)会与之通信。

  • http_app.py —— 一个 FastAPI HTTP 传输层,其中令牌来自真实的 Authorization: Bearer <token> 请求头,会话来自 X-Session-Id,这是真正的多租户部署会采用的形态。

示例工具

  • search_docs(query) -> list[DocResult] —— 只读。搜索一个小的静态内存语料库,过滤为调用用户所在团队可见的文档(或公司级文档)。这正是让身份验证透传可被证明的原因:alice(工程团队)和 bob(销售团队)使用相同查询会返回不同的结果。

  • create_ticket(title, body) -> TicketId —— 写入,受允许列表限制。代表一个真实的工单 API(TicketSystemClient);试运行会在触碰该客户端之前进行拦截。

数据模型

  • 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 配置(server.yaml 的 tools: 部分):每个工具名称对应 read_only, cost_units, description。

  • session_limits:内存中的每会话窗口(call_count, cost_used,在 window_seconds 时重置),由 server.yaml 中的 rate_limit: 驱动。

错误契约

每次拒绝都是一个结构化的 MCPError —— {code, message, retry_after?, details?} —— 绝不会是裸异常或静默无操作:

错误码

触发条件

UNAUTHENTICATED

令牌缺失或无法识别

WRITE_NOT_ALLOWED

调用了写入工具,但其不在 allowed_write_tools 中

RATE_LIMIT_EXCEEDED

会话超出了 calls_per_min 或 cost_per_session

TOOL_NOT_FOUND

未知的工具名称

INVALID_ARGUMENTS

处理函数对给定参数引发了 TypeError

在 HTTP 上,它们分别映射为 401 / 403 / 429 / 404 / 400,并在响应的 detail 中携带相同的 {code, message, ...} 主体。

安装

需要 Python 3.10+(在 3.10 上开发和测试;规范要求 3.11+ —— 有关为什么使用 3.10 而非 3.11,请参阅下面的偏差部分)。

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(工程团队)和 bob(销售团队)看到的是互不相交的文档集,外加共享的公司级手册(all-001)——权限边界在同一种工具和相同的查询下得到维持。None 令牌会被直接拒绝。由于默认情况下允许列表为空,create_ticket 会被拒绝。在每分钟 5 次调用的会话中,第 6 次调用会以结构化错误被拒绝。

在写入工具被列入允许列表的情况下运行(由于配置默认如此,仍然处于试运行状态),以查看试运行的响应格式:

mcp-starter demo --allow-writes
=== 3. Write tool default posture ===
  create_ticket allowed (allowlisted): TicketId(ticket_id='DRYRUN-8ffc09d5', dry_run=True)

没有创建真实工单——在试运行模式下,TicketSystemClient.created 保持为空;这一点在 tests/test_dry_run.py 中通过监视客户端方法本身直接断言。

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

开发令牌:token-alice(工程团队)、token-bob(销售团队)、token-admin(工程团队,已设置管理员标志)。

运行真实的 MCP stdio 服务器

mcp-starter serve-stdio

这会启动一个真实的 FastMCP stdio 服务器——将 MCP 客户端(例如 mcp CLI 的 mcp dev,或 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。

  • 干运行(test_dry_run.py)——直接监视 TicketSystemClient.create,断言在干运行中它确实从未被调用,而不仅仅是响应看起来是合成的;使用 caplog 确认 [DRY RUN] 标记确实被记录;确认一旦干运行关闭且工具被允许列表收录,真实客户端会被调用;多次重复“干运行 + 允许列表写入”组合以防止回归;确认只读工具不受该标志影响。

  • 速率限制(test_rate_limit.py)——突发 N+1 个请求时恰好拒绝第(N+1)个;通过假时钟,在该时间窗口的剩余时间内调用持续被拒绝(不仅仅是触发限流的那一次);成本上限独立于调用次数强制执行;会话之间相互隔离;被拒绝的写入本身不消耗速率预算。

  • 审计日志(test_audit_log.py)——JSONL 和 SQLite 都能捕获完整会话,细节足以重建 谁/什么/允许/干运行;SQLite 行可按会话过滤;速率限制拒绝也会被记录在跟踪中,而不仅仅记录成功。

  • 两种传输(test_http_transport.py、test_mcp_stdio.py)——当通过 FastAPI 的 TestClient 以及真实 FastMCP 服务器的异步 call_tool/list_tools 驱动时,相同的护栏依然成立,而不仅仅是通过与传输无关的核心。

  • 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.py 中的 audit_log 表)是纯 SQL,没有 SQLite 专属语法,因此以后迁移到 Postgres 只需更换驱动(sqlite3.connect → psycopg2/asyncpg)以及将 AUTOINCREMENT → SERIAL/IDENTITY,而不是重新设计。

  • 通过 stdio 的认证透传使用显式 token 参数,而非传输头。 MCP 的 stdio 传输是单个本地进程,没有每请求头,因此没有东西可以像 HTTP 的 Authorization 头给 HTTP 传输(http_app.py)提供真实每请求凭据那样被拦截。显式传递 token 保持了效果(一个解析后的、非默认身份门控每个调用)在两种传输上相同且可测试;这是一个文档化的简化,而非声称 stdio 具有“真正的”多用户认证。生产环境的多用户部署应运行 HTTP 传输,或由认证代理包装的 stdio 传输,代理在本代码上游注入真实凭据。

  • MockIdentityProvider 中没有 OAuth/JWT/mTLS。 它是一个静态的 token→user 映射,根据规范自身的风险说明,显然仅供开发使用。要换成真实验证,意味着需要实现 AuthMiddleware.authenticate 对真实 IdP 的 token 查找;流水线的其余部分(注册表、限流器、干运行、审计)不受影响,因为它只依赖返回一个 User。

  • 删减:v0.1 git 标签。 里程碑 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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

    Related MCP Connectors

    Related MCP Servers

    • F
      license
      Not graded
      quality
      D
      maintenance
      A secure MCP server example that demonstrates how to deploy to Google Cloud Run with authentication and identity token protection. Serves as a tutorial template for building production-ready MCP servers in the cloud.
      -
    • A
      license
      Not graded
      quality
      D
      maintenance
      A production-ready MCP server scaffold that features built-in authentication, Docker support, and a comprehensive CI/CD release pipeline. It provides a standardized template for deploying servers with multi-transport support and configurable read-only modes.
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      A production-ready foundation for building secure, observable MCP servers with built-in authentication, rate limiting, and reference tools like database-query and semantic-search.
      15
      21 npm
      MIT