Verification Gateway

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.

MethodDefault tierReason
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 dimensionMCP value
purposeDefault mcp_invoke; overridden via the precedence chain below.
actionDefault <method>:<toolName> (e.g. tools/call:start_checkout) or <method> alone. Overridden via the precedence chain below.
resourcemcp: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:

TierSource
1 (highest)X-Astra-<concept> HTTP header on the JSON-RPC POST. Caller's explicit override.
2params._meta.astrasync.<concept> body field (canonical SDK location — what prepareMcpMeta() populates).
3params.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:

  1. X-Astra-Id request header (the canonical SDK location).
  2. params._meta.astrasync.agentId inside the JSON-RPC body — set by setMcpMeta in @astrasyncai/verification-gateway.
  3. params.arguments.agent_id on tools/call (legacy / hand-written callers).

When both the header and the body supply an ASTRA-id and they disagree, the middleware applies onAgentIdMismatch:

ModeBehaviour
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-headerUse the header value, log the mismatch. For setups where bodies are still being migrated to the canonical _meta slot.
prefer-bodyUse 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 …
}