Skip to content

Your first proxied server

This is a complete, copy-pasteable walkthrough: you’ll stand up a tiny MCP server, put portcullis in front of it, make real calls, and watch the security controls and metrics work. It takes about five minutes.

In M0-M5 the edge connects to upstreams over Streamable HTTP (spawning stdio servers is the runner’s job; see ADR-0003), so we’ll use a small HTTP echo server as the upstream.

Save this ~20-line echo MCP server as echo_mcp.py. It answers initialize, tools/list, and tools/call:

from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("content-length", 0))
req = json.loads(self.rfile.read(n) or b"{}")
method, rid = req.get("method", ""), req.get("id")
if method == "initialize":
result = {"protocolVersion": req.get("params", {}).get("protocolVersion", "2026-07-28"),
"serverInfo": {"name": "echo", "version": "0"}, "capabilities": {}}
elif method == "tools/list":
result = {"tools": [{"name": "echo", "description": "Echoes its arguments",
"inputSchema": {"type": "object"}}]}
else:
result = {"echoed_method": method, "echoed_params": req.get("params")}
body = json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
HTTPServer(("127.0.0.1", 9090), Handler).serve_forever()

Run it (leave it running in its own terminal):

Terminal window
python3 echo_mcp.py

Save this as portcullis.toml. It’s the smallest useful gateway: one HTTP listener, one authenticated principal, one upstream. (No policy yet - that’s the configuration reference; here everything authenticated is allowed.)

[server.http]
bind = "127.0.0.1:8080"
allowed_hosts = ["127.0.0.1:8080", "localhost:8080"]
[[auth.tokens]]
token_env = "PORTCULLIS_TOKEN_AGENT" # the token *value* comes from this env var
principal = "agent-1"
tenant = "default"
upstream = "echo"
[[upstream]]
name = "echo"
url = "http://127.0.0.1:9090/mcp"

The config names an env var; the secret value never lives in the file:

Terminal window
export PORTCULLIS_TOKEN_AGENT="s3cr3t-agent-token"
Terminal window
portcullis doctor --config portcullis.toml
portcullis doctor
✓ [OK ] config parsed portcullis.toml
✓ [OK ] transport enabled: http
✓ [OK ] auth 1 principal token(s) resolved
! [WARN] session ephemeral signing key (single-node only; set session.secret_env for HA)
✓ [OK ] upstream echo reachable (501 Not Implemented)
✓ [OK ] clock skew ~0s vs echo
! [WARN] sandbox non-Linux host: sandboxed execution unavailable (M1 requires Linux namespaces)
healthy (warnings are non-fatal)
Terminal window
portcullis serve --config portcullis.toml

initialize, with the bearer token. Note the response’s x-portcullis-protocol header - the gateway negotiated the revision:

Terminal window
curl -si http://127.0.0.1:8080/mcp \
-H "authorization: Bearer $PORTCULLIS_TOKEN_AGENT" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{}}}'
HTTP/1.1 200 OK
content-type: application/json
x-portcullis-protocol: 2026-07-28
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2026-07-28","serverInfo":{"name":"echo","version":"0"},"capabilities":{}}}

Now a tools/call. The arguments pass through untouched - the gateway routes and relays, it does not rewrite payloads:

Terminal window
curl -s http://127.0.0.1:8080/mcp \
-H "authorization: Bearer $PORTCULLIS_TOKEN_AGENT" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hello"}}}'
{"jsonrpc":"2.0","id":2,"result":{"echoed_method":"tools/call","echoed_params":{"name":"echo","arguments":{"msg":"hello"}}}}

The same request without a token is rejected before it reaches the upstream - there is no --no-auth:

Terminal window
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8080/mcp \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/list"}'
HTTP 401

And a request carrying a forged Host (the shape of a DNS-rebinding attack) is refused - the Host isn’t in your allow-list:

Terminal window
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8080/mcp \
-H "authorization: Bearer $PORTCULLIS_TOKEN_AGENT" \
-H "host: evil.example.com" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/list"}'
HTTP 403

Every request is accounted per tenant, exported locally at /metrics:

Terminal window
curl -s http://127.0.0.1:8080/metrics
# HELP portcullis_requests_total Requests handled, by tenant and outcome.
# TYPE portcullis_requests_total counter
portcullis_requests_total{tenant="default",outcome="allowed"} 2
portcullis_requests_total{tenant="default",outcome="denied"} 0
portcullis_requests_total{tenant="default",outcome="upstream_error"} 0

Two allowed - the initialize and the tools/call. The rejected requests were turned away before a tenant was established (bad Host, no token), so they aren’t attributed to a tenant.

  • ✅ Authenticated calls are relayed faithfully, with protocol negotiation.
  • ✅ Unauthenticated and cross-origin requests are refused at the edge.
  • ✅ Usage is metered per tenant, locally.

Next: turn on authorization policy, rate limits, circuit breakers, and a tamper-evident audit log in the configuration reference.