Connect MCP servers and pin tools
Put your own MCP servers behind the MAQPNA gateway, locally and with the MCPServer resource, and pin tool definitions so a changed tool is hidden and denied.
flowchart LR
A["register the server<br/>--upstream or MCPServer"] --> B["agent calls<br/>{gateway}/mcp/NAME"]
B --> C["gateway hashes each<br/>tool definition"]
C --> D["maqpna mcp tools NS/NAME"]
D --> E["maqpna mcp pin NS/NAME<br/>--mode enforce"]
E --> F["toolPins in ToolPolicy<br/>or MCPServer"]
F --> G{"definition<br/>changed?"}
G -->|no| H["pinned: visible, callable"]
G -->|yes| I["drift: hidden and denied<br/>tool_definition_changed"]
Goal#
Connect the Model Context Protocol (MCP) servers your agents use so that every call to them goes through the MAQPNA gateway, then pin each tool's definition. With pins in enforce mode, a tool whose definition changed after it was pinned (drift, also called a "rug pull") is hidden from the agent and its calls are denied until someone reviews it.
Prerequisites#
- A local MAQPNA (
maqpna dev up) for steps 1โ5, or an installation and an admin token ormaqpna loginfor steps 6โ7. - An MCP server that speaks Streamable HTTP. Only the gateway talks to it; agents never get its URL or its credentials.
How it works#
| Where | How you register a server | URL the agent uses | Credentials |
|---|---|---|---|
| Local MAQPNA | maqpna dev up --upstream NAME=URL (repeatable) |
MAQPNA_TOOL_<NAME>_URL = {gateway}/mcp/NAME |
none |
| Cluster | an MCPServer resource in the agent's namespace, referenced from Agent.spec.tools |
the same, set by the operator | spec.auth: the gateway injects a bearer token, a header, OAuth client credentials, a token exchange, mTLS or the user's own token. The sandbox never sees them. |
A session can call a server only with the scope tools:<name> in its token. maqpna dev run grants tools: for every registered server by default; in a cluster the operator grants the servers listed in the agent's spec.tools.
Steps#
1. Add a server to a local MAQPNA#
Start the local MAQPNA with your server next to the built-in echo:
maqpna dev up --upstream tickets=http://127.0.0.1:3001/mcp
local MAQPNA is up (~/maqpna-demo/.maqpna)
gateway http://127.0.0.1:8080
broker http://127.0.0.1:8081
mcp echo -> http://127.0.0.1:8090/mcp
mcp tickets -> http://127.0.0.1:3001/mcp
next: maqpna dev run -- <your agent command>
Your agent gets one URL per server, all on the gateway:
maqpna dev run -- env | grep '^MAQPNA_TOOL_'
MAQPNA_TOOL_ECHO_URL=http://127.0.0.1:8080/mcp/echo
MAQPNA_TOOL_TICKETS_URL=http://127.0.0.1:8080/mcp/tickets
Call a tool of the new server through the gateway:
maqpna dev run -- maqpna call --server tickets --list
maqpna dev run -- maqpna call --server tickets --tool get_time
The dev policy decides these calls like any other: get_*, list_*, search_*, read_* and describe_* tools are allowed, create_*, update_*, patch_*, comment_*, post_* and send_* are allowed with a rate limit, delete_* and similar tools need approval, and anything else is denied (default_action of default-agent-policy). Use --no-echo to leave out the test server.
2. See the servers and the observed tools#
The gateway hashes the canonical definition (name, description, input schema) of every tool it sees in a tools/list answer. List the servers and the tools observed for namespace dev:
eval "$(maqpna dev env)"
maqpna dev run -- maqpna call --server echo --list > /dev/null
maqpna mcp list
maqpna mcp tools dev/echo
NAME SOURCE URL NAMESPACES TRUST PIN MODE AUTH
echo static http://127.0.0.1:8090/mcp
TOOL STATUS SHA256 VISIBLE CALLABLE CHANGES LAST SEEN
delete_resource unpinned sha256:69e35d14f2c8c5a052a79fd54f0df65647f780f52817727d69cfc226c5acbf9e true true 2026-10-03T00:12:49.20766-04:00
echo unpinned sha256:03929cef4ba64f8f797c5e686cf573d21f064d1358624b55e34d8974fc24bf5c true true 2026-10-03T00:12:49.207601-04:00
get_time unpinned sha256:125013379d07b081d5c5fa8a067d555796995f9354ebf11e4182a94aa470a0e8 true true 2026-10-03T00:12:49.207633-04:00
trace_context unpinned sha256:d5c2336dccff9b41702083bc7d2776d969247ae715dbdba0c58369d494da6cfb true true 2026-10-03T00:12:49.207678-04:00
NS/NAME is the namespace of the sessions that called the server and the server name. Servers registered with --upstream or in the gateway configuration show SOURCE static; MCPServer resources show their namespace, trust and pin mode.
3. Export the pins#
maqpna mcp pin dev/echo --mode enforce --out pins.yaml
cat pins.yaml
wrote pins.yaml
# ToolPolicy spec fragment for dev/echo, exported 2026-10-03T04:12:59Z
toolPins:
- server: "echo"
mode: enforce
onChange: hide
pins:
"delete_resource": "sha256:69e35d14f2c8c5a052a79fd54f0df65647f780f52817727d69cfc226c5acbf9e"
"echo": "sha256:03929cef4ba64f8f797c5e686cf573d21f064d1358624b55e34d8974fc24bf5c"
"get_time": "sha256:125013379d07b081d5c5fa8a067d555796995f9354ebf11e4182a94aa470a0e8"
"trace_context": "sha256:d5c2336dccff9b41702083bc7d2776d969247ae715dbdba0c58369d494da6cfb"
The default target is a ToolPolicy (spec.toolPins is a list, one entry per server). --target mcpserver writes the block for MCPServer.spec.toolPins instead (one server, no server: key). --first exports the first-seen hashes instead of the current ones, which is what you want when you suspect a tool already changed.
| Field | Values |
|---|---|
mode |
learn (default: record and audit, never block), enforce, off |
onChange |
In enforce mode: hide (default: drop the tool from tools/list and deny its calls), approve (require approval for its calls), alert (allow and audit) |
allowUnpinned |
true lets tools without a pin through in enforce mode |
pins |
Tool name to sha256:<hex> |
When a ToolPolicy and an MCPServer both pin the same server, the strictest setting wins: enforce over learn over off, and hide over approve over alert.
4. Enforce the pins locally#
The local gateway reads .maqpna/policies.json (policy bundle JSON) and reloads it every 2 seconds. Add the block from step 3, in JSON form, to the default-agent-policy entry:
"toolPins": [{"server": "echo", "mode": "enforce", "onChange": "hide", "pins": {
"delete_resource": "sha256:69e35d14f2c8c5a052a79fd54f0df65647f780f52817727d69cfc226c5acbf9e",
"echo": "sha256:03929cef4ba64f8f797c5e686cf573d21f064d1358624b55e34d8974fc24bf5c",
"get_time": "sha256:125013379d07b081d5c5fa8a067d555796995f9354ebf11e4182a94aa470a0e8",
"trace_context": "sha256:d5c2336dccff9b41702083bc7d2776d969247ae715dbdba0c58369d494da6cfb"}}]
To keep your own policy file across restarts, save the bundle elsewhere and start with maqpna dev up --policy FILE. --policy takes bundle JSON, not ToolPolicy YAML.
5. See drift being stopped#
To see what drift looks like without changing a real server, pin a hash for get_time that does not match (here, all zeros). This is exactly what the gateway sees when the server changes the tool's definition after it was pinned:
maqpna dev run -- maqpna call --server echo --list | grep '"name"'
maqpna dev run -- maqpna call --server echo --tool get_time
maqpna mcp tools dev/echo --drift-only
"name": "echo"
"name": "delete_resource"
maqpna dev: session dev-949050aa (agent coder, namespace dev, on behalf of you@localhost)
error -32001: denied by policy rule pin/echo/get_time: tool_definition_changed
data: {"domain":"maqpna.com","policy":"","reason":"tool_definition_changed","rule":"pin/echo/get_time"}
maqpna dev: dev-949050aa: 1 deny, cost $0.0000 (maqpna dev timeline --last)
TOOL STATUS SHA256 VISIBLE CALLABLE CHANGES LAST SEEN
get_time changed sha256:125013379d07b081d5c5fa8a067d555796995f9354ebf11e4182a94aa470a0e8 false false 2026-10-03T00:07:32.955965-04:00
get_time is gone from tools/list, and a direct call is denied with reason tool_definition_changed and rule pin/echo/get_time. The SDKs raise PolicyDenied for it. After you review the new definition, export the pins again and replace the old ones.
6. Register a server in a cluster#
Scaffold an MCPServer, edit it, and validate it offline:
maqpna init mcpserver github --namespace team-a > github.yaml
apiVersion: maqpna.com/v1alpha1
kind: MCPServer
metadata:
name: github
namespace: team-a
spec:
url: https://mcp-github.tools.acme.eu/mcp
transport: streamable-http
protocolVersions: ["2025-11-25"]
trust: trusted
dataClass: internal
jurisdiction: EU-DE
timeoutSeconds: 60
auth:
type: bearerSecret
secretRef:
name: github-mcp
key: token
maqpna validate -f github.yaml
1 file(s), 1 object(s): 0 error(s), 0 warning(s)
The fields that matter for governance:
| Field | Effect |
|---|---|
auth |
bearerSecret, headerSecret (with headerName), oauthClientCredentials, tokenExchange (a down-scoped token from the identity broker for each session), mtls, or perUser with a provider (the user's own OAuth token from the gateway's token vault). The gateway injects the credential; the agent never sees it. |
trust |
untrusted taints every session that reads from the server with untrusted-input, unless a toolLabels entry says otherwise. See Taint and prompt-injection containment. |
dataClass |
confidential or restricted adds the session label data-class:<class> for policies to match. |
jurisdiction |
Checked against the sovereignty policy. See Sovereignty. |
dlpProfile |
The data loss prevention (DLP) profile for this server's arguments and results; none turns DLP off for it. See DLP profiles. |
shared, allowedNamespaces |
Let agents in other namespaces use the server. |
toolPins |
Pins for this server, as exported with --target mcpserver. |
Reference the server from the agent:
spec:
tools:
- name: github
serverRef: { name: github }
Apply both with maqpna apply -f github.yaml -f agent.yaml.
7. Pin in a cluster#
Run the agent once so the gateway observes the tools, then export and commit the pins, usually into the agent's ToolPolicy:
maqpna mcp tools team-a/github
maqpna mcp pin team-a/github --mode enforce --out pins.yaml
Paste the toolPins block into the ToolPolicy spec and apply it. The operator copies the gateway's view into MCPServer.status (observedTools, driftedTools and the Pinned condition), and the console's MCP servers page shows the same. Check for drift any time with maqpna mcp tools team-a/github --drift-only.
A good rollout is learn first (drift is recorded and audited, never blocked), then enforce with onChange: approve for a period, then onChange: hide.
Verify#
maqpna mcp tools NS/NAMEshows every tool aspinned.maqpna mcp tools NS/NAME --drift-onlyprints nothing (or only the tools you expect).- A changed tool returns
tool_definition_changedto the agent.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
no observed tools from maqpna mcp tools |
No session in that namespace has listed the server's tools yet, or NS is wrong. |
Run the agent (or maqpna call --server NAME --list) first, and use the session's namespace, for example dev/echo locally. |
โ expected NAMESPACE/NAME, got echo |
mcp tools and mcp pin need NS/NAME. |
Use dev/echo. |
Every tool of a new server is denied with reason default_action (or no_applicable_policy when no policy covers the agent) |
No rule allows the server's tools. | Add a rule, and test it with maqpna policy test (see Write policies). |
A tool is missing from tools/list |
It drifted under onChange: hide, or policy does not let the session see it. |
maqpna mcp tools NS/NAME --drift-only, then maqpna policy test. |
HTTP 401 from maqpna mcp ... |
No admin credential. | eval "$(maqpna dev env)" locally, or maqpna login. |
The agent gets unauthorized for one server |
The token has no tools:<name> scope. |
Locally, drop custom --scope flags; in a cluster, list the server in Agent.spec.tools. |
Next steps#
- Write policies for the new server's tools.
- Taint and prompt-injection containment for servers that return untrusted content.
- DLP profiles for servers that handle personal or secret data.
- Command reference:
maqpna mcp list,maqpna mcp tools,maqpna mcp pin.