MCP servers draaien op een VPS voor AI agents
Leer hoe u MCP servers op een VPS host via stdio en HTTP. Wij behandelen de configuratie van systemd, TLS en authenticatie om JSON-RPC fouten en beveiligingslekken te voorkomen.
Wat u bouwt
Twee werkende MCP-opstellingen op één VPS. De eerste is een stdio-server, een bestandssysteem- of databasetool die Claude Code als onderliggend proces start en waarmee via een pipe wordt gecommuniceerd. De tweede is een remote HTTP-server die als langlopende netwerkservice draait achter systemd en een nginx reverse proxy met TLS, bereikbaar voor elke MCP-client die u hierop instelt. De installatie voor beide is beperkt. Het grootste deel van deze handleiding richt zich op de twee onderdelen die in de praktijk voor problemen zorgen: het schoonhouden van de JSON-RPC-stroom en het nooit blootstellen van een niet-geauthenticeerd tooleindpunt aan het openbare internet.
Wat MCP in essentie is
Het Model Context Protocol is een standaardmethode voor een AI-client, zoals Claude Code, Claude Desktop, de Gemini CLI op een VPS, of uw eigen script, om externe tools aan te roepen en externe bronnen te lezen. Het model zelf voert niets uit. Het verzoekt de client om actie, de client communiceert via JSON-RPC 2.0 met een MCP-server, en de server voert de tool uit en geeft het resultaat terug. Die client is wat men bedoelt met een agent harness: de lus rondom het model die de lijst met tools, de toegangscontroles en de sessiestatus beheert. MCP is simpelweg de manier waarop u het tool-gedeelte hiervan uitbreidt. Het is één protocol, waardoor een server die u eenmaal schrijft, werkt met elke client die MCP ondersteunt. Als dit onderscheid nieuw voor u is, en zeker als u zich afvraagt hoe een model besluit om een tool te gebruiken, is een stapsgewijs pad door de basisprincipes van agents een uur van uw tijd waard voordat u een van deze servers voorziet van echte inloggegevens.
Er zijn twee transportmethoden, en de rest van deze handleiding is op basis daarvan opgesplitst:
- stdio. De client start de server als een onderliggend proces en wisselt newline-delimited JSON-RPC-berichten uit via de standaardinvoer en standaarduitvoer. Er is geen netwerk, geen poort en geen authenticatie; de vertrouwensgrens is het proces zelf. Vrijwel elke lokale tool wordt op deze manier geleverd.
- Streamable HTTP (en de oudere variant, HTTP+SSE). De server is een webdienst die continu actief is. De client maakt verbinding via HTTP en de server kan antwoorden streamen als Server-Sent Events. Dit is de methode om één server met meerdere clients te delen, of om een tool te draaien die permanent op de machine moet blijven draaien.
Kies stdio wanneer de tool bij één machine en één gebruiker hoort. Kies HTTP wanneer het een gedeelde dienst betreft.
Vereisten en de eerlijke valkuilen
Ga uit van een verse Ubuntu 24.04 KVM VPS met root- of sudo-toegang. Daarnaast geldt:
- Een runtime waarin de server is geschreven. De meeste referentieservers zijn Node of Python. Ubuntu 24.04 levert Node 18, en diverse huidige MCP-pakketten vereisen Node 20 of nieuwer. Installeer daarom een actuele LTS-versie via NodeSource of nvm in plaats van te vertrouwen op
apt. Python 3.12 is reeds aanwezig. - Een domein en DNS A-record, maar alleen voor de externe HTTP-server; TLS vereist een naam die naar deze VPS verwijst. Het stdio-voorbeeld heeft helemaal geen DNS nodig.
- 512 MB RAM is ruim voldoende. MCP-servers zijn lichte JSON-RPC-processen; het geheugengebruik wordt bepaald door wat uw tool aanroept (een database-driver, een file-cache), niet door het protocol zelf.
- De specificatie is jong en in beweging. De revisie van 2025-03-26 verving HTTP+SSE door Streamable HTTP en markeerde SSE als deprecated. SSE werkt nog steeds en veel servers ondersteunen het nog, dus beschouw elke transport-pin als iets dat u moet controleren aan de hand van de release notes van de server, in plaats van als een vaststaand feit.
Stap 1: een stdio-server koppelen aan Claude Code
Begin met de filesystem-server; deze is officieel, wordt actief onderhouden en vereist enkel Node. Het onderstaande commando registreert de server bij Claude Code en koppelt deze aan het huidige project, zodat de configuratie in een committeerbaar bestand wordt opgeslagen:
cd /home/matt/projects/api
claude mcp add --scope project --transport stdio filesystem \
-- npx -y @modelcontextprotocol/server-filesystem /home/matt/projects/apiHet ---scheidingsteken is essentieel: alles wat daarna komt, is het commando dat Claude Code uitvoert en geen vlag voor Claude Code zelf. Dit schrijft een .mcp.json naar de projectmap:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/home/matt/projects/api"
]
}
}
}Er draait nog niets. Wanneer u de volgende keer Claude Code in deze map start, leest de agent .mcp.json, start npx -y @modelcontextprotocol/server-filesystem ... als onderliggend proces en voert de MCP-handshake uit via de stdin/stdout van dat proces. Controleer of dit is gelukt:
claude mcp listEen correct werkende server toont het commando en een groen vinkje, filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✔ Connected. Binnen de sessie toont het /mcp-slash-commando de tools die de server aanbiedt (read_text_file, write_file, list_directory), en de agent kan deze nu aanroepen op de paden die u heeft toegestaan. Een databasetool werkt op dezelfde manier; vervang het pakket en geef een connection string mee als laatste argument. Controleer echter de repository van de server voor de actuele pakketnaam, aangezien de referentie-Postgres-server meermaals van beheerder is gewisseld.
Dit is precies het doel van het draaien van de agent op de server: de Claude Code-sessie draait op de VPS binnen tmux, en de stdio-servers draaien direct daarnaast met directe toegang tot de projectbestanden en lokale services, zonder netwerkvertraging. Zodra de agent beschikt over write_file en read_text_file, is het raadzaam om dit bereik te combineren met een vaardigheid die de agent dwingt tot de kleinst mogelijke werkende wijziging, omdat een filesystem-tool een ingrijpende herschrijving net zo eenvoudig maakt als een correctie van twee regels. Deze koppeling reikt verder dan lokale bestanden: als u al een zoekmachine op de VPS draait, kunt u de agent uw eigen SearXNG-instantie als zoektool aanbieden. Hierdoor blijven de zoekopdrachten op uw eigen server, terwijl de tekst van onbetrouwbare pagina's direct in de context wordt geladen waar de agent vervolgens mee werkt.
Stap 2: een externe HTTP-server bouwen
Een stdio-server stopt zodra het ouderproces wordt beëindigd en wordt per client opnieuw gestart. Als u twee Claude Code-sessies op de server draait die taken aan elkaar doorgeven, krijgt elke sessie een eigen privé-kopie van de tool. Wanneer u een tool wilt die voor elke client actief blijft, zoals een gedeelde ops-tool, een database-gateway of een voorziening die zowel door uw laptop als uw CI wordt aangeroepen, heeft u HTTP-transport en een echte service nodig. Hieronder staat een minimale Python-server die gebruikmaakt van de officiële SDK en één tool beschikbaar stelt:
# /opt/mcp-ops/server.py
from mcp.server.mcpserver import MCPServer
import subprocess
mcp = MCPServer("ops-tools")
@mcp.tool()
def disk_free() -> str:
"""Return `df -h` for the server."""
out = subprocess.run(["df", "-h"], capture_output=True, text=True)
return out.stdout
if __name__ == "__main__":
# Serves Streamable HTTP at /mcp on 127.0.0.1:8000
mcp.run(transport="streamable-http", host="127.0.0.1", port=8000)Let op host="127.0.0.1". De server luistert uitsluitend op localhost; niets buiten de server kan deze direct bereiken. Dit is precies wat u wilt zolang er nog geen authenticatie is ingesteld. Dit is de 2.x SDK die pip install "mcp[cli]" momenteel ophaalt (2.3.0 per 2026-10-20). Hierin is FastMCP hernoemd naar MCPServer en zijn host en port verplaatst van de constructor naar run(). De oude mcp.server.fastmcp-import resulteert nu in ModuleNotFoundError. Installeer de SDK in een eigen virtualenv zodat systemd over een stabiel pad naar de interpreter beschikt:
sudo useradd --system --home /opt/mcp-ops --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp /opt/mcp-ops
sudo -H -u mcp python3 -m venv /opt/mcp-ops/.venv
sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install "mcp[cli]"Stap 3: houd het actief met systemd
Een tool die niet beschikbaar is wanneer de agent deze aanroept, is erger dan helemaal geen tool. Dit is vooral van belang wanneer de client zelf een langlopend proces is: een altijd actieve agent die zijn geheugen en planning behoudt na reboots zal deze tools volgens een schema aanroepen zonder toezicht, dus de server moet ook zelfstandig herstellen. Schrijf /etc/systemd/system/mcp-ops.service:
[Unit]
Description=MCP ops-tools server
After=network.target
[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/mcp-ops
ExecStart=/opt/mcp-ops/.venv/bin/python /opt/mcp-ops/server.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.targetHet absolute pad naar de venv Python in ExecStart is niet optioneel; verwijs naar /usr/bin/python3 en het proces start met ModuleNotFoundError: No module named 'mcp', omdat de systeem-interpreter uw pip install nooit heeft gezien. Schakel in en controleer:
sudo systemctl daemon-reload
sudo systemctl enable --now mcp-ops
sudo systemctl status mcp-ops
curl -si -H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-X POST http://127.0.0.1:8000/mcpstatus hoort active (running) te tonen. De curl komt terug met HTTP/1.1 400 Bad Request met een JSON-RPC-fout in de body; het verzoek bevatte geen sessie en geen geldige JSON-payload, en dat is precies wat u wilt: het bewijst dat de poort antwoordt en het protocol spreekt. Connection refused of een leeg antwoord betekent dat het proces niet gebonden is aan de plek waar u denkt; lees journalctl -u mcp-ops -n 50.
Stap 4: TLS en een reverse proxy configureren
De server luistert op localhost. Om deze vanaf elke locatie bereikbaar te maken, beëindigt u TLS bij nginx en proxyt u het verkeer naar binnen. Installeer nginx, verkrijg een certificaat via Certbot en Let's Encrypt op nginx, en schrijf vervolgens het location-blok. Het cruciale onderdeel is het uitschakelen van buffering, omdat het standaardgedrag van nginx een antwoord vasthoudt totdat dit volledig is, wat een SSE-stream permanent blokkeert:
server {
listen 443 ssl;
server_name mcp.example.com;
# ssl_certificate lines managed by Certbot
location /mcp {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# The four lines that make SSE work through nginx:
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding off;
}
}Herlaad de configuratie met sudo nginx -t && sudo systemctl reload nginx. Als u al een reeks containers beheert, wordt hetzelfde werk voor u gedaan door een Traefik reverse proxy met automatische TLS; deze geeft het certificaat uit en routeert op basis van hostnaam, waarbij u enkel labels aan de MCP-container hoeft toe te voegen. Hoe dan ook, de reverse proxy is nu het enige onderdeel op een publieke poort en deze wijst naar een service die u nog niet heeft beveiligd. Los dit op voordat u de URL ergens registreert.
Stap 5: de beveiligingsregel die dit onderwerp domineert
Stel nooit een niet-geauthenticeerd MCP-eindpunt bloot. Een MCP-server is geen alleen-lezen API. Het verleent toegang tot tools, tot uw bestanden, uw database en soms een shell. Een open /mcp op het openbare internet is een vreemde met dezelfde rechten als uw AI-agent: zij kunnen uw tools opvragen en vervolgens aanroepen. Behandel het exact als een niet-geauthenticeerde admin-socket, want dat is wat het is. Hoeveel schade een gestolen token aanricht, hangt ook af van de server erachter: de alleen-lezen MCP-server die wordt meegeleverd met de openGym workout tracker kan alleen trainingsdata teruggeven, terwijl een bestandssysteem- of shell-tool de volledige server overdraagt.
Drie verdedigingslinies, in volgorde van voorkeur:
- Publiceer het niet. Houd de server op
127.0.0.1en benader deze vanaf uw laptop via een SSH-tunnel:ssh -L 8000:127.0.0.1:8000 matt@vps, en laat de client vervolgens wijzen naarhttp://127.0.0.1:8000/mcp. Niets wordt ooit blootgesteld. - Plaats het op een privénetwerk. Koppel het tunneladres aan een zelfgehoste WireGuard VPN en laat alleen VPN-peers het bereiken. Het openbare internet ziet een gesloten poort.
- Als het publiek moet zijn, vereis dan een token. Het juiste antwoord is de MCP OAuth-flow die HTTP-transport standaard ondersteunt. Het pragmatische minimum is een gedeeld bearer-token dat bij de proxy wordt gecontroleerd; dit is goedkoop en stopt willekeurige aanvallen volledig:
location /mcp {
if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM") {
return 401;
}
proxy_pass http://127.0.0.1:8000;
# ...buffering-off block from above...
}Genereer het token met openssl rand -hex 32 en bind de server zelf nooit aan 0.0.0.0 zonder een van deze maatregelen ervoor. De client stuurt het token vervolgens als een header. In Claude Code:
claude mcp add --scope project --transport http ops-tools https://mcp.example.com/mcp \
--header 'Authorization: Bearer ${MCP_TOKEN}'Stel MCP_TOKEN in uw shell in zodat het geheim nooit in platte tekst in .mcp.json terechtkomt; Claude Code breidt ${MCP_TOKEN} uit vanuit de omgeving op het moment van lezen. Als de variabele niet is ingesteld, vervangt Claude Code deze niet door een lege tekenreeks: het markeert de ontbrekende variabele in claude mcp list en gebruikt de letterlijke ${MCP_TOKEN}-tekst. Kies ook een eigen naam, want in de headers van een externe server leest Claude Code bewust zijn eigen variabelen en die van uw cloudprovider, zoals ANTHROPIC_API_KEY, als leeg, samen met andere inloggegevens die uw omgeving bevat, zoals NPM_TOKEN.
Elke bovenstaande verdediging beveiligt het eindpunt in plaats van de agent die het token al bezit, wat de andere helft van het probleem is: als uw client de DeepSeek Harness is, dekken plugins die beperken welke tools een agent mag aanroepen en die tool-output scannen op geïnjecteerde instructies die kant af.
Stap 6: debuggen met de MCP Inspector
Wanneer een server niet naar behoren functioneert, ga dan niet af op aannames vanuit de agent, maar stuur deze direct aan met de Inspector, de officiële webgebaseerde testclient. Voor een stdio-server geeft u hetzelfde commando op als de agent uitvoert:
npx @modelcontextprotocol/inspector \
npx -y @modelcontextprotocol/server-filesystem /tmpDeze serveert de gebruikersinterface vanaf één webserver op http://localhost:6274 en toont een URL met een MCP_INSPECTOR_API_TOKEN query-string; open die link in plaats van het adres uit het hoofd te typen. Sinds v2 (2.11.0 is de huidige versie per 2026-10-20) injecteert de backend het token ook in de pagina die bij elke laadactie wordt geserveerd, zodat een verversing blijft werken. Het token beveiligt de /api/* routes die processen kunnen starten. De afzonderlijke v1-proxy op 6277 is verwijderd, maar de webserver is niet de enige listener: v2 start ook een sandbox voor MCP Apps op MCP_SANDBOX_PORT (standaard 6275) en een toegewezen app-origin server op MCP_APP_ORIGIN_PORT (standaard 6278), die elke MCP App serveert waarvan de UI-resource _meta.ui.domain declareert. Als u de Inspector bereikt via een SSH-tunnel of deze in een container draait en het tabblad Apps nodig heeft, forward dan 6275 en 6278 samen met 6274. Maak verbinding, open het tabblad Tools, kies een tool, vul echte argumenten in en roep deze aan. Als het in de Inspector werkt maar in de agent faalt, zit de fout in uw clientconfiguratie en niet in de server. Voor de externe HTTP-server start u de Inspector zonder commando, voegt u een server toe met het Streamable HTTP transport en https://mcp.example.com/mcp, plaatst u de Authorization header in de instellingen en maakt u verbinding. Dit is de snelste manier om te verifiëren of authenticatie en de proxy correct zijn voordat er een agent bij betrokken wordt. De Inspector slaat headers in de servercatalogus exact op zoals ze zijn geschreven, waardoor dat token nu ook in ~/.mcp-inspector/mcp.json staat.
Servers up-to-date houden
MCP ontwikkelt zich snel, dus voer updates uit volgens een planning. Node-servers die worden gestart met een niet-gefixeerde npx -y draaien de versie die npx op dat moment resolvet: de versie die in het lokale project staat (indien aanwezig), of anders een kopie die in de npm-cache wordt geïnstalleerd. -y slaat alleen de installatieprompt over; het dwingt geen update af. Hierdoor volgt u niet de nieuwste release en weet u niet zeker welke versie u draait. Fixeer de exacte versie die u heeft getest, lees deze af uit npm view @modelcontextprotocol/server-filesystem version en voeg deze toe aan de pakketnaam in .mcp.json (@modelcontextprotocol/server-filesystem@<version>), zodra een server van belang is, en verhoog deze versie bewust. Python-servers onder systemd worden bijgewerkt met sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install -U "mcp[cli]" gevolgd door sudo systemctl restart mcp-ops. Lees de release notes vóór een grote update: de overstap van de 1.x naar de 2.x Python SDK maakt 1.x-code onbruikbaar bij het importeren. De migratiehandleiding van de SDK zelf adviseert om een bovengrens voor <2 aan te houden, zoals "mcp[cli]<2", totdat u de migratie heeft voltooid. Controleer bij het upgraden welke specificatierevisie uw SDK hanteert; een sprong over de grens van SSE naar Streamable-HTTP kan het transportprotocol wijzigen dat uw clients moeten aanvragen.
Foutmodi en bijbehorende meldingen
De agent geeft aan dat de server is mislukt. claude mcp list print ✘ Failed to connect, en huidige versies voegen de foutdetails toe aan die regel. Voer claude --debug uit; u ziet dan meestal Error: spawn npx ENOENT, wat betekent dat het commando niet in het PATH van de agent staat. De runtime ontbreekt of staat niet op de locatie waar de agent zoekt: Node is niet geïnstalleerd, npx ontbreekt, of er wordt verwezen naar een virtualenv Python met een kale naam. Corrigeer het commando naar een absoluut pad of installeer de runtime en maak opnieuw verbinding.
Een stdio-server maakt verbinding, maar verbreekt deze direct. De client logt een JSON-parsefout, zoals Unexpected token 'S', "Server sta"... is not valid JSON of Failed to parse message. De oorzaak is altijd hetzelfde: de server schreef een logregel naar stdout. Bij stdio is stdout het JSON-RPC-kanaal; elke willekeurige tekst corrumpeert de stream en de handshake mislukt. In Node gaat console.log naar stdout; gebruik console.error. In Python gaat een kale print() naar stdout; schrijf logs met logging geconfigureerd naar sys.stderr, of geef file=sys.stderr mee. De regel is absoluut: bij stdio mag alleen JSON-RPC naar stdout, alle menselijk leesbare tekst moet naar stderr.
Een externe server bereikt een time-out of sluit de verbinding tijdens de handshake. De client faalt met MCP error -32000: Connection closed, of de Inspector blijft hangen op Connect zonder tools te tonen. Achter nginx wordt dit veroorzaakt door buffering: de proxy houdt de SSE-stream vast in plaats van deze door te sturen, waardoor de client wacht op een antwoord dat nooit aankomt. Voeg proxy_buffering off; (en de rest van het blok in Stap 4) toe aan de location. Controleer dit met curl -N tegen de publieke URL; u hoort gebeurtenisgegevens incrementeel te zien binnenkomen, niet alles tegelijk aan het einde.
Authenticatie wordt geweigerd. De client rapporteert Error POSTing to endpoint (HTTP 401) of simpelweg 401 Unauthorized. Ofwel de header ontbreekt, het token is onjuist, of de variabele is niet geëxpandeerd; dit is een veelvoorkomende valkuil. Als MCP_TOKEN niet is ingesteld wanneer de client de configuratie leest, waarschuwt Claude Code voor de ontbrekende variabele in claude mcp list en verstuurt de letterlijke tekst, waardoor nginx Bearer ${MCP_TOKEN} ziet. Een variabele die vernoemd is naar een van de credentials van Claude Code, uw cloudprovider of uw omgeving wordt als leeg gelezen, en nginx ziet Bearer zonder waarde. Echo de variabele, voeg de header opnieuw toe en verifieer dat de exacte bytes overeenkomen met het token in de nginx if.
De service start niet onder systemd. journalctl -u mcp-ops toont ModuleNotFoundError: No module named 'mcp', ExecStart wijst naar de systeem-Python in plaats van de venv-interpreter. Of Address already in use, een ander proces bezet poort 8000; zoek dit proces met sudo ss -ltnp | grep 8000.
FAQ
Wat is een MCP server precies?
Het is een programma dat tools en resources beschikbaar stelt aan een AI-client via het Model Context Protocol, gebruikmakend van JSON-RPC 2.0. Het AI-model voert de tool nooit zelf uit; het verzoekt de client, de client roept de MCP server aan, en de server voert de actie uit en retourneert een resultaat. Omdat het protocol een standaard is, werkt één server met elke compatibele client, of dat nu Claude Code, Claude Desktop of de Gemini CLI is.
Wat is het verschil tussen stdio en HTTP transport?
Een stdio-server wordt door de client gestart als een onderliggend proces en communiceert via stdin/stdout. Deze leeft en sterft samen met één client op één machine en vereist geen netwerk of authenticatie. Een HTTP-server is een langlopende netwerkservice die door meerdere clients tegelijk kan worden bereikt, waardoor TLS en authenticatie noodzakelijk zijn. Gebruik stdio voor lokale tools voor één gebruiker; gebruik HTTP (Streamable HTTP op huidige servers) voor gedeelde of persistente toepassingen.
Hoe beveilig ik een externe MCP server?
Ga ervan uit dat de server toegang verleent tot uw bestanden, database of shell, en stel deze nooit bloot zonder authenticatie. Het is het beste om de server gebonden te houden aan localhost en deze te benaderen via een SSH-tunnel of een privé-VPN. Als de server publiek toegankelijk moet zijn, plaats deze dan achter een reverse proxy die een bearer token of de MCP OAuth-flow afdwingt. Genereer het token met openssl rand -hex 32 en bind de server nooit aan 0.0.0.0 zonder een van deze beveiligingslagen ervoor.
Hoe debug ik een server die niet wil starten?
Controleer eerst claude mcp list, ✘ Failed to connect met spawn ... ENOENT; dit betekent dat het commando of de runtime ontbreekt, dus corrigeer het pad of installeer de software. Als de verbinding tot stand komt maar direct wordt verbroken met een JSON-parsefout, logt de server naar stdout en corrumpeert daarmee de JSON-RPC-stream; verplaats alle logging naar stderr. Voor andere problemen voert u het exacte commando uit onder de MCP Inspector, die de server in isolatie aanstuurt, zodat u kunt vaststellen of het een serverfout of een client-configuratiefout betreft.