Safeguard.sh Documentation Center

Guard Proxy Deployment

Run Guard as a standalone, self-hosted reverse-proxy gateway in front of your MCP servers instead of embedding the Guard SDK in every agent process.

Guard Proxy Deployment

Guard doesn't have to live inside your agent process. You can instead run it as a standalone reverse-proxy gateway that sits between your agents/clients and your MCP server(s), applying your Safeguard policy to every JSON-RPC call as it passes through — without touching the MCP server's code at all. This page covers the proxy deployment: when to choose it, how the pieces fit together, and how to bring it up with Docker Compose.

When to use the proxy instead of the SDK

ChooseWhen
Guard SDKYou control the MCP server or agent process's code and can add a few lines of setup directly inside it.
Guard ProxyYou want centralized enforcement in front of one or more MCP servers you don't control the code of (third-party or vendor-hosted MCP servers), or a server written in a language without a Guard SDK yet. It also lets you enforce policy for many MCP servers from one place instead of instrumenting each one individually.

The two modes enforce the same underlying Safeguard policy — the difference is purely where enforcement happens: inside the process (SDK) or in front of it on the network (proxy). You can also run both at once: for example, the SDK inside servers you own, and the proxy in front of the ones you don't.

Architecture

Agent / MCP client


Guard Proxy  ──▶  applies policy to each JSON-RPC call


Your MCP server(s) (upstream)

Instead of pointing your agent or MCP client directly at an MCP server, you point it at a Guard proxy URL. The proxy receives every request, evaluates it against your live Safeguard policy, and forwards allowed calls on to the real (upstream) MCP server — returning the response back through the same path so it can be filtered or logged on the way out too. Requests the policy blocks never reach the upstream server at all.

A full Guard proxy deployment is made up of several services, all present under deployments/ in the Guard repo:

ServiceRoleBuilt from
proxyThe reverse proxy itself — accepts agent/client connections and forwards allowed JSON-RPC calls to upstream MCP serversDockerfile.proxy (cmd/proxy)
apiDashboard/management REST API — register MCP servers, manage agents and policies, view audit historyDockerfile.api (cmd/api)
workerBackground processing (audit event aggregation and related async jobs)Dockerfile.worker (cmd/worker)
postgresPrimary datastore for servers, agents, policies, and audit datapostgres:16-alpine
redisCaching and background job coordinationredis:7-alpine
nginxOptional reverse proxy in front of proxy and api, handling routing and rate limitingnginx:alpine

Register an MCP server

Before anything can flow through the proxy, tell Guard about the upstream MCP server it should forward to. This is done through the management API (the api service), authenticated with your dashboard session:

curl -X POST https://<your-guard-host>/api/v1/servers \
  -H "Authorization: Bearer <your-jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "billing-mcp",
    "upstream_url": "https://internal-mcp.example.com",
    "transport": "http",
    "auth_type": "none"
  }'
FieldNotes
nameA label for the server, shown in the dashboard
upstream_urlThe real address of the MCP server the proxy should forward allowed calls to
transporthttp or sse
auth_typeHow the proxy authenticates to the upstream server: none, bearer, basic, or api_key
auth_configCredentials matching auth_type, see below

auth_config is a small JSON object whose shape depends on auth_type — this is what the proxy attaches to every request it forwards upstream:

auth_typeauth_config shapeHeader sent upstream
noneomit, or {}
bearer{"token": "..."}Authorization: Bearer <token>
basic{"username": "...", "password": "..."}HTTP Basic auth
api_key{"header": "X-API-Key", "key": "..."}The named header (defaults to X-API-Key if header is omitted) set to key

A successful create returns 201 with the stored server record:

{
  "id": "b6e6b0f2-2f9a-4d3e-9f1a-8c2e6a1f0abc",
  "org_id": "3f1a9c2e-...",
  "name": "billing-mcp",
  "description": "",
  "upstream_url": "https://internal-mcp.example.com",
  "transport": "http",
  "auth_type": "none",
  "auth_config": {},
  "status": "active",
  "last_health_at": null,
  "health_status": "unknown",
  "created_at": "2026-07-07T00:00:00Z",
  "updated_at": "2026-07-07T00:00:00Z"
}

status and health_status are always created as active and unknown respectively, regardless of what (if anything) you send for those fields — the same pattern Guard uses for agents. Neither field can be changed through a PUT update either; health_status is meant to be maintained by Guard's own upstream monitoring rather than set by the caller. See Servers & Agents for the full inventory model, including tool discovery and agent lifecycle actions.

Registering a server only creates the inventory record — it does not by itself give you a /p/{slug}/... path. That path comes from a separate resource, covered next.

Proxy URLs: binding a slug to a server, agent, and policy

The {slug} in /p/{slug}/message and /p/{slug}/sse identifies a proxy URL record, not the server directly. A proxy URL is what actually makes a registered server reachable through the proxy, and it carries more than just routing information:

FieldNotes
server_idWhich registered MCP server this proxy URL forwards to (required)
agent_idOptionally binds this proxy URL to one specific registered agent, so calls through it are attributed to that agent for policy targeting, risk scoring, and audit — otherwise calls are unattributed to any specific agent
policy_idOptionally pins this proxy URL to one specific policy, overriding your org's default policy for every call that comes through this slug
slugThe unique path segment used in /p/{slug}/...
labelA human-readable name for the proxy URL (e.g. claude-dev, gpt-prod)
statusactive or revoked

This split is what lets one MCP server sit behind several different proxy URLs at once — for example, a shared db-tools server exposed through one slug bound to a lenient policy for a trusted internal agent, and a second slug bound to a stricter policy for everything else, both pointing at the same upstream_url.

In the current release, proxy URLs are provisioned as their own record (distinct from the /api/v1/servers CRUD endpoints covered above) rather than being created automatically when you register a server. If you don't see a proxy URL for a server you just registered, that's expected — reach out to your Guard admin/deployment owner to have one created and bound to the right agent and policy before pointing traffic at it.

Revoking a proxy URL (status: revoked) immediately stops new requests from resolving against it, without touching the underlying server or agent records — useful for rotating a leaked slug or decommissioning one integration without affecting others that share the same server.

Point your agent at the proxy

Once a proxy URL exists for a server, route your agent's traffic to its slug-based path instead of calling the upstream MCP server directly:

  • POST /p/{slug}/message — send a JSON-RPC request through the proxy
  • GET /p/{slug}/sse — open an SSE stream through the proxy

Every proxy request must include a bearer API key scoped to your organization:

curl -X POST https://<your-guard-host>/p/billing-mcp/message \
  -H "Authorization: Bearer sg_live_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"..."}}'

A call the policy allows returns the upstream MCP server's own JSON-RPC response, unmodified except for any output filters (like PII masking) your policy applies:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": { "content": [{ "type": "text", "text": "..." }] }
}

Guard API keys are prefixed sg_live_... in production and sg_test_... in test/staging. Treat them like credentials — store them in a secrets manager or environment variable, not in source control.

Because the proxy speaks the same HTTP + SSE transport MCP already defines, most MCP clients need nothing more than a URL and header change to start routing through Guard — no SDK, no code change inside the agent.

What happens to a denied or blocked call

A call the policy denies, rate-limits, or otherwise blocks still comes back as an HTTP response with a JSON-RPC error body, not a bare HTTP error page — so a standard JSON-RPC client can parse it the same way it would any other error:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32000,
    "message": "request denied by policy: destructive tool calls require monitor review"
  }
}
HTTP statusJSON-RPC error.codeWhen
400-32700The request body isn't valid JSON-RPC
403-32000A policy rule denied the request (error.message includes the rule's deny reason)
403-32002The calling agent's status is blocked
403-32003The request (or, on the way back, the tool's response) tripped the jailbreak/prompt-injection threshold
429-32001The request was rate-limited by the matching policy rule
502-32004The proxy reached the slug fine, but the upstream MCP server itself errored or was unreachable

Every one of these outcomes — allow, deny, or monitor — is written to the audit trail asynchronously, so a blocked call still shows up in Audit & Alerts even though the agent never reached the upstream server.

The /p/{slug}/sse stream applies output filtering (like PII masking) to server-sent events as they pass through, but it does not run the full request-time policy pipeline (deny rules, rate limiting, jailbreak detection) that /p/{slug}/message does — that enforcement happens on the JSON-RPC calls your agent posts to /message, not on the event stream itself. If your MCP client uses SSE for server-initiated messages alongside POST /message for requests, the request-time checks still apply where they matter.

Deploy with Docker Compose

The Guard repo ships a ready-to-use Compose file at deployments/docker-compose.yml, along with Dockerfile.api, Dockerfile.proxy, Dockerfile.worker, and an nginx.conf. From the deployments/ directory:

docker compose up -d

This brings up:

ServiceContainerPort(s)
postgresmcpguard-postgres5432
redismcpguard-redis6379
proxymcpguard-proxy8080
apimcpguard-api8081
workermcpguard-worker— (no exposed port)
nginxmcpguard-nginx80, 443

proxy, api, and worker all wait for postgres and redis to report healthy before starting, and restart automatically (unless-stopped) if they crash.

Environment variables

VariableUsed byDefault in Compose
DATABASE_URLproxy, api, workerpostgres://postgres:postgres@postgres:5432/mcpguard?sslmode=disable
REDIS_URLproxy, api, workerredis://redis:6379
JWT_SECRETproxy, apidev-secret-change-in-production
ENVproxy, api, workerproduction

The bundled JWT_SECRET default is explicitly a placeholder (dev-secret-change-in-production). Override it by setting a JWT_SECRET environment variable before running docker compose up in any environment other than local testing — Compose picks it up automatically via ${JWT_SECRET:-dev-secret-change-in-production}.

nginx routing

The bundled nginx.conf fronts both the proxy and api services on ports 80/443 and applies per-route rate limiting:

PathForwarded toRate limitNotes
/p/proxy:8080300 req/min, burst 50SSE-aware (proxy_buffering off, 3600s read timeout) for long-lived streamed connections
/healthproxy:8080/healthUnauthenticated health check
/api/api:808160 req/min, burst 20Dashboard/management API

This nginx.conf only defines a listen 80 server block — it does not terminate TLS on port 443 out of the box, even though the nginx service in Compose exposes it. If you need HTTPS, terminate TLS in front of this stack (e.g. a load balancer or your own nginx/TLS config) rather than assuming port 443 is already secured.

You can also skip nginx entirely and point clients straight at the proxy service's port (8080) — nginx is there for routing and rate limiting convenience, not a hard requirement of the proxy itself.

FAQ & Troubleshooting

Do I need the api and worker services just to proxy traffic? api is how you register MCP servers, agents, and policies in the first place, so you'll need it at least once to configure the deployment. worker handles background audit processing; the proxy itself will still forward and enforce policy on traffic without it, but you'll lose the async audit/aggregation pipeline.

A request to /p/{slug}/message returns 401. Check that you're sending a valid Authorization: Bearer <key> header with a key that starts with sg_live_ or sg_test_, and that the key belongs to the same organization the server/slug was registered under.

A request to /p/{slug}/... returns 404 with "proxy URL not found". The proxy returns the same generic 404 whether the slug never existed or exists but has been revoked — it doesn't distinguish the two in the response body, so you can't tell which case you're in from the error alone. Check the proxy URL's status for that slug (it should be active), and double-check for typos in the slug itself.

I revoked (or just created) a proxy URL, but the old/new behavior doesn't seem to take effect right away. Slug resolution is cached for 5 minutes to avoid a database round-trip on every call. A revoked slug can keep resolving from cache for up to 5 minutes after revocation, and a brand-new slug can take a moment to be picked up if it happens to collide with a recently-failed lookup. If you need a revocation to take effect immediately for something like a leaked key, treat the 5-minute window as part of your incident response time, not as instantaneous.

A request to /p/{slug}/message returns 403 with "agent is blocked" even though my policy allows the call. Agent status (active / blocked / quarantined) is checked before policy evaluation and short-circuits it entirely — a blocked agent is rejected regardless of what any policy rule would have decided. Unblock the agent (see Servers & Agents) if the block was unintentional; otherwise this is working as designed.

SSE connections through the proxy drop or buffer oddly. If you're running behind the bundled nginx, confirm you haven't overridden proxy_buffering or proxy_read_timeout on the /p/ location — they're deliberately set to keep long-lived streamed connections open. If you're running your own reverse proxy in front of Guard, mirror those settings.

Can I run the proxy in front of multiple MCP servers? Yes — register each one via the API, provision a proxy URL for each, and route each agent/client to that server's own slug-based path. A single Guard proxy deployment can front many MCP servers at once, and a single server can sit behind more than one proxy URL (for example, one per calling agent).

My upstream MCP server requires auth, but calls through the proxy are getting rejected by it (not by Guard). That means the request made it past Guard's own policy check and the proxy tried to forward it, but the credentials it attached from auth_config were wrong or missing — a 502 with an upstream-error message is the signal to check auth_type/auth_config on the server record, not your Safeguard API key (which only authenticates you to the proxy itself, not to the upstream server).

  • Guard SDK — embed the same policy enforcement directly inside your MCP server or agent process instead of running a standalone proxy.
  • Guard Policies — how policies are defined and what they can enforce.
  • Servers & Agents — how MCP servers and agents are registered, health-checked, and how agent status (block/quarantine) is managed.
  • Audit Trail & Alerts — every allow/deny/monitor decision made by the proxy, including the proxy URL and agent it came through, and how to alert on it.

On this page