The Model Context Protocol standardizes how an agent discovers and calls tools. Before it, every integration was client-specific; with it, a server written once works with any compatible client. It is a standard socket between your systems and the agents that need to use them.
Two transports exist. Over stdio, the client launches the server as a subprocess: simple, but confined to the local machine. Over HTTP, the server listens on the network and becomes reachable by several clients from anywhere, that is the case that justifies a VPS, and the one this guide covers.
1. Write the server
The Python SDK provides FastMCP, which derives each tool's schema
from the function's signature and type annotations.
sudo useradd --system --home /srv/mcp --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp -m 750 /srv/mcp
sudo -u mcp python3 -m venv /srv/mcp/.venv
sudo -u mcp /srv/mcp/.venv/bin/pip install "mcp[cli]"
# /srv/mcp/server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ffxf-tools", host="127.0.0.1", port=8000)
@mcp.tool()
def check_dns(domain: str, record: str = "A") -> str:
"""Resolve a DNS record for a domain.
Use this when you need to verify a domain's DNS configuration, for
example before issuing a certificate or while diagnosing an outage.
Args:
domain: The domain name to resolve, for example "example.com".
record: The record type: A, AAAA, MX, TXT, NS, or CNAME.
"""
import subprocess
out = subprocess.run(
["dig", "+short", domain, record],
capture_output=True, text=True, timeout=10,
)
return out.stdout.strip() or f"no {record} record for {domain}"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
The docstring is not documentation: it is sent to the model, and it is what the model uses to decide whether to call the tool. Write it for the model, say which situations the tool applies to, not just what it computes.
The server binds to 127.0.0.1 deliberately. An MCP
server exposes execution capability: the specification defines OAuth 2.1 for the
HTTP transport, but until you have implemented it, nothing authenticates calls.
Let the proxy handle that and keep the process unreachable directly.
2. Make it a service
sudo tee /etc/systemd/system/mcp.service <<'EOF'
[Unit]
Description=MCP server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mcp
Group=mcp
WorkingDirectory=/srv/mcp
ExecStart=/srv/mcp/.venv/bin/python -u server.py
Restart=on-failure
RestartSec=5s
StartLimitBurst=5
StartLimitIntervalSec=300
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
SyslogIdentifier=mcp
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now mcp
journalctl -u mcp -n 20 --no-pager
3. TLS and an access token
Nginx terminates TLS, checks a bearer token, and forwards to the local server. MCP's HTTP transport uses streamed responses: buffering must be off, or the client waits forever.
map $http_authorization $mcp_ok {
default 0;
"Bearer YOUR_LONG_TOKEN" 1;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mcp.example.com;
location /mcp {
if ($mcp_ok = 0) { return 401; }
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # the MCP transport streams
proxy_read_timeout 600s; # a tool call can run long
}
}
openssl rand -hex 32 # to generate the token
sudo nginx -t && sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
A hardcoded token is fine for a server with one consumer. As soon as there are several, or you need to revoke one access without cutting the others, move to the OAuth authentication the specification defines, a string comparison does not revoke selectively.
4. Connect an agent
On the API side, a remote MCP server is declared in two inseparable halves: the
server in mcp_servers, and an mcp_toolset that
references it by name. Declaring the first without the second is rejected.
import anthropic
client = anthropic.Anthropic()
response = client.beta.messages.create(
model="claude-opus-5",
max_tokens=4000,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{
"type": "url",
"name": "ffxf-tools",
"url": "https://mcp.example.com/mcp",
"authorization_token": "YOUR_LONG_TOKEN",
}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "ffxf-tools"}],
messages=[{"role": "user", "content": "What are the AAAA records for ffxf.net?"}],
)
for block in response.content:
if block.type == "text":
print(block.text)
The name passed to mcp_server_name must match the name
declared above exactly: it is the most common configuration mistake, and it shows
up as a rejected request rather than as a silently missing tool.
5. Test without an agent
Before wiring anything up, the inspector shipped with the SDK lists the exposed tools and lets you call them by hand. It is the fastest way to separate a server problem from a client problem.
npx @modelcontextprotocol/inspector
A direct curl check at least confirms the proxy and the token work:
# without a token: should return 401
curl -s -o /dev/null -w "%{http_code}\n" https://mcp.example.com/mcp
# with a token: should no longer return 401
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer YOUR_LONG_TOKEN" \
https://mcp.example.com/mcp
6. Designing the tools
An MCP server's quality rests more on tool design than on code. A few rules that make the difference in practice:
- Few tools, with sharp boundaries. Two overlapping tools produce hesitant calls. If they resemble each other, say explicitly in each description when to use the other one.
- Dense responses. Everything a tool returns enters the context and is paid for. Return what informs a decision, not the underlying API's full dump.
-
Expressive parameters. A named enum
(
record: "A" | "AAAA" | "MX") conveys intent better than a free string, and removes a whole class of errors. - Useful errors. Return a message that says what to do ("domain not found, check the spelling") rather than an exception trace: the model can adapt to the first, not the second.
-
Bounded timeouts. Every external call carries a
timeout. Without one, a stuck tool freezes the whole session.
Checklist
- Server bound to
127.0.0.1, never exposed directly. - Hardened systemd service, dedicated account, bounded restarts.
- Nginx with TLS, token checked, buffering off, timeout raised.
- 401 confirmed from outside without a token.
- Tools validated in the inspector before connecting any agent.
- Descriptions written for the model, timeouts bounded on external calls.