MCP integration
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 /mcpURL. 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,
counterpartyType: 'mcp_server',
// Per-tool overrides — tools not listed inherit the default tier
// ('standard' for tools/call, 'none' for handshake/introspection).
toolGates: {
browse_catalog: 'read-only',
start_checkout: 'standard',
confirm_purchase: 'full',
},
onAgentIdMismatch: 'reject', // safest — see "Identity reconciliation" below
}),
yourMcpServerHandler,
);
Gating the tool enforces ACCESS, not spend — authorize the value before you settle
toolGatesverifies the agent's identity and access to start_checkout — it does notenforce 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.
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: 'none' } | Gateway blocks the tool. Every call to start_checkout is denied with a structured failure. |
| methodGates: { 'tools/call': 'internal' } | Gateway blocks alltool calls below the 'internal' access band. |
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-sidecheck 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. |
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 + isVerifiedHopValidForand 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: 'standard' },
})
);
// 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 …
}
