Govern tool calls with the MCP Gateway
An agent should not gain unrestricted access to every tool just because it can speak MCP. The DVARA MCP Gateway puts workspace identity, policy, data protection, approval, loop detection, and audit controls between your agent and its tools.
Not included in DVARA Open Source.
How does DVARA govern a tool call?
Your MCP client connects to DVARA over Streamable HTTP at /mcp. DVARA resolves
the API key to one workspace and returns only that workspace's registered tools.
Tool names use {serverId}__{toolName}, such as support__search_articles, so
tools from different servers cannot collide.
When the client calls a tool, DVARA:
- authenticates the API key and fixes the workspace boundary;
- checks that the server and tool are available to that workspace;
- evaluates matching tool policy and scans arguments for PII;
- applies loop detection and any configured human-approval rule;
- sends an allowed call to the registered upstream server;
- scans a successful response for PII; and
- records the intent and result in the tamper-evident audit trail.
The MCP client still plans the work. DVARA governs the call that crosses the gateway.
What do you need before you connect?
You need a running Enterprise platform deployment (a licence is optional), an active workspace API key or configured MCP OAuth issuer, and at least one active MCP server with a synced tool catalog. Register and sync a server from MCP → MCP Servers in the DVARA Flightdeck.
The native endpoint is on by default. If an operator has disabled it with
dvara.mcp-gateway.native.enabled: false, /mcp is not available.
Connect an MCP client
The examples below use a workspace API key, which is the default. When MCP
OAuth is enabled, use an OAuth access token instead. API keys remain accepted
unless the operator sets dvara.mcp-gateway.oauth.api-keys-enabled: false.
See Authenticate MCP clients with OAuth 2.1.
Point a Streamable HTTP client at the gateway. Replace <dvara-host> and
<your-api-key> with values from your deployment.
{
"mcpServers": {
"dvara": {
"type": "streamableHttp",
"url": "https://<dvara-host>/mcp",
"headers": {
"Authorization": "Bearer <your-api-key>",
"X-Session-Id": "support-run-42"
}
}
}
}
X-Session-Id is optional, but add it when you want DVARA to join tool calls
into a session and detect repeated behavior across calls. It is a correlation
value, not an identity credential.
List the tools your agent can use
The following request uses the MCP JSON-RPC contract directly. A POST request must accept both JSON and event-stream responses.
curl --silent https://<dvara-host>/mcp \
--header 'Authorization: Bearer <your-api-key>' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json, text/event-stream' \
--header 'X-Session-Id: support-run-42' \
--data '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}'
A workspace with a support server can receive:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "support__search_articles",
"description": "Search approved customer-support articles",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "support__issue_refund",
"description": "Issue a refund for an order",
"inputSchema": {
"type": "object",
"properties": {
"order_id": { "type": "string" }
},
"required": ["order_id"]
},
"annotations": { "destructiveHint": true },
"_meta": { "dvara/approval_required": true }
}
]
}
}
DVARA scans tool descriptions before returning them. A description detected as tool poisoning is omitted from the list.
From 1.8.4 each tool keeps the annotations its server declared, such as
readOnlyHint and destructiveHint; earlier releases dropped them. A tool the
workspace holds for human approval is marked with
_meta["dvara/approval_required"]: true, so a client can tell in advance that a
call to it will wait for a person.
Call a governed tool
Use the namespaced tool name returned by tools/list:
curl --silent https://<dvara-host>/mcp \
--header 'Authorization: Bearer <your-api-key>' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json, text/event-stream' \
--header 'X-Session-Id: support-run-42' \
--data '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "support__search_articles",
"arguments": { "query": "refund policy" }
}
}'
An allowed call returns a standard MCP tool result:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "Returns are accepted within 30 days of delivery."
}
],
"isError": false
}
}
An upstream tool can also complete normally with a tool-level error. DVARA returns that result unchanged so the model can read the tool's message and recover:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{"type": "text", "text": "file not found: report.csv"}],
"isError": true
}
}
This is not a gateway error: the HTTP and JSON-RPC exchange succeeded. In 1.8,
the tool-call evidence therefore records a successful transport even though
the returned result carries isError: true. An upstream HTTP or transport
failure still returns a gateway error.
How are native governance refusals returned?
The native endpoint uses stable application-specific JSON-RPC codes:
| Refusal | JSON-RPC code | HTTP-equivalent decision |
|---|---|---|
| Killed session, denied approval, or approval store unavailable | -32001 | 403 |
| Approval timeout | -32002 | 408 |
| Agent loop detected | -32003 | 429 |
The error data also carries the DVARA code (dvara_code) and the
HTTP-equivalent status (http_status). A real,
unexpected gateway fault returns -32603 Internal error; exception details are
not returned to the client. Native tool-call evidence carries the request trace
ID.
From 1.8.4, when the upstream server itself answers a tool call with a JSON-RPC
error, such as Unknown tool, DVARA returns that error with the server's own
code and message. Its data adds dvara_code (mcp_upstream_rejected for an
invalid request, unknown method or invalid params; mcp_upstream_error for any
other code), http_status, trace_id and, when the server sent any,
upstream_data. Before 1.8.4 this was reported as an unreachable server. See
MCP Gateway errors.
Open MCP → Tool Calls in the Flightdeck (in the Flightdeck, Agents → MCP Tool Calls) to verify the workspace, session,
server, tool, policy decision, PII flags, status, and latency. A completed call
writes MCP_TOOL_INTENT before execution and MCP_TOOL_RESULT afterward.
What happens without extra configuration?
| Control | 1.8 default | What that means |
|---|---|---|
| Workspace authentication | Required | /mcp accepts only an active DVARA API key |
| Native endpoint | On | Set dvara.mcp-gateway.native.enabled: false to turn it off |
| PII scanning | On, action LOG | Detected PII is recorded, but the call continues |
| Response PII scanning | On | Successful responses are scanned before release |
| Loop detection | On | The fifth identical consecutive call in one session is rejected by the default threshold |
| Automatic session kill | Off | A detected loop rejects that call but does not kill the session |
| Human approval engine | On, with no matching rules | Calls do not pause until a workspace names required tools or servers |
| MCP extensions | None allowed | Every extension a client declares is refused and recorded; DVARA serves none yet |
| Approval timeout | 300 seconds, then deny | A matching call is refused if nobody decides in time |
| Tool-call argument size | 1 MiB, from 1.8.5 | A call with larger arguments is refused with 413 MCP_ARGUMENTS_TOO_LARGE |
| Upstream answer size | 4 MiB whole, 16 MiB streamed, from 1.8.5 | A larger answer is refused with 502 MCP_RESPONSE_TOO_LARGE |
| Legacy HTTP+SSE upstreams | Work until 2027-07-28 | Each use is logged and audited; from that date a call is refused with 410 mcp_transport_sunset |
| Input requests from a server | Sampling refused, elicitation relayed, roots refused, from 1.8.5 | See what happens when a server asks for input |
A session kill is keyed by workspace and session ID. It refuses that ID only
for the workspace that issued the kill; the same ID in another workspace keeps
working. From 1.8.5 a kill also stops the session's model calls and A2A hops,
on every Gateway, for 24 hours. A tool call held for approval when its session
is killed is denied at once and refused with SESSION_KILLED. See
Stop an agent session.
Policy enforcement is conditional on the active policies for the workspace. A
policy denial stops the call before the upstream and returns
MCP_POLICY_DENIED. A policy evaluation failure fails closed with
MCP_POLICY_ERROR.
From 1.8.4 a call a policy denied has its own tool-call record, with policy
decision DENY, status 403 and MCP_POLICY_DENIED, so it appears in
the Flightdeck's MCP → Tool Calls and in the session timeline. The policy and rule that
decided are on the MCP_POLICY_DENIED audit event with the same trace ID. The
call reached no server, so it is not counted as a governed call for limits or
billing.
How large can a tool call and its answer be?
From 1.8.5 both directions have a size limit.
Arguments. A tools/call whose arguments, as JSON, are larger than 1 MiB
is refused with 413 MCP_ARGUMENTS_TOO_LARGE. The check runs before the schema
check, policy, the PII scan and the upstream server, on the REST surface and on
/mcp. The call is recorded as a denial: an MCP_ARGUMENTS_TOO_LARGE audit
event and a tool-call row with decision DENY. Before 1.8.5 a call of any size
was scanned for PII, put to policy and forwarded.
Answers. DVARA counts an upstream server's bytes as it reads them. A whole
answer may be up to 4 MiB and a streamed answer up to 16 MiB. Past a limit the
call fails with 502 MCP_RESPONSE_TOO_LARGE before the answer is parsed, is not
retried, and is recorded with decision DENY. This covers REST,
REST_BRIDGE, STREAMABLE_HTTP and SSE servers and the tool sync. The
default is lower than the A2A plane's because a multi-megabyte tool result
takes seconds to read; a larger limit would time out before it refused.
An operator sets the fleet values with
dvara.mcp-gateway.limits.max-arguments-bytes and
dvara.mcp-gateway.response-limits.*; 0 turns a limit off. A workspace can
have its own values, set in the Size limits section of the Flightdeck
workspace form. The Flightdeck shows them to the workspace admin, read-only. See
how large a request or response can be.
A tool call with more than 1 MiB of arguments, or an answer over 4 MiB, is refused where it used to be forwarded or read whole. Raise the limit for a workspace that needs more before you upgrade.
Ask what a server can do
From 1.8.5 DVARA answers the MCP server/discover request itself and never
forwards it. A workspace is never told of an upstream's tools or capabilities
it cannot use.
On /mcp the answer names the Gateway, its protocol version and only what the
workspace can use: tools when it has a tool to call, and the extensions its
allow-list grants. No tool is named. On the REST surface,
POST /mcp/{serverId}/server/discover answers for that one server and runs the
governed chain like tools/list, so another workspace's server is not found.
A key with scopes needs mcp:read. On /mcp each call is audited as
MCP_SERVER_DISCOVERED; the REST call is recorded as MCP_TOOL_INTENT and
MCP_TOOL_RESULT with operation server/discover.
What happens when a server asks for input?
The MCP revision 2026-07-28 lets a server answer a call with
resultType: "input_required": requests the client must answer before it
retries. From 1.8.5 DVARA decides each one before it reaches your client:
| Input request | Default | What DVARA does |
|---|---|---|
sampling/createMessage, a model call | Refused, always | The whole result is refused with MCP_SAMPLING_REFUSED. Relayed, that model call would skip the LLM Gateway's policy, PII scan, budget, cost record and audit. |
elicitation/create, a question for the user | Relayed on the REST surface | Its text is scanned for prompt injection like any result. |
roots/list, the client's filesystem roots | Refused | Refused because it discloses the client's file layout to the server. |
A workspace can change elicitation and roots with its settings
mcp.input_requests.elicitation and mcp.input_requests.roots (allow or
refuse). Its policies can deny them too: the policy sees operation
input_requests/elicitation or input_requests/roots. The fleet defaults are
dvara.mcp-gateway.input-requests.*.
The server's opaque requestState is never parsed. It must be a string of at
most 16 KiB each way, or the call is refused with MCP_REQUEST_STATE_INVALID.
It is recorded only as its size and digest. Every round trip is audited as
MCP_INPUT_REQUESTED and MCP_INPUT_RESPONDED, joined by that digest. Each
retry is a governed tool call of its own, so budgets, rate limits and loop
detection count every one.
Only the REST surface relays input requests. The native /mcp endpoint speaks
2025-06-18, which has no round trips, so it refuses such a result with
502 MCP_INPUT_REQUIRED_UNSUPPORTED.
See progress while a call waits for approval
A tool call held for human approval can wait minutes. From 1.8.4 a client can
ask to hear about it: put a progressToken in the call's _meta.
curl --silent --no-buffer https://<dvara-host>/mcp \
--header 'Authorization: Bearer <your-api-key>' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json, text/event-stream' \
--data '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "support__issue_refund",
"arguments": { "order_id": "A-1042" },
"_meta": { "progressToken": "refund-A-1042" }
}
}'
DVARA then answers the call as an event stream. When the call is held, it sends
an MCP notifications/progress at once and every 5 seconds after, until a
reviewer decides or the approval expires. Each names the approval ID shown in
the Approvals queue and its expiry, in message and in _meta:
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "refund-A-1042",
"progress": 5,
"total": 300,
"message": "Held for approval 7f3c9a2e-5d41-4b8e-9c6a-1e2f3a4b5c6d until 2026-09-30T15:05:00Z",
"_meta": {
"dvara/approval_id": "7f3c9a2e-5d41-4b8e-9c6a-1e2f3a4b5c6d",
"dvara/expires_at": "2026-09-30T15:05:00Z"
}
}
}
progress is the seconds held so far and total the seconds held plus the
seconds left until expiry. The last event is the call's answer. No notification
is sent after the decision. The interval is
dvara.mcp-gateway.agentic.approval.progress-interval-ms (default 5000). A
call without a progressToken, and a REST POST /mcp/{serverId}/tools/call,
wait with no notifications, as before.
Choose how PII is handled
Set a workspace's MCP PII action to match its risk. LOG is the default.
| Action | Tool arguments | Successful tool response |
|---|---|---|
LOG | Record the detection and forward the original values | Record MCP_PII_OUTPUT_LEAK and release the response |
REDACT | Replace detected values before the upstream call | Replace newly detected values before release |
TOKENIZE | Replace values with reversible tokens; refuse the call if no token store is available | Irreversibly redact newly detected response values |
BLOCK | Return MCP_PII_DETECTED with HTTP 400; do not call the upstream | Return MCP_PII_DETECTED with HTTP 403; do not release the response |
Response values are not tokens DVARA created on the request path, so
TOKENIZE uses irreversible redaction for newly detected response PII.
Request-side tokenization writes MCP_PII_TOKENIZED. If tokenization cannot
run, DVARA writes the retained MCP_PII_REDACT_DEGRADED event with
action: TOKENIZE; the default degraded action refuses the call with
MCP_PII_TOKENIZE_UNAVAILABLE. These names preserve the 1.7 audit contract—do
not infer reversible redaction from the older event name.
Give an agent only the tools it needs
A virtual MCP server is a workspace-scoped, default-deny view of tools from one
or more registered servers. Its allow list contains namespaced tool names such
as support__search_articles.
Select a virtual server for a client with:
X-DVARA-Virtual-Server: support-agent-tools
With this header, tools/list returns only the allow-listed tools. A call to a
tool outside the allow list is refused before an upstream receives it. The
allowed tools still pass through the same policy, PII, approval, loop, and audit
controls. Virtual servers cannot contain other virtual servers.
Forward a user's upstream identity
Registered servers use stored credentials by default. Set a server's auth mode
to DELEGATED when the upstream should enforce the calling user's permissions,
then send that user's token separately:
X-DVARA-Delegated-Authorization: Bearer <upstream-user-token>
Authorization still carries the DVARA API key. A delegated server called
without the second token returns MCP_DELEGATED_TOKEN_REQUIRED. DVARA uses the
delegated token for that upstream call and does not store or log it; audit data
contains the delegated auth mode and a fingerprint instead.
Choose an upstream transport
The client-to-DVARA connection uses Streamable HTTP. Each registered upstream server separately chooses how DVARA reaches it.
| Transport | Use it for |
|---|---|
STREAMABLE_HTTP | A modern native MCP server using protocol revision 2025-06-18 |
SSE | A native MCP server that still uses the older HTTP and SSE transport. Deprecated; refused from 2027-07-28 |
REST | A server that implements DVARA's earlier REST tool contract |
REST_BRIDGE | A plain REST API described by OpenAPI and exposed as governed tools |
For REST_BRIDGE, DVARA turns supported OpenAPI operations into tools. The
server's base URL is validated before it is saved, and every generated tool call
uses the same governance path as a native MCP call.
From 1.8.5 the legacy HTTP+SSE transport (SSE) has a sunset date. The MCP
revision 2026-07-28 deprecates it. An SSE server keeps working until
2027-07-28. Until then each use is logged as a warning and audited as
MCP_DEPRECATED_TRANSPORT_USED, at most once a day per server. From that date a
call is refused with 410 mcp_transport_sunset and a tool sync fails. Move such
servers to STREAMABLE_HTTP. An operator can move the date with
dvara.mcp-gateway.legacy-sse-sunset, set on the Gateway and on Flightdeck.
DVARA does not follow redirects from an upstream MCP server. Register the final
URL: a 3xx response fails the tool call, tool sync, or health check instead of
being followed to another address.
Control which MCP extensions a client may use
The MCP revision 2026-07-28 adds extensions: optional features outside the
core protocol, named vendor-prefix/name, such as
io.modelcontextprotocol/tasks. An extension is in use only when both sides
declare it.
From 1.8.4 DVARA reads the extensions a client declares, in initialize or in a
request's _meta, and decides each one for the workspace:
- The workspace allow-list must allow it. Unknown extensions are denied by default: an empty list allows nothing.
- Each allowed extension is then put to the workspace's active policies. A policy can narrow the allow-list, never widen it.
- DVARA must serve the extension. It serves none yet, so today no extension is granted, and every one a client declares is refused and recorded.
A refusal never fails the request. The client falls back to core MCP behavior.
When a client declared any extension, the initialize answer carries
capabilities.extensions with the granted ones (today an empty object). A
client that declares no extensions gets exactly the answer it got before.
Each refused extension writes an MCP_EXTENSION_DENIED audit event and a
warning log line, at most once a day per workspace, extension, version, reason
and client on each Gateway pod. The event's reason is not_allowed,
version_not_allowed, invalid_identifier, policy_denied (with the policy
and rule), policy_error (the policy could not be evaluated, so the extension is
refused) or not_supported.
Set the allow-list
The fleet default is dvara.mcp-gateway.extensions.allowed (empty by default,
which allows nothing). A workspace's own list replaces it. Set it in
Flightdeck: the Flightdeck workspace form's MCP extensions section (owner), or
the Flightdeck's Agents → MCP Extensions page (workspace admin). Put one entry
per line or separate entries with commas:
| Entry | Allows |
|---|---|
io.modelcontextprotocol/tasks | That extension, any version |
io.modelcontextprotocol/* | Every extension from that vendor prefix |
com.example/charts@2 | That extension only when its settings say version 2 |
none | Nothing, even when the fleet default allows some |
Leave the field blank to use the fleet default. Flightdeck refuses a malformed
entry when you save, and a GitOps import refuses it too. A Flightdeck change is
audited as WORKSPACE_MCP_EXTENSIONS_UPDATED. In GitOps the workspace key is
mcp.extensions.allowed.
Deny an extension with a policy
The policy sees operation extensions/negotiate, with mcp.operation,
mcp.extension_id and mcp.extension_version in the request metadata
(mcp.extension_version is empty when the client names none). Write the rule as
a CEL expression:
version: "1"
rules:
- id: no-tasks
expression: request.metadata["mcp.extension_id"] == "io.modelcontextprotocol/tasks"
action: DENY
A rule that does not check mcp.operation applies to extension negotiation too,
as it does to every MCP operation.
Know which deprecated MCP features clients still use
The MCP revision 2026-07-28 deprecates Roots, Sampling and Logging. The
specification keeps them until at least 2027-07-28. DVARA has never offered
them, and still does not: it never asks a client for roots, never sends a
sampling request, and does not advertise logging.
From 1.8.4 DVARA records who still uses them. When a client on /mcp declares
the roots or sampling capability, sends notifications/roots/list_changed,
calls logging/setLevel, or carries either capability or a log level in a
request's _meta, DVARA logs a warning and writes an
MCP_DEPRECATED_FEATURE_USED audit event naming the feature, the client and the
dates. The same client is recorded at most once a day per feature on each
Gateway pod. A failed audit write never fails the request.
Answers do not change, except that the -32601 refusal of logging/setLevel
now says Logging is deprecated. From 1.8.5 a server's own sampling or roots
request is refused too; see
what happens when a server asks for input.
The legacy HTTP+SSE upstream transport has its own
sunset date. Instead of Roots, pass directories or files as
tool arguments, which DVARA governs. Instead of Sampling, have the tool server
call a model through the LLM Gateway with its own API key.
Know the native endpoint boundaries
The native endpoint limits the complete HTTP request body with
dvara.mcp-gateway.limits.max-request-bytes, which defaults to 4 MiB from
1.8.6. A larger declared or chunked body receives a bare HTTP 413 before
JSON-RPC processing. Authentication runs first, so an invalid credential still
receives 401 without its body being read. Set the limit to 0 or less to
disable it.
The limit applies only to native POST /mcp; it does not cover the
server-scoped REST bridge. The tool-argument limit is separate and defaults to
1 MiB. A request can therefore pass the body limit and still be refused because
its serialized arguments value is too large. Likewise, a workspace argument
limit above 4 MiB cannot take effect on the native endpoint unless the operator
raises this outer limit.
The older server-scoped REST bridge accepts POST /mcp/{serverId}/resources/{*path} and POST /mcp/{serverId}/prompts/{*path}. DVARA forwards the wildcard suffix as the MCP operation: /resources/list becomes resources/list, /resources/read becomes resources/read, /prompts/list becomes prompts/list, and /prompts/get becomes prompts/get. Send that operation's parameter object as the JSON body. These paths are separate from the native JSON-RPC boundary below.
- The native endpoint advertises the MCP tools capability and implements
tools/listandtools/call. It does not expose MCP resources or prompts in 1.8;resources/*andprompts/*requests return a JSON-RPC method-not-found error. - DVARA implements MCP protocol revision
2025-06-18. If a client requests a different revision duringinitialize, DVARA answers with2025-06-18; the client decides whether it can continue. - The tools capability advertises
listChanged: false. Catalog changes appear on the nexttools/list; DVARA does not push a change notification. - A present browser
Originmust matchdvara.mcp-gateway.native.allowed-origins. Requests withoutOriginare accepted, and localhost origins are accepted for local development. - The native endpoint is exactly
/mcp. The earlier REST contract remains on paths beneath/mcp/{serverId}/...for existing integrations.
Where to go next
- Govern MCP tool calls in 5 minutes for a one-script local setup with a demo MCP server already registered.
- Stop runaway agents and approve risky actions for loop detection, session kills, and approval rules.
- Manage agents and MCP in Flightdeck to register servers, sync tools, and inspect calls.
- Understand workspace isolation to see how API keys scope every MCP request.