Federated MCP Control Plane: Policy-Aware Access to Multi-Backend Tool Servers
A federated gateway replaces scattered local MCP credentials with brokered, short-lived, least-privilege tokens plus the guardrails needed to run it safely.
Join the DZone community and get the full member experience.
Join For FreeA federated gateway provides secure, policy-aware access to tool servers.
The thing that made me stop and rethink our whole approach to agentic tooling was a text file.
An engineer on one of our platform teams had wired an AI coding assistant up to our internal source control. To do it, they had pasted a personal access token into a local MCP server config in their home directory. It worked. That also meant a long-lived credential with broad repository scope sat in plaintext in a file the agent could read, on a laptop, with no audit trail and no expiry. Multiply that by every engineer who wants their assistant to see internal code, artifacts, docs, and warehouse tables, and you have hundreds of copies of your crown-jewel credentials distributed across endpoints you do not control.
That is the real problem with Model Context Protocol adoption in an enterprise. MCP itself is a good protocol. The failure mode is topological: the default deployment story puts the server, the credentials, and the client on the same machine, which is exactly where you least want them in a network-isolated environment.
What we built instead was a federated control plane. One gateway, many backend tool servers, and a thin local connector that holds no secrets at all.
The Three-Hop Topology
The pattern is simple to state, and most of the engineering effort goes into the seams:
Connector -> Gateway -> Server
The connector runs locally next to the IDE or agent. It speaks stdio to the client, because that is what most assistants expect, and streamable HTTP outbound to the gateway. It is deliberately dumb. It knows one URL and how to complete a browser-based login. It stores no client secret, no API key, no PAT.
The gateway is the control plane. It terminates authentication, brokers OAuth on the user's behalf, resolves which backend server should handle a given request, enforces policy, and emits telemetry. It is the only component that ever touches a credential.
The backend servers are the actual MCP implementations: source control, artifact repository, documentation search, static analysis, browser automation, warehouse metadata. Each is a separate deployment with its own least-privilege identity. They live in-cluster, on the internal network, with no default egress to the public internet.
The property that matters is that the trust boundary sits at the gateway, not at the laptop. A compromised developer machine yields a session, not a credential.
The Gateway as an OAuth Broker
This is the part people underestimate. The gateway does not proxy the user's token; it exchanges an authenticated session for a narrowly scoped downstream credential, per backend, per request.
Concretely, when a request arrives, the gateway resolves the caller's identity from the session, looks up the target server, and mints or fetches a downstream token with only the scopes that server is registered to need:
async def broker(request: MCPRequest, session: Session) -> MCPResponse:
server = registry.resolve(request.server_id)
if server is None:
raise PolicyError("unregistered_server")
if not policy.allows(session.principal, server, request.method):
audit.deny(session.principal, server.id, request.method)
raise PolicyError("not_permitted")
# Client secrets are held by the gateway only; never sent downstream
# to the connector and never written to a client-side config.
token = await broker_pool.token_for(
principal=session.principal,
provider=server.auth_provider, # e.g. saml_scm, google
scopes=server.least_privilege_scopes, # e.g. ["repo:read"]
ttl_seconds=900,
)
return await transport.forward(server, request, bearer=token)
Two design choices are worth calling out.
First, least_privilege_scopes is a property of the registered server, not of the user's login. A developer authenticating once through the gateway does not thereby grant every backend the union of their permissions. A documentation server gets read scope on docs and nothing else, even if the same human has admin rights elsewhere.
Second, we deliberately started with a static client registration model backed by the platform's own secret store, with a migration path to Dynamic Client Registration. DCR is where this should end up, but shipping a working broker with rotating short-lived tokens beat waiting for the spec ecosystem to settle. Secrets are created by CI/CD from a managed secret store; no human hands a production secret to a running workload.
Guardrails Against Tool Poisoning
Once agents can call tools, tool descriptions become an attack surface. A malicious or compromised server can return a tool definition whose description instructs the model to exfiltrate context, or can silently mutate a description after initial approval. Rate limiting alone does not help here.
We enforce validation at the gateway in both directions of the exchange:
POISON_PATTERNS = [
r"ignore (all )?(previous|prior) instructions",
r"do not (tell|inform|mention to) the user",
r"<\s*(system|assistant)\s*>",
]
def validate_tool_manifest(server_id: str, manifest: dict) -> None:
for tool in manifest["tools"]:
blob = f"{tool['name']} {tool.get('description', '')}"
for pattern in POISON_PATTERNS:
if re.search(pattern, blob, re.IGNORECASE):
quarantine(server_id, tool["name"], reason=pattern)
raise PolicyError("suspect_tool_description")
# Descriptions are pinned at review time. Drift requires re-approval.
if sha256(blob) != registry.approved_digest(server_id, tool["name"]):
raise PolicyError("manifest_drift")
The digest pinning is the load-bearing control. Pattern matching catches the naive cases; pinning catches the case where an approved server changes its behavior after review. Any drift takes the tool out of rotation until a human re-approves it.
On top of that: per-principal and per-server rate limits, an explicit allow/block list of methods, and argument validation before forwarding. We mapped these controls to published guidance for AI system risks so the security review had something concrete to assess rather than a narrative.
Observability Is Not Optional Here
When something goes wrong in an agentic workflow, the user's report is usually "the assistant got confused." That is not debuggable. Centralizing traffic through one gateway means you get, for free, the telemetry that makes it debuggable: latency percentiles per server and per method, error rates by status code, MCP method distribution, transport breakdown between stdio and streamable HTTP, and per-principal activity.
Two things surfaced from that data that we would never have found otherwise. One backend was returning successful responses with empty payloads for a large share of calls, which looked healthy on an error-rate dashboard and terrible to users. And tool usage was heavily concentrated: a small number of servers and a small number of engineers accounted for most traffic, which told us where to spend reliability effort instead of guessing.
Making It Self-Service, or It Dies
A control plane that requires a platform engineer in the loop becomes the bottleneck it was meant to remove. The onboarding path we settled on is a scaffolded repository from an internal portal, image build and promotion through CI, infrastructure-as-code deployment via pull request, automated vulnerability scanning, and auto-registration into the gateway registry on merge. New server idea to registered production service is one pull request and two approvals.
The lesson I would pass on: solve the credential topology first, then the ergonomics. Teams that start with developer convenience end up retrofitting security onto a distributed pile of local configs, and that retrofit is far more expensive than getting the trust boundary right on day one.
Published at DZone with permission of Harish Gaggar. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments