AgenticWire test record: fastmcp-vs-mcp-python-sdk Article: https://www.agenticwire.news/article/fastmcp-vs-mcp-python-sdk Machine and versions: see results.json (machine field). results.json is the October 5, 2026 run. results-2026-08.json is the original August run, kept unchanged so readers can see what moved (FastMCP 3.4.5 bundling mcp 1.29.0 then; 4.0.11 bundling mcp 2.3.0 now). Harness script (experiment/run.sh): #!/usr/bin/env bash # fastmcp vs the official MCP Python SDK: what a plain install actually gives you. # # Self-contained: builds its own venvs, assumes only python3 + uv on PATH. # Emits one {"metric","value",...} JSON line per measurement on stdout; narration goes to stderr. # # Re-run: node blog-app/scripts/pipeline/bench.mjs drafts/fastmcp-vs-mcp-python-sdk set -uo pipefail cd "$(dirname "$0")" log() { echo "$@" >&2; } emit() { echo "$1"; } WORK="${TMPDIR:-/tmp}/fastmcp-bench-$$" mkdir -p "$WORK" trap 'rm -rf "$WORK"' EXIT log "building venvs in $WORK" uv venv --python 3.13 "$WORK/fastmcp" >&2 2>/dev/null uv venv --python 3.13 "$WORK/sdk" >&2 2>/dev/null VIRTUAL_ENV="$WORK/fastmcp" uv pip install fastmcp >&2 2>/dev/null VIRTUAL_ENV="$WORK/sdk" uv pip install "mcp[cli]" >&2 2>/dev/null PY_F="$WORK/fastmcp/bin/python" PY_S="$WORK/sdk/bin/python" SP_F="$WORK/fastmcp/lib/python3.13/site-packages" SP_S="$WORK/sdk/lib/python3.13/site-packages" # --- A1: what a plain install resolves to (no --prerelease flag anywhere above) --- V_FASTMCP=$($PY_F -c "import importlib.metadata as m; print(m.version('fastmcp'))") V_MCP=$($PY_S -c "import importlib.metadata as m; print(m.version('mcp'))") emit "{\"metric\":\"fastmcp_version_resolved\",\"value\":\"$V_FASTMCP\",\"note\":\"uv pip install fastmcp, no flags\"}" emit "{\"metric\":\"mcp_sdk_version_resolved\",\"value\":\"$V_MCP\",\"note\":\"uv pip install 'mcp[cli]', no prerelease flag\"}" # --- A2: transitive pin — which mcp does fastmcp actually bundle? --- V_BUNDLED=$($PY_F -c "import importlib.metadata as m; print(m.version('mcp'))") emit "{\"metric\":\"mcp_version_bundled_by_fastmcp\",\"value\":\"$V_BUNDLED\",\"note\":\"transitive dep inside the fastmcp venv\"}" # --- A3: API surface — does the widely-documented import path still exist? --- if $PY_S -c "import mcp.server.fastmcp" 2>/dev/null; then R=true; else R=false; fi emit "{\"metric\":\"sdk_mcp_server_fastmcp_importable\",\"value\":$R,\"note\":\"from mcp.server.fastmcp import FastMCP\"}" if $PY_S -c "from mcp.server import MCPServer" 2>/dev/null; then R=true; else R=false; fi emit "{\"metric\":\"sdk_mcpserver_importable\",\"value\":$R,\"note\":\"from mcp.server import MCPServer\"}" if $PY_F -c "import mcp.server.fastmcp" 2>/dev/null; then R=true; else R=false; fi emit "{\"metric\":\"fastmcp_venv_v1_path_importable\",\"value\":$R,\"note\":\"old path still alive under fastmcp\"}" # --- A4: error behaviour on the obvious mistake (bare vs called decorator) --- $PY_S - >/dev/null 2>&1 <<'PY' from mcp.server.mcpserver import MCPServer m = MCPServer("b") @m.tool def f(a: int) -> int: "doc" return a PY [ $? -ne 0 ] && R=true || R=false emit "{\"metric\":\"sdk_rejects_bare_tool_decorator\",\"value\":$R,\"note\":\"@mcp.tool without parens raises TypeError\"}" $PY_F - >/dev/null 2>&1 <<'PY' from fastmcp import FastMCP m = FastMCP("b") @m.tool def f(a: int) -> int: "doc" return a @m.tool() def g(a: int) -> int: "doc" return a PY [ $? -eq 0 ] && R=true || R=false emit "{\"metric\":\"fastmcp_accepts_both_decorator_forms\",\"value\":$R,\"note\":\"bare and called both work\"}" # --- A5: install footprint --- emit "{\"metric\":\"fastmcp_site_packages_mb\",\"value\":$(du -sm "$SP_F" | cut -f1),\"unit\":\"MB\"}" emit "{\"metric\":\"sdk_site_packages_mb\",\"value\":$(du -sm "$SP_S" | cut -f1),\"unit\":\"MB\"}" emit "{\"metric\":\"fastmcp_package_count\",\"value\":$(ls -1 "$SP_F" | grep -c 'dist-info$')}" emit "{\"metric\":\"sdk_package_count\",\"value\":$(ls -1 "$SP_S" | grep -c 'dist-info$')}" # --- A9: cold import, median of 15, interpreter startup excluded --- bench() { "$1" - "$2" <<'PY' import sys, time, statistics, subprocess py, mod = sys.executable, sys.argv[1] code = f"import time,importlib; t=time.perf_counter(); importlib.import_module('{mod}'); print((time.perf_counter()-t)*1000)" xs = [] for _ in range(15): r = subprocess.run([py, "-c", code], capture_output=True, text=True) if r.returncode == 0 and r.stdout.strip(): xs.append(float(r.stdout)) print(round(statistics.median(xs), 1) if xs else "null") PY } emit "{\"metric\":\"fastmcp_cold_import_median\",\"value\":$(bench "$PY_F" fastmcp),\"unit\":\"ms\",\"n\":15}" emit "{\"metric\":\"sdk_cold_import_median\",\"value\":$(bench "$PY_S" mcp.server.mcpserver),\"unit\":\"ms\",\"n\":15}" # --- A7: equivalent 3-tool server, non-blank LOC --- emit "{\"metric\":\"fastmcp_server_loc\",\"value\":$(grep -cve '^[[:space:]]*$' srv_fastmcp.py),\"note\":\"3-tool server\"}" emit "{\"metric\":\"sdk_server_loc\",\"value\":$(grep -cve '^[[:space:]]*$' srv_sdk.py),\"note\":\"3-tool server\"}" # both servers must actually register their tools, or the LOC comparison is meaningless CNT=$(cd "$(pwd)" && $PY_F -c " import asyncio,sys; sys.path.insert(0,'.') from srv_fastmcp import mcp; print(len(asyncio.run(mcp.list_tools())))" 2>/dev/null || echo 0) emit "{\"metric\":\"fastmcp_tools_registered\",\"value\":$CNT}" CNT=$($PY_S -c " import asyncio,sys; sys.path.insert(0,'.') from srv_sdk import mcp; print(len(asyncio.run(mcp.list_tools())))" 2>/dev/null || echo 0) emit "{\"metric\":\"sdk_tools_registered\",\"value\":$CNT}" log "done"