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.
A tool that changes after approval waits for approval again
A server can pass review and then change the text that tells a model how to use its tools. This is sometimes called a rug pull. From 1.9.0 the Gateway records a fingerprint of each tool's description, input schema and output schema when its server's tools are synced. Clients only ever see the synced copy, never the server's live list, so a change can only arrive through a later sync.
When a later sync finds a tool whose description or schemas changed:
- Agents keep seeing the version that was approved.
- The server is quarantined. Its tools are not listed, and calls to it are refused, until the change is approved.
- The audit trail records
MCP_TOOL_DEFINITION_CHANGED, with the old and the new fingerprint and both descriptions. - The server's page in Flightdeck shows each changed tool, with the current and the new text.
To approve the change, a platform operator uses Release quarantine. A workspace admin can use Approve tool changes on their own workspace's server. The new text is then scanned for tool poisoning first. If the scan cannot clear it, nothing changes and the server waits for a platform operator.
This check is always on. A new tool, or a server's first sync, is recorded
without waiting for approval; the poisoning scan still reads it. Annotations
such as destructiveHint are held the same way when their values change.
A new tool can wait for approval
A server you approved can add a tool later. By default the next sync makes the new tool usable at once, and the poisoning scan reads it. From 1.9.0 a workspace can hold new tools instead, so that a person reviews each one before an agent can use it. This guards against scope creep (OWASP MCP02).
Turn it on in Flightdeck: a workspace admin uses Agents → MCP New Tools, and
a platform operator uses the MCP new tools section of the workspace form.
The setting is off by default, so nothing changes until you turn it on. It can
also be set by GitOps import, as mcp.tools.hold-new: true in the workspace's
metadata.
With the hold on, when a sync finds a tool the server did not have before:
- The tool is not listed to agents, and a call to it is refused as an unknown tool, the same answer as for a tool that does not exist.
- The server is not quarantined. Its other tools keep working.
- The audit trail records
MCP_TOOL_HELD, naming the server and the tool. - The server's page in Flightdeck lists it under New tools waiting for approval.
To approve it, a platform operator or the workspace admin uses Approve new
tools on the server's page. A workspace admin's approval scans the new tool
for tool poisoning first; if the scan cannot clear it, nothing changes and the
tool waits for a platform operator. The approval is recorded as
MCP_TOOL_APPROVED. A server's first sync is never held.
A server that reports a new version waits for approval too
A server can be replaced behind the same address. A new release can change what a tool does while every description stays the same, so the description check above does not see it. From 1.9.0 the Gateway also records the name and version the server reports about itself, at its first sync. When a later sync hears a different name or version:
- The server is quarantined until the change is approved, as above.
- The audit trail records
MCP_SERVER_IDENTITY_CHANGED, with the approved and the new name and version. - The server's page in Flightdeck shows the version it was approved at and the one it reports now.
Approval works the same way: Release quarantine for a platform operator, Approve tool changes for a workspace admin.
A server registered from the MCP catalog also records where it came from: the catalog, the entry, the package or image, and the version the catalog listed. The server's page shows these under Provenance.
Know the limits. The name and version are what the server says about itself, so a server that lies about its version is not caught. A hosted server listed only by its address gives nothing stronger to check. The next section checks what a server runs, which the server cannot claim.
A server is pinned to what it runs
From 1.9.0 the Gateway also records what a server actually is, not only what it says:
-
A local (STDIO) server is pinned to what its command runs, when it is registered or first synced. Nothing is downloaded and nothing is started to find out:
The command Is pinned to npx,pnpm dlx,npm exec,bunxwithpackage@1.2.3that package and exact version uvx,uv tool run,pipx runwithpackage==1.2.3that package and exact version docker run(orpodman run) withimage@sha256:…that image digest node,python3and other interpreters with a scripta fingerprint of the script file anything else a fingerprint of the program file Before every start the Gateway reads it again. If the file changed, or the command now names something else, the server is not started.
-
A remote (HTTPS) server can be pinned to its TLS public key. Enter the key's SHA-256 in TLS Key Pin on the server's page. Every connection then checks the key, after the usual certificate check. A pin on the key keeps working when a certificate is renewed with the same key. To get the value:
openssl s_client -connect mcp.example.com:443 </dev/null \| openssl x509 -pubkey -noout \| openssl pkey -pubin -outform der \| openssl dgst -sha256 -binary | base64
When a server is not what it is pinned to, nothing is started or sent to it. The
call fails with mcp_server_pin_mismatch, the server is quarantined, and the
audit trail records MCP_SERVER_PIN_MISMATCH with the pinned and the found
value. Approval works as above, and the found value becomes the new pin. An
honest upgrade, a rebuilt program or a rotated key therefore waits for approval
once.
Some commands cannot be pinned, and the server's page says why: a package with
no exact version (npx some-server@latest), an image named by a tag, or
python -m. Name an exact version or an image digest to pin them. Know the
limits here too. An exact version pins the package, not the versions of the
packages it depends on; an image digest pins everything. A package version is
trusted to mean the same content, which npm and PyPI guarantee for a published
version.
The GitOps export includes each server's pins, reported identity and provenance. An import keeps what a server already has, and does not lift a quarantine; change a pin on the server's page, or approve it.
A risk hint that contradicts the tool is flagged
A tool's hints, such as readOnlyHint and destructiveHint, are what its server
says about it. Some are wrong from the start. Public catalogues include delete
tools marked read-only and not destructive, often by a framework default. From
1.9.0 every sync checks each hint a server declares against the tool's name,
description and input schema. Three rules, and no AI model:
| The hint says | But the tool | Example |
|---|---|---|
readOnlyHint: true | creates, updates, sends, deletes or otherwise writes | delete_user_account marked read-only |
destructiveHint: false | deletes, removes, drops, purges or revokes | drop_table marked not destructive |
openWorldHint: false | crawls, scrapes or browses, or fetches a url it is given | fetch with a url parameter marked closed-world |
The Gateway reads the tool's action from the first verb in its name, so
get_post is a read and list_deleted_items is a list. If the name has no verb,
it uses the first word of the description. A hint the server leaves out is never
flagged, because the Gateway already reads it the cautious way.
When a sync finds a contradiction:
- The audit trail records
MCP_TOOL_ANNOTATION_CONTRADICTION, with the tool, the hint and the reason. The same contradiction is not recorded again on the next sync. - The server's page in Flightdeck lists it under Risk hints to review, for platform operators and for workspace users.
A flag does not change anything else. The hint stays in force and the server keeps serving. Check the tool before a policy relies on its hint. A tool whose name and description use no clear verb is not checked, so a missing flag does not prove a hint is right.
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.
From 1.9.0, when the server refuses because the client did not declare a
capability it needs (-32021), data also carries requiredCapabilities
where the MCP schema puts it: an object naming each capability, such as
{"sampling": {}}. Your client can read it, declare the capability, and try
again.
Also from 1.9.0, when a server answers resources/read with "resource not
found", data.uri names the URI your client asked for, written as your client
wrote it: dvara-mcp://<server>/<uri> on /mcp, the server's own URI on the
server's own address.
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 until the workspace allows it. DVARA carries two, Tasks and MCP Apps |
| 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; STDIO servers from 1.9.0 | A larger answer is refused with 502 MCP_RESPONSE_TOO_LARGE |
| Upstream protocol revision | The newest both sides speak, from 1.9.0 | 2026-07-28 when the server speaks it, else 2025-11-25 or 2025-06-18 through initialize; see upstream MCP servers on 2026-07-28 |
| 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. From 1.9.0 a workspace can have sampling made through the LLM Gateway (sampling) | 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.
STDIO servers. From 1.9.0 a STDIO server's answer is held to the same
4 MiB, counted in bytes. When an answer passes it, DVARA stops reading, fails
the call at once with 502 MCP_RESPONSE_TOO_LARGE and records it with
decision DENY, as for an HTTP server. It also stops the server process and
any process the server started. The next call starts the server again. In
1.8.6 such a call waited 30 seconds and failed as an unavailable server.
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, unless the workspace chose the LLM Gateway | Never relayed to your client: relayed, that model call would skip the LLM Gateway's policy, PII scan, budget, cost record and audit. By default the whole result is refused with MCP_SAMPLING_REFUSED. From 1.9.0 a workspace can have DVARA make the call through the LLM Gateway instead (sampling). |
elicitation/create, a question for the user | Relayed on the REST surface, and to 2026-07-28 clients of /mcp from 1.9.0 | 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.
The REST surface relays input requests, and so does the native /mcp
endpoint for a 2026-07-28 client's tool call, from 1.9.0. The client answers
by calling the tool again with inputResponses and the same requestState,
and DVARA sends both to the server unchanged. A client on 2025-11-25 or
2025-06-18 has no round trips, so for it such a result is refused with
502 MCP_INPUT_REQUIRED_UNSUPPORTED. DVARA tells an upstream server it may ask
for elicitation or roots only when your client said it can answer them, and
never tells a server it can sample. See
upstream MCP servers on 2026-07-28.
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.
See a long tool's progress
From 1.9.0 the same progressToken also brings you the progress the MCP server
itself reports while it works, such as "Indexed 400 of 1,200 files". Send the
call as above; DVARA answers as an event stream and passes on each
notifications/progress the server sends for your call, then the result:
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "refund-A-1042",
"progress": 400,
"total": 1200,
"message": "Indexed 400 of 1,200 files"
}
}
This works on every MCP revision DVARA serves, and for servers reached over Streamable HTTP, the older HTTP+SSE transport and STDIO. What DVARA does on the way:
- Your token stays yours. DVARA asks the server for progress under a token of its own, and puts yours back on each update. The server never sees your token, and an update for any other call is dropped.
- Values always go up. An update whose
progressis not higher than the last one is dropped, as MCP requires. If the call was first held for approval, the server's values continue on from the approval updates. - No floods. You get at most one update every 100 ms. When a server sends faster, the latest value is kept and sent at the end of the interval.
- The message is checked like a tool result. It is cut to 1,000 characters
and scanned with the workspace's injection settings. Under
BLOCKa flagged message is removed (the numbers still arrive), underSANITIZEthe flagged text is removed, underFLAGit passes. The first finding on a call is recorded in the audit trail. Any extra data the server attaches (_meta) is not passed on. - Nothing after the answer. Updates stop when the result is sent.
A call without a progressToken gets no updates, and DVARA does not ask the
server for any. A REST POST /mcp/{serverId}/tools/call has no event stream,
so it gets none either. The mcp_progress_notifications_total metric counts
updates by outcome: relayed, coalesced (replaced by a later one) and
dropped.
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.
Create, edit and delete virtual servers in Flightdeck under MCP → Virtual Servers, with the same rights as MCP servers. A virtual server whose member server was deleted is marked incomplete. See 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, on 2026-07-28, 2025-11-25 or 2025-06-18 (moving to 2026-07-28) |
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 carry the extension. It carries two: MCP Tasks
(
io.modelcontextprotocol/tasks) and MCP Apps (io.modelcontextprotocol/ui), each only when the MCP server declares it too. Every other extension 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 (an empty object when none is). 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), not_supported, not_offered (no server of the workspace declares
an extension DVARA carries) or requires_2026_07_28 (declared in initialize).
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.
Extensions of the MCP servers behind the Gateway
DVARA sits between two negotiations: your client and DVARA, and DVARA and each MCP server you register. From 1.9.0 it joins them up, so an extension is in use on a call only when all three agree.
- What each server declares is recorded. Each tool sync records the
extensions the server declares: in its
server/discoveranswer on2026-07-28, or in itsinitializeanswer on an older revision. The MCP server's page in Flightdeck shows them (Declared extensions), the server list shows them for workspace roles, and theMCP_SERVER_TOOLS_SYNCEDaudit event lists them asdeclared_extensions. A server not synced since 1.9.0 shows nothing, which is different from "none". - DVARA declares an extension to a server only when it may. On a call to a
server on
2026-07-28, DVARA declares an extension only when your client declared it on that request, the workspace allows it, and that server declares it. It passes on your client's settings for the extension unchanged. A server on an older revision is never sent an extension, because its session is shared by every caller of the workspace. - Extension data reaches your client only when the extension is in use.
Before a result reaches your client, DVARA removes official extension data
(
_metakeys underio.modelcontextprotocol/that are not part of the core protocol) for any extension not in use on that call. Each removal writes anMCP_EXTENSION_UNDECLAREDaudit event and a warning log line, at most once a day per workspace, server, key and reason. Other_metakeys, under a server's own vendor prefix, pass as before. server/discoverpromises only what a server offers. DVARA grants an extension it carries only when at least one of the workspace's active servers declares it (otherwiseMCP_EXTENSION_DENIEDwith reasonnot_offered). Aninitializeanswer never grants one (reasonrequires_2026_07_28), because a client on an older revision does not declare its extensions on each request.
DVARA builds on this to carry two extensions, Tasks and MCP Apps. Both are off until a workspace allows them. For the order to turn them on, see Govern MCP Apps and Tasks.
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, unless the workspace allows it; 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, or let the workspace
answer sampling through the LLM Gateway (sampling).
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 serves MCP protocol revisions
2026-07-28,2025-11-25and2025-06-18. A2026-07-28client connects with no handshake. An older client that asksinitializefor another revision is answered with2025-11-25, and the client decides whether it can continue. See MCP protocol versions. - On
2026-07-28a client can opensubscriptions/listenand is told when its tool, prompt or resource list changes, or a resource it subscribed to; see Be told when your tools, prompts or resources change. On2025-11-25and2025-06-18the tools capability advertiseslistChanged: false, and catalog changes appear on the nexttools/list. - 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
- Move MCP clients and servers to 2026-07-28 for what changes, in what order, when your clients and servers upgrade.
- Govern MCP Apps and Tasks for the settings and Flightdeck pages that turn on MCP Apps, Tasks and sampling.
- 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.