← Back to Blog

Self-Hosted MCP Gateway on a VPS: Control What Your AI Agents Can Touch

Published · by RS Computers

MCP AI agents Security

A self-hosted MCP gateway is one small service on a VPS that every AI agent connects to instead of talking to MCP servers directly. Claude Code, Codex, Cursor or Open WebUI get one URL and one key each, and the gateway decides which tools that key may see and call, writes every call to a log and slows down an agent that loops. In our lab on Debian 13, agentgateway 1.6.0 did this in 37 MB of RAM and added about 0.7 ms per tool call, and AISIX 1.6.0 did the same job in about 160 MB. Both refused the tools we had blocked, and both run fine on a CPU-only server, because a gateway only forwards small JSON messages and never runs a model.

Key facts, checked on 16 October 2026:

What an MCP gateway does, in plain words

MCP, the Model Context Protocol, is the standard way for an AI agent to use tools. An MCP server offers tools such as "read a file" or "create an issue", and the agent calls them. Our guide to running your own MCP server on a VPS covers one server. A gateway is the next step, once you have several servers and several agents.

Without a gateway, every agent holds its own list of servers, its own tokens and its own idea of what it may do. With a gateway, the agent knows a single address. Behind it, the gateway does four jobs:

This is a different job from the LLM gateway in our LiteLLM and OmniRoute article. That one routes model requests ("which model answers this prompt"). An MCP gateway routes tool calls ("may this agent delete that file"). Some products, AISIX and agentgateway included, do both.

Why agents should not reach tools directly

The official MCP test server, @modelcontextprotocol/server-everything, has a tool called get-env. When we called it directly in our lab, it returned the server's environment variables: PATH, HOME, the user name. On a real server that list can hold database passwords and API tokens. An agent does not need malice to leak them. A prompt hidden in a web page it reads can ask it to call the tool and paste the result somewhere.

The fix is least privilege: each agent can use only the tools its job needs. A coding agent may read the project folder; it has no reason to write outside it, print the environment or move files. A gateway turns that rule into configuration you can read in one file, instead of hoping every agent's settings are right.

Open-source MCP gateways compared

We checked each project's repository and release page on 16 October 2026. We installed and tested the first two.

GatewayLicence and versionInstallPer-tool controlAuth and limitsTested by us
agentgatewayApache 2.0, v1.6.0 (2 Oct 2026)One binary, YAML file; Kubernetes optionalCEL rules per tool, per key or per JWT claimAPI keys, JWT, OAuth, basic auth; local and remote rate limitsYes, stdio and HTTP servers
AISIXApache 2.0, v1.6.0 (12 Oct 2026)Docker image, one resources.yamlAllow and deny globs per caller keyHashed caller keys, OIDC/JWT; per key and per server limitsYes, Streamable HTTP servers
Tuskira AI Agent GatewayApache 2.0, v0.4.0 (1 Oct 2026), alphaDocker Compose built from source, PostgreSQLAgent profiles checked on every tool callGateway API keys bound to profiles; stored credentials injectedNo, see below
MetaMCPMIT, v2.4.22 (19 Dec 2025)Docker Compose with PostgreSQLNamespaces with tools switched on or offAPI key or OAuth per endpoint; SSE and Streamable HTTPNo
IBM ContextForgeApache 2.0, v1.0.11 (28 Sep 2026)pip, uvx or Docker"Virtual servers" that bundle chosen toolsRBAC, rate limiting, admin UINo

We wanted to test Tuskira too. On 16 October 2026 its container image on GitHub's registry answered "unauthorized" to an anonymous pull, and the documented quickstart builds the gateway and its web console from source, which needs Go and Node.js toolchains and at least 4 GB free for Docker by the project's own README. What it describes is attractive: profiles enforced on tools/call, not only hidden from the tool list, and credentials injected on the way out so agents never hold them. Its README also warns that a key not bound to a profile lets the caller choose its own profile, so bind every key.

If you start today and want something small, agentgateway is the easiest to run on a VPS. If you also want an OpenAI-compatible model gateway with guardrails in the same box, AISIX is worth a look. Both fit on a small VPS.

Our lab: what we built

We used a Debian 13 container limited to 2 vCPUs and 3 GB of RAM, on our Amsterdam lab host with an AMD EPYC 7742 whose 8 cores were shared with other tests. Your plan's numbers can differ. Behind the gateways we put two harmless MCP servers:

The rule we wanted: the claude key may read files and use two demo tools; the ci key may also write files; nobody may call get-env. As the client we used a short Python script on the official MCP SDK. We first tried the MCP Inspector CLI, but version 2.11.0 needs Node.js 22.19 or newer and Debian 13 ships 20.19.2.

Setup 1: agentgateway in front of two MCP servers

Step 1: packages, a user and a test folder

# as root
apt update && apt install -y curl ca-certificates openssl nodejs npm python3-venv
adduser --disabled-password --gecos "" mcp
mkdir -p /srv/mcp-test
echo "hello from the lab" > /srv/mcp-test/readme.txt
chown -R mcp:mcp /srv/mcp-test
npm install -g @modelcontextprotocol/server-filesystem@1.0.0 @modelcontextprotocol/server-everything@1.0.0

This took 47 seconds in our lab. node -v should print v20.19.2.

Step 2: run the demo server as a service

# as root
cat > /etc/systemd/system/mcp-demo.service <<'EOF'
[Unit]
Description=MCP demo server (official "everything" test server)
After=network-online.target

[Service]
User=mcp
Environment=PORT=3001
ExecStart=/usr/local/bin/mcp-server-everything streamableHttp
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now mcp-demo
ss -ltnp | grep 3001

You should see LISTEN ... *:3001 ... node. This server listens on every interface, so your firewall must keep port 3001 closed to the internet (see the checklist below).

Step 3: install agentgateway and check the download

# as root
cd /root
curl -fsSLO https://github.com/agentgateway/agentgateway/releases/download/v1.6.0/agentgateway-linux-amd64
curl -fsSLO https://github.com/agentgateway/agentgateway/releases/download/v1.6.0/agentgateway-linux-amd64.sha256
sha256sum agentgateway-linux-amd64
cat agentgateway-linux-amd64.sha256
install -m 755 agentgateway-linux-amd64 /usr/local/bin/agentgateway
agentgateway --version

The two hashes must be the same. Ours were both 496ed27e...f079d, and the version check printed "version": "1.6.0".

Step 4: keys, rules and the service

Two random keys, one per agent, then the config. The mcpAuthorization rules are the allow list: a tool not matched by any rule is hidden and refused.

# as root
CLAUDE_KEY=$(openssl rand -hex 24)
CI_KEY=$(openssl rand -hex 24)
printf 'CLAUDE_KEY=%s\nCI_KEY=%s\n' "$CLAUDE_KEY" "$CI_KEY" > /root/gateway-keys.txt
chmod 600 /root/gateway-keys.txt
mkdir -p /etc/agentgateway
cat > /etc/agentgateway/config.yaml <<EOF
config:
  adminAddr: "127.0.0.1:15000"
  statsAddr: "127.0.0.1:15020"
  readinessAddr: "127.0.0.1:15021"
frontendPolicies:
  accessLog:
    add:
      agent: 'apiKey.agent'
      tool: 'mcp.tool.name'
      server: 'mcp.tool.target'
binds:
- port: 3000
  listeners:
  - routes:
    - policies:
        apiKey:
          mode: strict
          keys:
          - key: "$CLAUDE_KEY"
            metadata: { agent: claude }
          - key: "$CI_KEY"
            metadata: { agent: ci }
        localRateLimit:
        - maxTokens: 120
          tokensPerFill: 120
          fillInterval: 60s
          key: 'apiKey.agent'
        mcpAuthorization:
          rules:
          # every key: read-only file tools, only inside /srv/mcp-test
          - allow: 'mcp.tool.target == "files" && mcp.tool.name in ["list_allowed_directories", "list_directory", "read_text_file", "search_files"]'
          # every key: two harmless demo tools
          - allow: 'mcp.tool.target == "demo" && mcp.tool.name in ["echo", "get-sum"]'
          # only the ci key may write files
          - allow: 'apiKey.agent == "ci" && mcp.tool.target == "files" && mcp.tool.name == "write_file"'
      backends:
      - mcp:
          targets:
          - name: files
            stdio:
              cmd: /usr/local/bin/mcp-server-filesystem
              args: ["/srv/mcp-test"]
          - name: demo
            mcp:
              host: http://127.0.0.1:3001/mcp
EOF
chown root:mcp /etc/agentgateway/config.yaml
chmod 640 /etc/agentgateway/config.yaml
cat > /etc/systemd/system/agentgateway.service <<'EOF'
[Unit]
Description=agentgateway MCP gateway
After=network-online.target mcp-demo.service

[Service]
User=mcp
ExecStart=/usr/local/bin/agentgateway -f /etc/agentgateway/config.yaml
Restart=on-failure
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now agentgateway
systemctl is-active agentgateway

Expected: active. ss -ltnp | grep agentgateway showed the admin UI, stats and readiness ports on 127.0.0.1 only, and the gateway itself on *:3000, which means every interface. Your firewall or a reverse proxy has to guard that port.

Proving the block works

Now the client. It runs as the mcp user; on a real setup it would be your agent on another machine.

# as root: give the test user the keys
install -o mcp -m 600 /root/gateway-keys.txt /home/mcp/gateway-keys.txt
# as the mcp user
python3 -m venv ~/venv
~/venv/bin/pip install -q mcp==2.3.0
cat > ~/mcpcall.py <<'EOF'
# mcpcall.py - tiny MCP client: list the tools, or call one tool, through a gateway
import asyncio, json, logging, os, sys, time
import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

logging.disable(logging.CRITICAL)  # hide SDK noise when the session closes

def root_cause(e):
    while isinstance(e, BaseExceptionGroup):
        e = e.exceptions[0]
    return e

async def main():
    url, action = sys.argv[1], sys.argv[2]
    headers = {}
    if os.environ.get("MCP_TOKEN"):
        headers["Authorization"] = "Bearer " + os.environ["MCP_TOKEN"]
    async with httpx2.AsyncClient(headers=headers, timeout=30) as http:
        async with streamable_http_client(url, http_client=http) as (read, write):
            async with ClientSession(read, write) as s:
                await s.initialize()
                if action == "list":
                    tools = await s.list_tools()
                    print("tools:", ", ".join(t.name for t in tools.tools))
                    return
                args = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
                t0 = time.perf_counter()
                r = await s.call_tool(action, args)
                ms = (time.perf_counter() - t0) * 1000
                text = " ".join(getattr(c, "text", "") for c in r.content)
                print(f"error={r.is_error} time={ms:.1f}ms -> {text.strip()[:200]}")

try:
    asyncio.run(main())
except BaseException as e:
    e = root_cause(e)
    print(f"refused: {type(e).__name__}: {e}"[:300])
    sys.exit(1)
EOF
set -a; . ~/gateway-keys.txt; set +a
GW=http://127.0.0.1:3000/mcp
MCP_TOKEN=$CLAUDE_KEY ~/venv/bin/python mcpcall.py $GW list
MCP_TOKEN=$CLAUDE_KEY ~/venv/bin/python mcpcall.py $GW files_read_text_file '{"path": "/srv/mcp-test/readme.txt"}'
MCP_TOKEN=$CLAUDE_KEY ~/venv/bin/python mcpcall.py $GW files_write_file '{"path": "/srv/mcp-test/new.txt", "content": "hi"}'
MCP_TOKEN=$CLAUDE_KEY ~/venv/bin/python mcpcall.py $GW demo_get-env
MCP_TOKEN=$CI_KEY ~/venv/bin/python mcpcall.py $GW files_write_file '{"path": "/srv/mcp-test/new.txt", "content": "hi"}'

What we saw, line by line:

# output, as the mcp user
tools: files_read_text_file, files_list_directory, files_search_files, files_list_allowed_directories, demo_echo, demo_get-sum
error=False time=87.0ms -> hello from the lab
refused: MCPError: Unknown tool: files_write_file
refused: MCPError: Unknown tool: demo_get-env
error=False time=78.8ms -> Successfully wrote to /srv/mcp-test/new.txt

The claude key never even sees write_file or get-env in its tool list, and a direct call is refused. The ci key can write. Without any key, the gateway answers HTTP 401 with api key authentication failure: no API Key found. The filesystem server adds its own fence: a read of /etc/shadow came back as Access denied - path outside allowed directories. Two locks are better than one.

The audit log

# as root
journalctl -u agentgateway --no-pager | grep 'tools/call' | tail -3

Each line carries the key's agent name, the server and the tool. Shortened, a refused and an allowed call look like this:

# output (shortened)
http.status=400 mcp.method.name=tools/call mcp.target=files gen_ai.tool.name=write_file error="mcp: Unknown tool: files_write_file" reason=MCP agent="claude" tool="write_file" server="files"
http.status=200 mcp.method.name=tools/call mcp.target=files gen_ai.tool.name=write_file duration=8ms agent="ci" tool="write_file" server="files"

The rate limit

We ran 30 sessions in a row with the claude key against a limit of 120 requests per minute. Nineteen went through and eleven were refused with rate limit exceeded, logged with reason=RateLimit. Our first try used a limit of 30 a minute and tripped after six sessions, because one session uses about five HTTP requests. Set limits per key, so one runaway agent does not lock out the others.

Setup 2: AISIX with allow and deny lists

AISIX reaches upstream MCP servers only over Streamable HTTP, so the stdio filesystem server needs a bridge. We used supergateway 4.2.0 and Debian's Docker package.

# as root
apt install -y docker.io
npm install -g supergateway@4.2.0
cat > /etc/systemd/system/mcp-files.service <<'EOF'
[Unit]
Description=Filesystem MCP server over Streamable HTTP (via supergateway)
After=network-online.target

[Service]
User=mcp
ExecStart=/usr/local/bin/supergateway --stdio "/usr/local/bin/mcp-server-filesystem /srv/mcp-test" --outputTransport streamableHttp --stateful --host 127.0.0.1 --port 3002
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now mcp-files

Next, the two AISIX files. Keys are never written into them; key_env names an environment variable, and AISIX stores only a hash.

# as root
mkdir -p /opt/aisix
cat > /opt/aisix/config.yaml <<'EOF'
resources_file: /etc/aisix/resources.yaml
proxy:
  addr: "127.0.0.1:4000"
admin:
  enabled: false
observability:
  metrics:
    prometheus:
      enabled: true
      addr: "127.0.0.1:9090"
EOF
cat > /opt/aisix/resources.yaml <<'EOF'
_format_version: "1"

mcp_servers:
  - name: files
    url: http://127.0.0.1:3002/mcp
  - name: demo
    url: http://127.0.0.1:3001/mcp

api_keys:
  - display_name: claude-laptop
    key_env: CLAUDE_KEY
    allowed_models: []
    mcp_access:
      allow: ["files__read_text_file", "files__list_directory", "files__list_allowed_directories", "demo__echo", "demo__get-sum"]
    mcp_rate_limits:
      files: { rpm: 60 }
  - display_name: ci-runner
    key_env: CI_KEY
    allowed_models: []
    mcp_access:
      allow: ["files__*", "demo__echo"]
      deny: ["files__move_file", "files__edit_file"]
EOF
chmod 644 /opt/aisix/config.yaml /opt/aisix/resources.yaml
docker run --rm --env-file /root/gateway-keys.txt -v /opt/aisix:/etc/aisix:ro --entrypoint aisix ghcr.io/api7/aisix:1.6.0 validate --resources /etc/aisix/resources.yaml
docker run -d --name aisix --restart unless-stopped --network host \
  --env-file /root/gateway-keys.txt \
  -v /opt/aisix:/etc/aisix:ro \
  ghcr.io/api7/aisix:1.6.0
curl -s http://127.0.0.1:4000/readyz

The validate step printed OK: /etc/aisix/resources.yaml loaded 4 resource(s) and the ready check printed ok. The 141 MB image downloaded in 3 seconds. The same client script against http://127.0.0.1:4000/mcp gave:

# output, as the mcp user
tools: demo__echo, demo__get-sum, files__read_text_file, files__list_directory, files__list_allowed_directories
error=False time=979.1ms -> hello from the lab
refused: MCPError: tool 'files__write_file' is not available
refused: MCPError: tool 'demo__get-env' is not available
refused: MCPError: tool 'files__move_file' is not available

The last line is the ci key hitting its deny list. Seventy quick reads from claude on the files server ended with 52 allowed and 18 refused with HTTP 429 rate_limit_exceeded, while the demo server stayed open to the same key. The access log (docker logs aisix) records the tool and the key's ID for every call, including refused ones.

Two lessons from this run. First, the 979 ms: AISIX opens a fresh upstream session for each call, and supergateway starts a new filesystem process for each session. Calls to a native HTTP server (the demo) took about 11 ms instead. Use AISIX with MCP servers that speak HTTP themselves. Second, mount the folder, not a single file. With -v resources.yaml:..., editing the file with sed -i created a new file on disk; docker kill -s HUP aisix logged a reload, but the container still read the old copy. Mounting /opt/aisix as a folder fixed it.

Speed and memory: our numbers

Measurement (our lab, 2 vCPU)agentgateway 1.6.0AISIX 1.6.0
RAM of the gateway, idle37 MB158 to 183 MB
Disk94 MB binary141 MB image, plus Docker
Echo tool, median of 300 calls5.6 to 5.9 ms (direct: 4.9 to 5.2 ms)11.3 to 11.8 ms
2,000 calls from 4 clients at once9.3 s, about 1 CPU second15.7 s, about 3 CPU seconds
Upstreams it can start or reachstdio, SSE, Streamable HTTPStreamable HTTP, OpenAPI

The gateways are not the heavy part. Each Node.js MCP server used 70 to 120 MB, and agentgateway starts one copy of a stdio server per client session: four parallel sessions raised the filesystem server count from 2 to 8 processes, which all exited when the sessions closed. The whole stack, both gateways, three Node processes and Docker, used 414 MB of the container's 3 GB. Nothing here touches a GPU: 2,000 calls through agentgateway cost about one second of CPU time.

Security checklist before agents connect from outside

Pointing an agent at the gateway is one setting. Claude Code, for example, documents a claude mcp add command with an HTTP transport and an Authorization header in its MCP documentation; we did not run it here because it needs a paid account. If your agents live on a server too, see running Claude Code and Codex 24/7 on a VPS, and our claude-mem sync server guide for sharing agent memory.

Which plan and which city

Your situationPlanWhy
agentgateway plus two or three MCP servers, a few agentsVPS Micro: 2 shared vCPU, 2 GB RAM, 40 GB NVMeOur whole agentgateway stack fit in well under 1 GB; Linux only, which is all you need
AISIX or another Docker gateway, five or more MCP servers, a small teamVPS Mini: 4 shared vCPU, 4 GB RAM, 80 GB NVMeRoom for Docker, several Node servers and many stdio sessions at once
Gateway plus the agents themselves, or plus an LLM gateway and a databaseVDS Small: 4 dedicated vCPU, 8 GB RAM, 240 GB NVMeDedicated cores keep tool latency steady when agents compile code next to the gateway
A company with many teams, Tuskira or ContextForge with PostgreSQL and analyticsVDS Medium: 8 dedicated vCPU, 16 GB RAMThe database and log storage grow with every call you record

You can move to a bigger plan later from the client area with a short reboot. For the city, put the gateway near the tools it calls and the people using it. In our own ping.pe test on 6 October 2026, probes reached our Amsterdam server in 6.6 ms from Frankfurt, 6.7 ms from London and 9.3 ms from Paris; our Prishtina server answered Vienna in 18.6 ms and Frankfurt in 27.8 ms, and Skopje is about 3 ms away on our own network. Dublin suits teams in Ireland and the UK. Availability by city is on the plans page: Amsterdam, Dublin, Prishtina.

When is a VPS the wrong answer? If you use one agent on one laptop with two local tools, a gateway adds moving parts for little gain. A managed gateway from a cloud vendor also makes sense if your company already pays for one and needs its single sign-on. The VPS wins once several agents or people share tools, or when logs and keys must stay on a server you control in the EU. Compare every size on the VPS and VDS plans page.

Frequently asked questions

What is an MCP gateway?

An MCP gateway is a proxy between AI agents and MCP servers. Agents connect to one endpoint with their own key; the gateway lists only the tools each key may use, refuses other calls, logs every call and applies rate limits. agentgateway, AISIX, MetaMCP, IBM ContextForge and Tuskira AI Agent Gateway are open-source examples.

Is an MCP gateway the same as an LLM gateway?

No. An LLM gateway such as LiteLLM routes prompts to language models and tracks tokens. An MCP gateway controls tool calls: which agent may read files, query a database or open tickets. Some products, including AISIX and agentgateway, offer both in one binary or container.

Does an MCP gateway need a GPU?

No. It forwards small JSON messages and runs no model. In our lab, agentgateway handled 2,000 tool calls with about one second of CPU time and 37 MB of RAM, and AISIX used about 160 MB, both on 2 CPU cores without a graphics card.

How much latency does an MCP gateway add?

In our Debian 13 lab, agentgateway 1.6.0 added about 0.7 ms per call (median 5.6 to 5.9 ms against 4.9 to 5.2 ms direct). AISIX 1.6.0 answered in about 11 ms because it opens a new upstream session per call. Network distance to your agents usually matters more than either.

How do I block one MCP tool for one agent?

Give the agent its own key and list only the tools it needs. In agentgateway, write a CEL rule such as mcp.tool.name in ["read_text_file"] under mcpAuthorization. In AISIX, set mcp_access.allow on that key, with deny as an extra guard. Blocked tools disappear from the list and calls to them are refused.

Which is the best open-source MCP gateway for a small VPS?

For one small server, agentgateway is the lightest we tested: a single 94 MB binary, 37 MB of RAM, stdio and HTTP servers, per-key rules and logs. Choose AISIX if you also want an OpenAI-compatible model gateway with guardrails, and look at ContextForge or Tuskira when you need a web console and a database-backed registry.

One door for every agent

Start with agentgateway on a VPS Micro, two keys and an allow list you can read in one minute. Add HTTPS, close the other ports, and check the log after the first week. If you would like us to install the gateway and your MCP servers for you, that setup work is quoted per job: message us on Telegram, or pick a plan and a city on the VPS plans page.

← All articles

Chat on Telegram