Skip to main content
Version: 1.9.0

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.

Enterprise only

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:

  1. authenticates the API key and fixes the workspace boundary;
  2. checks that the server and tool are available to that workspace;
  3. evaluates matching tool policy and scans arguments for PII;
  4. applies loop detection and any configured human-approval rule;
  5. sends an allowed call to the registered upstream server;
  6. scans a successful response for PII; and
  7. 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 commandIs pinned to
    npx, pnpm dlx, npm exec, bunx with package@1.2.3that package and exact version
    uvx, uv tool run, pipx run with package==1.2.3that package and exact version
    docker run (or podman run) with image@sha256:…that image digest
    node, python3 and other interpreters with a scripta fingerprint of the script file
    anything elsea 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 saysBut the toolExample
readOnlyHint: truecreates, updates, sends, deletes or otherwise writesdelete_user_account marked read-only
destructiveHint: falsedeletes, removes, drops, purges or revokesdrop_table marked not destructive
openWorldHint: falsecrawls, scrapes or browses, or fetches a url it is givenfetch 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:

RefusalJSON-RPC codeHTTP-equivalent decision
Killed session, denied approval, or approval store unavailable-32001403
Approval timeout-32002408
Agent loop detected-32003429

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?​

Control1.8 defaultWhat that means
Workspace authenticationRequired/mcp accepts only an active DVARA API key
Native endpointOnSet dvara.mcp-gateway.native.enabled: false to turn it off
PII scanningOn, action LOGDetected PII is recorded, but the call continues
Response PII scanningOnSuccessful responses are scanned before release
Loop detectionOnThe fifth identical consecutive call in one session is rejected by the default threshold
Automatic session killOffA detected loop rejects that call but does not kill the session
Human approval engineOn, with no matching rulesCalls do not pause until a workspace names required tools or servers
MCP extensionsNone allowedEvery extension a client declares is refused and recorded until the workspace allows it. DVARA carries two, Tasks and MCP Apps
Approval timeout300 seconds, then denyA matching call is refused if nobody decides in time
Tool-call argument size1 MiB, from 1.8.5A call with larger arguments is refused with 413 MCP_ARGUMENTS_TOO_LARGE
Upstream answer size4 MiB whole, 16 MiB streamed, from 1.8.5; STDIO servers from 1.9.0A larger answer is refused with 502 MCP_RESPONSE_TOO_LARGE
Upstream protocol revisionThe newest both sides speak, from 1.9.02026-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 upstreamsWork until 2027-07-28Each use is logged and audited; from that date a call is refused with 410 mcp_transport_sunset
Input requests from a serverSampling 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.

On upgrade to 1.8.5

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 requestDefaultWhat DVARA does
sampling/createMessage, a model callRefused, unless the workspace chose the LLM GatewayNever 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 userRelayed on the REST surface, and to 2026-07-28 clients of /mcp from 1.9.0Its text is scanned for prompt injection like any result.
roots/list, the client's filesystem rootsRefusedRefused 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 progress is 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 BLOCK a flagged message is removed (the numbers still arrive), under SANITIZE the flagged text is removed, under FLAG it 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.

ActionTool argumentsSuccessful tool response
LOGRecord the detection and forward the original valuesRecord MCP_PII_OUTPUT_LEAK and release the response
REDACTReplace detected values before the upstream callReplace newly detected values before release
TOKENIZEReplace values with reversible tokens; refuse the call if no token store is availableIrreversibly redact newly detected response values
BLOCKReturn MCP_PII_DETECTED with HTTP 400; do not call the upstreamReturn 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.

TransportUse it for
STREAMABLE_HTTPA modern native MCP server, on 2026-07-28, 2025-11-25 or 2025-06-18 (moving to 2026-07-28)
SSEA native MCP server that still uses the older HTTP and SSE transport. Deprecated; refused from 2027-07-28
RESTA server that implements DVARA's earlier REST tool contract
REST_BRIDGEA 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:

  1. The workspace allow-list must allow it. Unknown extensions are denied by default: an empty list allows nothing.
  2. Each allowed extension is then put to the workspace's active policies. A policy can narrow the allow-list, never widen it.
  3. 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:

EntryAllows
io.modelcontextprotocol/tasksThat extension, any version
io.modelcontextprotocol/*Every extension from that vendor prefix
com.example/charts@2That extension only when its settings say version 2
noneNothing, 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/discover answer on 2026-07-28, or in its initialize answer on an older revision. The MCP server's page in Flightdeck shows them (Declared extensions), the server list shows them for workspace roles, and the MCP_SERVER_TOOLS_SYNCED audit event lists them as declared_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 (_meta keys under io.modelcontextprotocol/ that are not part of the core protocol) for any extension not in use on that call. Each removal writes an MCP_EXTENSION_UNDECLARED audit event and a warning log line, at most once a day per workspace, server, key and reason. Other _meta keys, under a server's own vendor prefix, pass as before.
  • server/discover promises only what a server offers. DVARA grants an extension it carries only when at least one of the workspace's active servers declares it (otherwise MCP_EXTENSION_DENIED with reason not_offered). An initialize answer never grants one (reason requires_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/list and tools/call. It does not expose MCP resources or prompts in 1.8; resources/* and prompts/* requests return a JSON-RPC method-not-found error.
  • DVARA serves MCP protocol revisions 2026-07-28, 2025-11-25 and 2025-06-18. A 2026-07-28 client connects with no handshake. An older client that asks initialize for another revision is answered with 2025-11-25, and the client decides whether it can continue. See MCP protocol versions.
  • On 2026-07-28 a client can open subscriptions/listen and 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. On 2025-11-25 and 2025-06-18 the tools capability advertises listChanged: false, and catalog changes appear on the next tools/list.
  • A present browser Origin must match dvara.mcp-gateway.native.allowed-origins. Requests without Origin are 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​