Skip to content
Public template

About

Secure MCP server template in TypeScript and Python: stdio by default, optional streamable HTTP behind bearer auth, rate limit and body cap, bound to 127.0.0.1; bounded file and network access with allowlists and SSRF protection; redacting JSON logs; health tool; tests, non-root pinned Docker images, Semgrep, gitleaks, CodeQL, SBOMs.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mcp-server-template: secure MCP server template

Secure MCP server template in TypeScript and Python: a starting point for a Model Context Protocol server that is safe by default. Both reference implementations ship the same four read-only tools and the same controls: strict input validation, file access bounded to allowlisted directories, network access bounded to allowlisted hosts with SSRF protection, structured logs that redact secrets, a health tool, stdio transport by default, and an optional streamable HTTP transport that is bound to 127.0.0.1, requires a bearer token, rate-limits clients and caps request bodies. Tests, Dockerfiles with digest-pinned bases and non-root users, CI with Semgrep, gitleaks and CodeQL, and SBOMs on release are included.

CI License: MIT

Use this template

  1. Open the repository on GitHub and press Use this template, then Create a new repository (the button appears once the repository is marked as a template under Settings, General, "Template repository"). You get a copy with no shared history.
  2. Delete the implementation you do not need (typescript/ or python/) and the matching jobs in .github/workflows/.
  3. Rename the server (MCP_SERVER_NAME, package.json name, pyproject.toml name), adjust the reporting instructions in SECURITY.md if you do not use GitHub's private vulnerability reporting, and keep the threat model section.
  4. Add your tools next to the bundled ones following docs/adding-a-tool.md. Keep the bundled health tool; clients use it to confirm the server is up.
  5. Enable Dependabot, secret scanning and code scanning on the new repository. CodeQL in the workflow runs only when the repository is public.

Or clone it:

git clone https://github.com/basitalisandhu/mcp-server-template my-mcp-server
cd my-mcp-server && rm -rf .git && git init

Why

Most MCP servers are written in an afternoon and then given to an agent that reads untrusted web pages, documents and tool results. Every argument the model sends is attacker-influenced, and a tool that takes a path or a URL is a file-read or SSRF primitive unless it checks its input against a fixed boundary. Servers exposed over HTTP without authentication are reachable by anyone on the network and by browsers through DNS rebinding. Logs that include request headers leak the tokens that protect them. The template makes the safe choice the default for each of these so a new server starts from a known-good position and the controls are tested rather than assumed.

What is in the box

TypeScript (typescript/) Python (python/)
SDK @modelcontextprotocol/sdk 1.x with zod schemas FastMCP 4.x with pydantic validation
Transports stdio (default); streamable HTTP (opt-in) stdio (default); streamable HTTP (opt-in)
HTTP controls requireBearerAuth with a constant-time shared-token verifier, fixed-window rate limit per client address, body cap, Host header validation (DNS rebinding protection), stateless sessions, loopback bind TokenVerifier with a constant-time shared-token check, FastMCP RateLimitingMiddleware, ASGI body cap, Host and Origin protection, stateless sessions, loopback bind
Tools health, read_file, list_directory, fetch_url the same four
File boundary resolveInside (realpath, symlink-safe, allowlisted roots, size cap) safe_join (resolve, symlink-safe, allowlisted roots, size cap)
Network boundary assertSafeUrl and fetchBounded (host allowlist, private ranges blocked, no redirects, size and time caps) validate_url and fetch_bounded (same policy)
Logging JSON lines on stderr, redact on every field, one audit entry per tool call (wrapper in audit.ts) JSON lines on stderr, redact on every field, one audit entry per tool call (FastMCP middleware in audit.py)
Tests vitest: in-memory client, real HTTP server, local fetch target pytest: in-memory client, uvicorn server, mocked HTTP transport
Container node:22-alpine pinned by digest, runs as node python:3.14-slim pinned by digest, runs as mcp

Quickstart

TypeScript:

cd typescript
npm ci && npm run build && npm test
MCP_ALLOWED_DIRS=$PWD/../docs node dist/index.js        # stdio server exposing one directory

Python:

cd python
uv sync && uv run pytest -q                            # or: pip install -e ".[dev]" && pytest -q
MCP_ALLOWED_DIRS=$PWD/../docs uv run secure-mcp-server # stdio server exposing one directory

Client configuration for a stdio server (Claude Desktop, Claude Code, Cursor and others use this shape):

{
  "mcpServers": {
    "secure-mcp-server": {
      "command": "node",
      "args": ["/path/to/typescript/dist/index.js"],
      "env": { "MCP_ALLOWED_DIRS": "/path/to/docs", "MCP_ALLOWED_HOSTS": "docs.example.com" }
    }
  }
}

Try the HTTP transport

export MCP_TRANSPORT=http
export MCP_AUTH_TOKEN=$(openssl rand -hex 32)        # at least 32 characters or the server refuses to start
export MCP_ALLOWED_DIRS=$PWD/docs
node typescript/dist/index.js                        # or: uv run --project python secure-mcp-server

curl -s http://127.0.0.1:3000/healthz                # {"status":"ok"}, no token needed
curl -s -X POST http://127.0.0.1:3000/mcp \
  -H "Authorization: Bearer $MCP_AUTH_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

Without the header the endpoint answers 401 with a WWW-Authenticate: Bearer challenge; with a Host header that is not loopback it answers 403 (TypeScript) or 421 (Python, from FastMCP's Host protection); with a body over MCP_MAX_BODY_BYTES it answers 413; after MCP_RATE_LIMIT_PER_MINUTE requests it answers 429. The server binds to 127.0.0.1; to expose it beyond the host put a TLS reverse proxy in front and keep the bind address on loopback.

Configuration

Everything is an environment variable; the defaults are the safe ones.

Variable Default Meaning
LOG_LEVEL info debug, info, warn/warning, error.
MCP_ALLOWED_DIRS empty Comma-separated directories read_file and list_directory may read; empty disables file tools.
MCP_ALLOWED_HOSTS empty Comma-separated hosts (host or host:port) fetch_url may contact; empty disables fetch.
MCP_ALLOWED_HTTP_HOSTS empty Host header allowlist; empty uses loopback names.
MCP_ALLOW_PRIVATE_NETWORKS false Let fetch_url reach loopback and private ranges.
MCP_AUTH_TOKEN unset Shared bearer token; required for http, minimum 32 characters.
MCP_FETCH_TIMEOUT_MS 10000 Timeout for fetch_url, in milliseconds.
MCP_HOST 127.0.0.1 Bind address for the HTTP transport.
MCP_MAX_BODY_BYTES 1000000 Request body cap on the HTTP transport.
MCP_MAX_FILE_BYTES 1000000 Largest file read_file returns.
MCP_MAX_RESPONSE_BYTES 1000000 Largest response fetch_url keeps (longer bodies are truncated and flagged).
MCP_PORT 3000 Port for the HTTP transport.
MCP_RATE_LIMIT_PER_MINUTE 120 Requests per minute per client on the HTTP transport.
MCP_SERVER_NAME secure-mcp-server Server name reported in initialize and by health.
MCP_SERVER_VERSION 0.3.1 Server version reported in initialize and by health.
MCP_TRANSPORT stdio stdio or http.

Regenerate this table with python scripts/gen_env_table.py; CI runs python scripts/gen_env_table.py --check to detect stale documentation or different variable names between the implementations.

Tools

Tool Input What it refuses
health none Nothing; reports name, version, transport, uptime and whether file and network access are enabled.
read_file path (1 to 4096 characters) Paths outside MCP_ALLOWED_DIRS (including through symlinks), non-files, files over the cap, null bytes.
list_directory path Same boundary; returns at most 1000 entries with names and types.
fetch_url url (http or https, up to 2048 characters) Hosts not in MCP_ALLOWED_HOSTS, non-http schemes, credentials in the URL, private and loopback addresses, redirects (returned as-is, not followed), bodies over the cap, slow responses.

Every call writes one audit log entry in the same shape in both implementations. Two real entries from the Python server, an allowed read_file and a refused one (the directory path is shortened):

{"ts": "2026-10-20T09:18:14.576+00:00", "level": "info", "logger": "secure_mcp_server.audit", "msg": "audit", "event": "audit", "tool": "read_file", "outcome": "ok", "duration_ms": 3, "client_id": null, "target": ".../srv-docs/README.md"}
{"ts": "2026-10-20T09:18:14.654+00:00", "level": "info", "logger": "secure_mcp_server.audit", "msg": "audit", "event": "audit", "tool": "read_file", "outcome": "refused", "duration_ms": 2, "client_id": null, "target": null}

outcome is ok, refused (the tool declined: outside the allowed directories, host not allowed, not found, too large) or error (an unexpected failure). client_id is the bearer token's client on the HTTP transport and null on stdio. target is the resolved real path, or the host for fetch_url, and is null for refused calls; argument values, file contents and response bodies are never logged.

Descriptions are fixed literals in the source and every tool carries readOnlyHint: true; see docs/adding-a-tool.md before adding one that writes.

Security

SECURITY.md holds the threat model (tool poisoning via descriptions, path traversal, SSRF, unauthenticated HTTP transport, secret leakage in logs, over-broad tools, resource exhaustion, supply chain), what the template does not cover, and the mapping of each control to the rules in agentic-semgrep-rules, which CI runs against both implementations and which reports no findings on the template.

CI (.github/workflows/ci.yml): build and test both implementations, build both images and check they run as non-root, Semgrep with the agentic rule pack, gitleaks, and CodeQL for TypeScript and Python when the repository is public. Release (.github/workflows/release.yml, on v* tags): images to GHCR with provenance, SPDX SBOMs for each image and each source tree attached to the GitHub release.

Troubleshooting

Real messages and what to do about them.

{"level":"error","msg":"MCP_AUTH_TOKEN must be set to at least 32 characters when MCP_TRANSPORT=http ..."} and the server exits. The HTTP transport refuses to start without a strong token. Generate one with openssl rand -hex 32, or leave MCP_TRANSPORT unset for stdio.

MCP_PORT must be an integer between 0 and 65535 (and the same for the other numeric settings). A setting is outside its range; the Configuration table gives each range.

file access is disabled: no allowed directories configured from read_file or list_directory. Set MCP_ALLOWED_DIRS to a comma-separated list of directories in the client's env block; nothing is readable by default.

path is outside the allowed directories: ../README.md. Working as designed: paths, including symlinks, must resolve inside MCP_ALLOWED_DIRS. Add the directory if the client should see it. path cannot be resolved means a symlink loop or a name too long for the file system.

network access is disabled: no allowed hosts configured, host not on the allowlist: example.com or private and loopback addresses are blocked from fetch_url. Add the host to MCP_ALLOWED_HOSTS (with :port if it is not the default). Private, loopback, link-local and other non-public addresses stay blocked even when listed, in every spelling (127.1, 2130706433, localhost., IPv4-mapped IPv6); set MCP_ALLOW_PRIVATE_NETWORKS=true only for a server that must reach an internal service.

HTTP answers 401, 403, 421, 413 or 429. 401: the Authorization: Bearer header is missing or wrong. 403 (TypeScript) or 421 (Python): the Host header is not loopback; set MCP_ALLOWED_HTTP_HOSTS behind a reverse proxy. 413: the body is over MCP_MAX_BODY_BYTES. 429: over MCP_RATE_LIMIT_PER_MINUTE.

uv: command not found. Use pip install -e ".[dev]" in a virtual environment instead, as the Python quickstart shows.

Frequently asked questions

Why stdio by default? A stdio server is reachable only by the process that started it. Most MCP servers are personal tools on one machine, and stdio removes the whole class of network problems (unauthenticated access, DNS rebinding, token handling). Turn on HTTP only when a remote client needs it.

Can I bind to 0.0.0.0? You can set MCP_HOST, but the template is designed for a TLS reverse proxy in front of a loopback bind. If you bind beyond loopback, also set MCP_ALLOWED_HTTP_HOSTS to the names the proxy forwards, keep the bearer token, and expect the Semgrep rule fastmcp-bind-all-interfaces to flag a literal 0.0.0.0 in Python source.

How is this different from the SDK examples? The SDK examples show the protocol. This template adds the boundaries around what the tools can touch, the HTTP hardening, the redacting logger, the tests for the refused cases, the container and CI hardening, and the threat model that explains why each piece exists.

Does the Python side use the official SDK? It uses FastMCP 4, the maintained high-level framework built on the official mcp package, because its auth providers, middleware and Host/Origin protection match the controls the template needs. The TypeScript side uses the official @modelcontextprotocol/sdk 1.x directly.

Contributing

Issues and pull requests are welcome; the starter list is in docs/good-first-issues.md. Changes should land in both implementations; see CONTRIBUTING.md. Security problems: see SECURITY.md.

Sibling projects

More tools by the same author: https://github.com/basitalisandhu

Licence

MIT, see LICENSE. Copyright 2026 Muhammad Basit Ali. Projects created from the template may use any licence.

About

Secure MCP server template in TypeScript and Python: stdio by default, optional streamable HTTP behind bearer auth, rate limit and body cap, bound to 127.0.0.1; bounded file and network access with allowlists and SSRF protection; redacting JSON logs; health tool; tests, non-root pinned Docker images, Semgrep, gitleaks, CodeQL, SBOMs.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages