MCP integration
Gating an MCP server with the Verification Gateway — body-aware policy, PDLSS mapping for JSON-RPC, dedupe headers, identity reconciliation.
Gating an MCP server with the Verification Gateway. Covers the body-aware policy pattern, the PDLSS mapping for JSON-RPC traffic, the dedupe header for inner-hop REST calls, and the identity-reconciliation rules so your audit trail stays consistent across hops.
Why a separate middleware — The default createMiddleware from /express matches by URL +
method, but every MCP request lands on the same /mcp URL. Without peeling the JSON-RPC body, you
can't distinguish initialize (low-risk handshake) from tools/call start_checkout
(high-risk). The MCP middleware peels the body, applies a per-method risk tier, and tags outbound
responses with X-Astra-Verified-Hop so inner REST hops can dedupe.
Quick start
Mount createMcpMiddleware on your MCP route, after express.json().
import express from 'express';
import { createMcpMiddleware } from '@astrasyncai/verification-gateway/mcp';
const app = express();
app.use(express.json()); // body must be a parsed object before the middleware runs
app.post(
'/mcp',
createMcpMiddleware({
apiBaseUrl: 'https://astrasync.ai/api',
apiKey: process.env.ASTRASYNC_API_KEY,
counterpartyId: 'ASTRAE-…', // your MCP endpoint — enables the dashboard tool rules
counterpartyType: 'mcp_server',
// Per-tool gates in code. Tools not listed are enforced by default for
// tools/call (handshake/introspection passes through). The dashboard's
// tool rules can add checks to these but never loosen them.
toolGates: {
browse_catalog: { purpose: 'shopping', action: 'shopping.search' },
start_checkout: { purpose: 'shopping', action: 'shopping.checkout' },
confirm_purchase: { purpose: 'shopping', action: 'commerce.payment.execute' },
},
onAgentIdMismatch: 'reject', // safest — see "Identity reconciliation" below
}),
yourMcpServerHandler
);Gating the tool enforces ACCESS, not spend — authorize the value before you settle.
toolGates verifies the agent's identity and access to start_checkout — it does not
enforce the agent's PDLSS spending limits, because the middleware never sees your priced cart
total. If you settle in your own handler without a value check, those limits are unenforced.
Before settling, call authorizeSettlement(config, { agentId, value, currency }) with your
authoritative priced total (fail-closed; a step-up-band or over-Hard-Limit value, a missing
value, or a verify error all refuse settlement). See
Direct path: authorize the cart value for the worked
example. This is mandatory.
Tool rules
An MCP server endpoint (counterpartyType: 'mcp_server') is governed by tool rules, edited on
the endpoint page in the dashboard: one rule per tool, { tool, purpose, action, observe? }
(plus an optional resource). Purpose and action are required. The same rules are enforced on
both sides of the hop:
- Your middleware. With
apiKeyandcounterpartyIdset,createMcpMiddlewarefetches the rules (refreshed every 5 minutes;routesRefreshMsto change it) and turns each into a gate for thattools/calltool name. A rule changed on the dashboard applies at the next refresh, with no deploy. - AstraSync's agent bridge. An agent reaching your server through AstraSync calls
invoke_endpoint({ astraeId, tool, arguments }). The bridge calls only tools that have a rule, verifies the agent with the rule's purpose and action (never ones the agent picks), then opens a real MCP session with your server (initialize, thentools/call) carrying the agent's identity headers. It never forwards a raw path to an MCP endpoint.
Precedence — a dashboard rule can tighten a code gate, never loosen it. For a tool you gate in
toolGates:
- a dashboard
observerule, or one with no purpose, is ignored; - a dashboard rule whose purpose or action differs is checked in addition — both must pass;
- tools with no code gate (or a code
'observe') follow the dashboard rule.
Set toolGatePrecedence: 'dashboard' to let the dashboard rule win outright. The middleware sends
its code-gated tool names on the policy fetch, and the dashboard marks those tools "gated in code —
a rule here can only add checks".
Path rules saved on an MCP endpoint before tool rules existed (for example * /mcp minAccessLevel: none) are ignored by the middleware and the bridge. The dashboard shows them as
"ignored — replace with tool rules"; saving tool rules removes them. The policy API returns them
with legacy: true, and saving a path rule on an MCP endpoint is refused with
mcp_rules_are_tool_rules.
One endpoint per surface. Tool rules live on an mcp_server endpoint, because your middleware
and the bridge must read the same contract. If your website and its MCP server share one (website)
endpoint, register a separate MCP endpoint for the MCP server — same owner, URL of the MCP
server — and use its ASTRAE id as the middleware's counterpartyId. An API key that already
operates your site endpoint may create it with POST /api/endpoints. From SDK 5.20.0 the
middleware warns once (console.warn) when its counterpartyId is not an mcp_server endpoint.
Fewer backend calls (SDK 5.20.0). skipEvaluation: { tools, methods } passes the listed
unenforced tools and methods through with no verify-access call — use it for initialize,
tools/list, resources/list and resources/read, and for observe tools whose handler already
calls verify-access itself. Concurrent identical verifies share one call, and a cached verdict no
longer records a second decision.
Risk tiers per JSON-RPC method
Default mapping. Override per-tool with toolGates or per-method with methodGates.
| Method | Default tier | Reason |
|---|---|---|
initialize | none | Handshake. Must succeed for unregistered probes. |
notifications/initialized | none | Post-init handshake notification. |
tools/list, prompts/list, resources/list, ping | none | Introspection. Public-surface by design. |
resources/read | read-only | Reads stored content. |
tools/call | standard | Tool execution. Override per tool with toolGates. |
Tool annotations and taskSupport
The MCP spec lets tools advertise annotations such as taskSupport: 'forbidden' that declare
whether a tool may be invoked through MCP task-augmentation wrappers. The AstraSync gateway
does not currently enforce these annotations — they're advisory metadata that well-behaved MCP
clients should honour. A misbehaving client that ignores the annotation and calls the tool
directly will still reach your handler.
If you need enforcement at the gateway, use one of the configured gates instead:
| Tester sets… | Result at the gateway |
|---|---|
taskSupport: 'forbidden' (on a tool annotation) | Advisory only. Request reaches your MCP handler regardless of how it was invoked. |
toolGates: { start_checkout: { purpose: 'shopping.purchase', action: 'execute' } } | Gateway verifies every start_checkout call against the agent's PDLSS for that purpose and action; a denial is a structured failure. |
| A dashboard tool rule for the tool | Same check, set on the endpoint page with no deploy; it can tighten a code gate but never loosen one. |
Future versions of the gateway may add taskSupport enforcement — track the package CHANGELOG.
Until then, set the right gate explicitly rather than relying on the annotation.
Where taskSupport: 'forbidden' comes from
The @modelcontextprotocol/sdk client library auto-sets taskSupport: 'forbidden' on every tool
registered via server.registerTool(...). Tools registered via server.registerToolTask(...)
get taskSupport: 'supported' instead. So you're probably not setting the annotation manually —
the MCP SDK is adding it for you based on which registration API you called. Both annotations are
advisory at the AstraSync gateway level; the behavioural difference is on the client side of the
MCP wire.
What a rejected request looks like (positive enforcement example)
A tools/call request with task-augmentation params targeting a tool annotated
taskSupport: 'forbidden' is rejected by the MCP SDK's client-side check before the request
leaves the agent — the gateway never sees it. From the partner's perspective:
// Agent code attempting task augmentation on a forbidden tool
const result = await client.callTool({
name: 'start_checkout', // declared with registerTool() → forbidden
arguments: { …, _astrasync_task: 'multi-step-checkout' }, // task-augmentation param
});
// → throws ToolTaskNotSupportedError at the MCP client layer.
// The HTTP request never hits AstraSync's gateway. From your server logs
// you'll see no verify-access call for this attempt — the client-side
// SDK refused to ship it.The gateway-side enforcement counterpart is toolGates['start_checkout'] = 'none' per the table
above — that one blocks regardless of how the client invoked the tool.
PDLSS mapping
MCP traffic maps into PDLSS's purpose / action / resource taxonomy like this. Doc-stable across
cohort-3 — your boundary policies and audit dashboards can rely on it.
| PDLSS dimension | MCP value |
|---|---|
purpose | Default mcp_invoke; overridden via the precedence chain below. |
action | Default <method>:<toolName> (e.g. tools/call:start_checkout) or <method> alone. Overridden via the precedence chain below. |
resource | mcp:tool/<name> for tools/call; mcp:method/<method> otherwise. Not overrideable — transport identity, not business semantic. |
Purpose + action precedence
Round-13 (R13-1, R13-2) introduced a 4-tier precedence chain for both purpose and action
— paired concepts share fallback chains exactly. Same rule applies to each independently:
| Tier | Source |
|---|---|
| 1 (highest) | X-Astra-<concept> HTTP header on the JSON-RPC POST. Caller's explicit override. |
| 2 | params._meta.astrasync.<concept> body field (canonical SDK location — what prepareMcpMeta() populates). |
| 3 | params.arguments.<concept> body field (legacy / conventional — for hand-written tool callers). |
| 4 (default) | Transport-layer default — purpose → mcp_invoke; action → <method>:<toolName> (or <method> alone). |
Worked example: caller sets X-Astra-Purpose: shopping.purchase header AND
params._meta.astrasync.action: "start_checkout" body field on a tools/call for
start_checkout tool. The resulting verify-access body carries purpose: "shopping.purchase"
(header wins), action: "start_checkout" (body wins because no header for action), and
resource: "mcp:tool/start_checkout" (transport identity, unchanged).
SDK debug log emits both purpose_source and action_source per call (with debug: true on the
middleware config) so partners can confirm which channel resolved each concept.
Identity reconciliation
The middleware reads the agent's ASTRA-id from two places, in order:
X-Astra-Idrequest header (the canonical SDK location).params._meta.astrasync.agentIdinside the JSON-RPC body — set bysetMcpMetain@astrasyncai/verification-gateway.params.arguments.agent_idontools/call(legacy / hand-written callers).
When both the header and the body supply an ASTRA-id and they disagree, the middleware applies
onAgentIdMismatch:
| Mode | Behaviour |
|---|---|
reject (default) | Returns a JSON-RPC AGENT_ID_MISMATCH error (code -32602) with both observed values. Safest — forces the client to reconcile before any tool runs. |
prefer-header | Use the header value, log the mismatch. For setups where bodies are still being migrated to the canonical _meta slot. |
prefer-body | Use the body value, log the mismatch. For reverse-proxy setups that strip auth headers before the request reaches the middleware. |
An ASTRA-id identifies; it never authenticates. Agent ids are public, so anyone can send one.
The middleware calls verify-access with your API key to decide; AstraSync itself refuses any
request that offers only an agent id (401 agent_identity_not_a_credential). To make sure the
real agent is making the call, require the runtime
challenge on the endpoint or on individual routes.
Agents arriving through a Workforce Guard checkpoint
Organisations on Workforce Guard can run their agents through a checkpoint that sits in front of
your MCP server (the agent connects to <checkpoint>/a/<slug>/u/<upstream>/mcp). Nothing changes
on your side: your middleware still sees the agent's ASTRA-id and still verifies it. Where you
require the runtime challenge, the checkpoint answers it for calls made through it. See
Workforce Guard checkpoint.
Dedupe with X-Astra-Verified-Hop
When a tool execution calls an inner REST endpoint that also runs the verification gateway, two verify-access calls happen for the same agent against the same counterparty within the same request. Audit gets noisy and you pay a doubled round-trip.
The MCP middleware tags the outbound response with:
X-Astra-Verified-Hop: <astraId>;<sessionId>;<checkedAt-ms>If your tool forwards this header to the inner REST hop, the inner middleware detects it via
parseVerifiedHop + isVerifiedHopValidFor and skips a duplicate verify-access call when:
- The marker's ASTRA-id matches the agent claimed on the inner hop, AND
- The marker is fresh (within 60s by default — override with
verifiedHopMaxAgeMs).
The header alone is not proof of identity; pair with X-Astra-Id on the inner hop. The
marker only gates the dedupe-skip decision.
Worked example — runnable code
Upstream MCP middleware verifies the agent → tool handler reads req.agentVerification and
forwards the marker on the outgoing inner-hop fetch → inner Express middleware reads it via
parseVerifiedHop + isVerifiedHopValidFor and skips a redundant verify-access call when fresh
AND matching the inner hop's claimed agent.
// === outer MCP server ===
import express from 'express';
import { createMcpMiddleware } from '@astrasyncai/verification-gateway/mcp';
const mcp = express();
mcp.use(express.json());
mcp.use(
createMcpMiddleware({
apiBaseUrl: 'https://astrasync.ai/api',
apiKey: process.env.ASTRASYNC_API_KEY,
counterpartyId: 'ASTRAE-mcp...',
toolGates: { start_checkout: { purpose: 'shopping', action: 'shopping.checkout' } },
})
);
// MCP middleware sets X-Astra-Verified-Hop on the response automatically
// AND populates req.agentVerification. Tool handlers can read both.
mcp.post('/mcp', async (req, res) => {
const v = req.agentVerification!;
// Forward the verified-hop marker on outgoing inner-hop calls.
const { serializeVerifiedHop, MCP_VERIFIED_HOP_HEADER } =
await import('@astrasyncai/verification-gateway/mcp');
const hop = serializeVerifiedHop({
astraId: v.agent!.astraId,
sessionId: v.sessionId,
checkedAt: Date.now(),
});
const inner = await fetch('http://internal/api/checkout/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Astra-Id': v.agent!.astraId, // identity marker
[MCP_VERIFIED_HOP_HEADER]: hop, // dedupe marker
},
body: JSON.stringify({ sku: 'sku_42' }),
});
res.status(inner.status).json(await inner.json());
});
// === inner REST hop (same fleet) ===
import { createMiddleware } from '@astrasyncai/verification-gateway/express';
const rest = express();
rest.use(
createMiddleware({
apiBaseUrl: 'https://astrasync.ai/api',
apiKey: process.env.ASTRASYNC_API_KEY,
counterpartyId: 'ASTRAE-rest...',
trustVerifiedHop: true, // accept the dedupe marker
verifiedHopMaxAgeMs: 60_000, // 60s default
})
);
// Inner middleware validates via parseVerifiedHop + isVerifiedHopValidFor
// and skips verify-access when the marker is fresh AND its astraId
// matches the X-Astra-Id on this request.Reference: low-level helpers
When you can't use the middleware (e.g. wrapping an MCP server framework that doesn't expose Express middleware mounting), the same primitives are available:
import {
parseMcpJsonRpc,
mcpToPdlss,
mcpRiskTier,
serializeVerifiedHop,
parseVerifiedHop,
isVerifiedHopValidFor,
MCP_VERIFIED_HOP_HEADER,
} from '@astrasyncai/verification-gateway/mcp';
const parsed = parseMcpJsonRpc(rpcBody);
if (parsed) {
const minTier = mcpRiskTier(parsed); // 'none' | 'read-only' | 'standard'
const { purpose, action, resource } = mcpToPdlss(parsed);
// Forward to your own verify-access call …
}
