AID-H-034.003: Server-Side Tool Invocation Validation & Object-Level Authorization

Version 1.20261015 · Actionable sub-technique

Make the MCP server the final policy enforcement point for every tools/call request. The server must validate tool arguments against strict schemas, enforce per-tool, object-level, and tenant-level authorization inside the handler, protect URL-fetching tools from SSRF and unsafe redirects, prevent SQL/NoSQL injection in backend calls, sanitize model-visible errors, and sandbox or deny command, code, browser, and filesystem execution paths.

Pillars: app, infra, data

Lifecycle: building, operation

Scope boundary

Owns final server-handler schema validation and per-tool, object, tenant, and backend authorization before reads, calls, queries, fetches, filesystem access, commands, or code execution. It enforces the server's own trust boundary after request arrival.

AID-H-018.002: AID-H-034.003 authorizes the MCP request inside the handler against MCP-local tools, tenants, backends, and objects; AID-H-018.002 owns authorization at an external non-MCP application or service boundary. An MCP handler may consume an external service's decision without scoring the same external-object authorization twice, but it still enforces its own MCP request boundary.

AID-H-019.001: AID-H-034.003 authorizes whether a URL-fetching tool may perform the requested operation; AID-H-019.001 normalizes, resolves, pins, bounds, and safely fetches the destination. Authorization to fetch does not make the URL network-safe.

AID-I-001: AID-H-034.003 validates and authorizes command or code paths in the MCP handler; AID-I-001 contains the execution environment when such paths are permitted. Handler checks do not create process isolation.

AID-H-018.009: AID-H-034.003 validates the MCP tool request and consumes a filesystem-broker receipt before a handler uses a workspace, working directory, interpreter, script, module, executable, or other model-influenced path; AID-H-018.009 owns rooted object resolution, path confinement, race resistance, and exact-object evidence. The MCP handler must not replay path-traversal or symlink fixtures as a second confinement PASS.

Implementation guidance

AID-H-034.003-G001

Validate every tools/call argument with a strict server-side JSON Schema before invoking the handler.
Concept:

Tool schemas are not just client hints. They are server-side contracts. The MCP server must reject unknown fields, wrong types, missing required values, oversized values, invalid enums, and unsafe string formats before the handler runs.

Example strict schema validation with Ajv
// file: src/tools/schemaGate.js
    import Ajv2020 from "ajv/dist/2020.js";
    import addFormats from "ajv-formats";

    const ajv = new Ajv2020({
      allErrors: true,
      strict: true,
      removeAdditional: false,
      coerceTypes: false,
      useDefaults: false
    });
    addFormats(ajv);

    const schemas = {
      read_customer_account: {
        type: "object",
        additionalProperties: false,
        required: ["customer_id"],
        properties: {
          customer_id: {
            type: "string",
            pattern: "^cus_[A-Za-z0-9]{12,32}$"
          }
        }
      }
    };

    const validators = Object.fromEntries(
      Object.entries(schemas).map(([name, schema]) => [name, ajv.compile(schema)])
    );

    export function validateToolArguments(toolName, args) {
      const validate = validators[toolName];
      if (!validate) {
        throw new Error("Unknown tool: " + toolName);
      }
      if (!validate(args)) {
        const safeDetail = (validate.errors || []).map(error => ({
          field: error.instancePath || "/",
          constraint: error.keyword
        }));
        throw new Error("Invalid tool arguments: " + JSON.stringify(safeDetail));
      }
      return args;
    }
    

Operational notes: Use explicit maximum lengths, numeric ranges, enum values, URL schemes, and file path formats. Do not use schema descriptions as enforcement; descriptions are model-visible documentation, not policy. Return field and constraint names only in validation errors; never echo raw argument values into model-visible tool errors.

Verify safely

A separately credentialed verifier independently recompiles each schema and replays valid, missing, extra, wrong-type, duplicate-key, deep, oversized, pathological-regex, external-reference, schema-drift and validator-outage fixtures through the deployed dispatcher.

AID-H-034.003-G002

Bind each tool invocation to the authenticated principal, per-request client metadata, tenant, explicit application handles, and backend object authorization before executing side effects.
Concept:

The server should not assume the client only calls tools it is allowed to use. The handler must verify that the authenticated user or workload can access the specific backend object or tenant being requested.

Example object-level authorization check
// file: src/tools/customerAuthz.js
    export async function requireCustomerAccess({ auth, customerId, db }) {
      const tenantIds = Array.isArray(auth.tenantIds) ? auth.tenantIds : [];
      const scopes = new Set(Array.isArray(auth.scopes) ? auth.scopes : []);
      if (!auth.subject || tenantIds.length === 0) {
        throw new Error("Missing authenticated tenant context");
      }

      const row = await db.queryOne(
        "select tenant_id from customers where customer_id = ?",
        [customerId]
      );

      if (!row) {
        throw new Error("Customer not found");
      }

      const allowed = tenantIds.includes(row.tenant_id) &&
        scopes.has("customers:read");

      if (!allowed) {
        throw new Error("Not authorized for customer");
      }

      return row;
    }

    export async function readCustomerAccountHandler({ auth, args, db }) {
      await requireCustomerAccess({ auth, customerId: args.customer_id, db });
      return db.queryOne(
        "select customer_id, status, plan from customers where customer_id = ?",
        [args.customer_id]
      );
    }
    

Operational notes: Treat the MCP server as the final policy enforcement point. AID-H-018 can prevent the agent from selecting a capability, but this server-side gate prevents direct API calls, compromised clients, stale client policy, or cross-tenant confusion from reaching backend data.

Verify safely

A separately credentialed verifier replays IDOR, cross-tenant, subject/client swap, forged/expired/revoked handle, object-version race, stale grant, confused-deputy and retry fixtures against real authorization and backend boundaries.

AID-H-034.003-G003

Use parameterized SQL or NoSQL APIs and server-side identifier allowlists for every database-backed MCP tool.
Concept:

JSON Schema validates shape; it does not make a database query safe. Tool arguments can be influenced by indirect prompt injection, malicious clients, or compromised sessions. Treat every argument as untrusted data and never concatenate it into SQL, MongoDB filters, sort clauses, collection names, table names, or projection expressions.

Example SQL handler
// file: src/tools/customerSearchSql.js
    const allowedSortColumns = {
      created_at: "created_at",
      customer_id: "customer_id"
    };

    export async function searchCustomers({ auth, args, db }) {
      if (!auth.scopes.includes("customers:read")) {
        throw new Error("Missing customers:read scope");
      }

      const sortColumn = allowedSortColumns[args.sort_by || "created_at"];
      if (!sortColumn) {
        throw new Error("Unsupported sort column");
      }

      return db.query(
        "select customer_id, status, plan from customers where tenant_id = ? and email like ? order by " +
          sortColumn +
          " limit ?",
        [auth.activeTenantId, "%" + args.email_fragment + "%", Math.min(args.limit || 25, 100)]
      );
    }
    
Example MongoDB filter construction
// file: src/tools/customerSearchMongo.js
    const allowedFields = new Set(["customer_id", "email", "status"]);

    export async function findCustomerByField({ auth, args, collection }) {
      if (!allowedFields.has(args.field)) {
        throw new Error("Unsupported field");
      }

      return collection.findOne(
        {
          tenant_id: auth.activeTenantId,
          [args.field]: String(args.value)
        },
        {
          projection: { _id: 0, customer_id: 1, status: 1, plan: 1 }
        }
      );
    }
    

Operational notes: Give each MCP server or tool a least-privilege database identity, preferably read-only for read tools. Dynamic identifiers require server-owned enum allowlists; prepared statements only protect values, not table names, column names, collection names, or sort expressions.

Verify safely

A separately credentialed verifier captures datastore-side statement templates and bindings while testing quote/comment/encoding, identifier, operator, stacked-query, wildcard, cross-tenant, timeout and over-result injection fixtures.

AID-H-034.003-G004

For tools that fetch URLs or resolve network targets, enforce SSRF-safe URL validation, redirect validation, DNS rebinding checks, and egress allowlists.
Concept:

URL-fetching MCP tools are high-risk because attackers can use them as server-side probes into internal networks, cloud metadata services, localhost admin panels, or private DNS names. This guidance references AID-I-002 for network segmentation and AID-H-019.001 for safe-fetch patterns.

Example URL validation in Python
# File: tools/safe_fetch_gate.py
from __future__ import annotations

import ipaddress
import math
import socket
from urllib.parse import urlparse

import urllib3

BLOCKED_HOSTS = {"localhost", "metadata.google.internal"}
ALLOWED_SCHEMES = {"https"}
ALLOWED_DOMAINS = {"api.github.com", "docs.example.com"}
METADATA_IPS = {
    ipaddress.ip_network("169.254.169.254/32"),
    ipaddress.ip_network("169.254.169.253/32"),
    ipaddress.ip_network("100.100.100.200/32"),
}


def normalize_ip(ip):
    if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
        return ip.ipv4_mapped
    return ip


def is_blocked_ip(ip) -> bool:
    normalized = normalize_ip(ip)
    if not normalized.is_global:
        return True
    return any(normalized in network for network in METADATA_IPS)


def domain_allowed(hostname: str) -> bool:
    return any(hostname == domain or hostname.endswith("." + domain) for domain in ALLOWED_DOMAINS)


def _all_resolved_ips(hostname: str):
    infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
    ips = []
    for info in infos:
        sockaddr = info[4]
        candidate = normalize_ip(ipaddress.ip_address(sockaddr[0]))
        if is_blocked_ip(candidate):
            raise ValueError(f"Blocked non-public or metadata address: {candidate}")
        ips.append(str(candidate))
    return ips


def parse_and_pin_url(url: str):
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError("Only https URLs are allowed")
    if not parsed.hostname:
        raise ValueError("URL hostname is required")
    if parsed.username is not None or parsed.password is not None or parsed.fragment:
        raise ValueError("Userinfo and fragments are not allowed in fetch URLs")
    if parsed.port not in {None, 443}:
        raise ValueError("Only TCP port 443 is allowed")

    hostname = parsed.hostname.encode("idna").decode("ascii").lower().rstrip(".")
    if hostname in BLOCKED_HOSTS:
        raise ValueError("Localhost is blocked")
    if not domain_allowed(hostname):
        raise ValueError("Domain is not on the fetch allowlist")

    try:
        literal = normalize_ip(ipaddress.ip_address(hostname))
    except ValueError:
        literal = None
    if literal is not None:
        raise ValueError("Use allowlisted hostnames, not literal IP addresses")

    ips = _all_resolved_ips(hostname)
    if not ips:
        raise ValueError("No DNS answers")
    return parsed._replace(netloc=hostname), ips[0]


def fetch_resolved_once(url: str, timeout: float, maximum_response_bytes: int) -> bytes:
    if not math.isfinite(timeout) or timeout <= 0:
        raise ValueError("signed fetch timeout must be finite and positive")
    if isinstance(maximum_response_bytes, bool) or not isinstance(maximum_response_bytes, int) or maximum_response_bytes <= 0:
        raise ValueError("signed response cap must be a positive integer")
    parsed, ip_address = parse_and_pin_url(url)
    target = parsed.path or "/"
    if parsed.query:
        target += "?" + parsed.query

    pool = urllib3.HTTPSConnectionPool(
        ip_address,
        port=443,
        server_hostname=parsed.hostname,
        assert_hostname=parsed.hostname,
        cert_reqs="CERT_REQUIRED",
        timeout=timeout,
        retries=False,
    )
    response = pool.request(
        "GET",
        target,
        headers={"Host": parsed.hostname, "User-Agent": "AIDEFEND-MCP-Fetcher/1.0"},
        redirect=False, preload_content=False, decode_content=True,
    )
    try:
        if 300 <= response.status < 400:
            raise ValueError("Redirects are disabled; validate each hop explicitly")
        if response.status >= 400:
            raise RuntimeError(f"Upstream fetch failed: {response.status}")
        declared = response.headers.get("Content-Length")
        if declared is not None and (not declared.isdigit() or int(declared) > maximum_response_bytes):
            raise ValueError("Upstream response exceeds signed byte cap")
        body = bytearray()
        while True:
            chunk = response.read(
                amt=min(65536, maximum_response_bytes + 1 - len(body)),
                decode_content=True,
            )
            if not chunk:
                break
            body.extend(chunk)
            if len(body) > maximum_response_bytes:
                raise ValueError("Decoded upstream response exceeds signed byte cap")
        return bytes(body)
    finally:
        response.close()
        pool.close()

Pass both timeout and maximum_response_bytes from the signed, versioned fetch policy for the specific tool and destination class.

Operational requirements
Verify safely

A separately credentialed verifier tests mixed encodings, IPv4/IPv6 variants, localhost/private/link-local/metadata, allowlist suffix tricks, DNS rebinding between validation and connect, redirect chains, proxy bypass, wrong TLS identity, oversized/slow responses, and resolver/egress outages.

AID-H-034.003-G005

For command, code, browser, and filesystem tools, deny shell execution by default, consume AID-H-018.009 filesystem-broker receipts instead of caller paths, and route permitted handlers through sandboxed execution profiles.
Keep caller-controlled paths out of the MCP handler

A command-capable MCP tool may accept an allowlisted command identifier and an opaque workspace handle, but it must never accept or pass a caller-supplied cwd, executable path, script path, or module path to a local process launcher. Send the opaque handle and authorization receipt to the filesystem broker owned by AID-H-018.009. That broker resolves the workspace from authenticated root policy, binds the operation to the exact filesystem objects, and returns a signed confinement receipt. This handler owns MCP request validation and receipt consumption; it does not earn a second filesystem-confinement outcome.

Use a fixed mTLS broker and verify its signed receipt
// File: src/tools/commandRunner.js
import { createHash, createPublicKey, randomUUID, verify } from "node:crypto";
import { readFileSync } from "node:fs";
import { request } from "node:https";

const COMMANDS = new Set(["git_status", "npm_audit_json"]);
const WORKSPACE_HANDLE = /^wsp_[A-Za-z0-9_-]{20,128}$/;
const OPAQUE_RECEIPT = /^[A-Za-z0-9._~-]{43,8192}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const ED25519_SIGNATURE_B64 = /^[A-Za-z0-9+/]{86}==$/;
const BROKER = new URL(process.env.WORKSPACE_BROKER_URL);
if (
  BROKER.protocol !== "https:" || BROKER.username || BROKER.password ||
  BROKER.search || BROKER.hash || BROKER.pathname !== "/v1/workspace/execute"
) {
  throw new Error("WORKSPACE_BROKER_URL must be the fixed canonical HTTPS broker endpoint");
}

function positiveNumber(name) {
  const value = Number(process.env[name]);
  if (!Number.isFinite(value) || value <= 0) throw new Error(name + " must be positive");
  return value;
}

const TIMEOUT_MS = positiveNumber("WORKSPACE_BROKER_TIMEOUT_MS");
const MAX_RESPONSE_BYTES = positiveNumber("WORKSPACE_BROKER_MAX_RESPONSE_BYTES");
const MAX_STDOUT_BYTES = positiveNumber("WORKSPACE_BROKER_MAX_STDOUT_BYTES");
const MAX_RECEIPT_AGE_SECONDS = positiveNumber("WORKSPACE_BROKER_RECEIPT_MAX_AGE_SECONDS");
const MAX_FUTURE_SKEW_SECONDS = positiveNumber("WORKSPACE_BROKER_MAX_FUTURE_SKEW_SECONDS");
const TLS = {
  ca: readFileSync("/etc/aidefend/workspace-broker/ca.pem"),
  cert: readFileSync("/etc/aidefend/workspace-broker/client-cert.pem"),
  key: readFileSync("/etc/aidefend/workspace-broker/client-key.pem"),
  servername: BROKER.hostname,
  rejectUnauthorized: true,
};
const RECEIPT_KEY = createPublicKey(
  readFileSync("/etc/aidefend/workspace-broker/receipt-ed25519.pub.pem"),
);

function canonicalReceipt(receipt) {
  const ordered = Object.fromEntries(
    Object.entries(receipt).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0),
  );
  return Buffer.from(JSON.stringify(ordered), "utf8");
}

function postBroker(body) {
  const encoded = Buffer.from(JSON.stringify(body), "utf8");
  if (encoded.length > 16384) throw new Error("broker request exceeds hard byte cap");
  return new Promise((resolve, reject) => {
    const req = request({
      protocol: BROKER.protocol,
      hostname: BROKER.hostname,
      port: BROKER.port || 443,
      path: BROKER.pathname,
      method: "POST",
      ...TLS,
      headers: {
        "content-type": "application/json",
        "accept": "application/json",
        "content-length": encoded.length,
      },
    }, response => {
      response.on("error", reject);
      const contentLength = response.headers["content-length"];
      if (contentLength !== undefined) {
        const declared = Number(contentLength);
        if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_RESPONSE_BYTES) {
          response.destroy(new Error("broker response has an invalid declared length"));
          return;
        }
      }
      const chunks = [];
      let size = 0;
      response.on("data", chunk => {
        size += chunk.length;
        if (size > MAX_RESPONSE_BYTES) {
          response.destroy(new Error("broker response exceeds streamed byte cap"));
          return;
        }
        chunks.push(chunk);
      });
      response.on("end", () => {
        if (response.statusCode !== 200) {
          reject(new Error("workspace broker returned HTTP " + response.statusCode));
          return;
        }
        try {
          resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
        } catch (error) {
          reject(new Error("workspace broker returned invalid JSON", { cause: error }));
        }
      });
    });
    req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error("workspace broker timed out")));
    req.on("error", reject);
    req.end(encoded);
  });
}

export async function runAllowedCommand({ commandId, workspaceHandle, authorizationReceipt }) {
  if (!COMMANDS.has(commandId)) throw new Error("command is not allowlisted");
  if (!WORKSPACE_HANDLE.test(workspaceHandle)) throw new Error("workspace handle is invalid");
  if (!OPAQUE_RECEIPT.test(authorizationReceipt)) throw new Error("authorization receipt is invalid");

  const requestId = randomUUID();
  const response = await postBroker({
    schema_version: "aidefend.workspace-broker-request.v1",
    request_id: requestId,
    command_id: commandId,
    workspace_handle: workspaceHandle,
    authorization_receipt: authorizationReceipt,
  });
  const expectedResponseKeys = ["receipt", "receipt_signature_b64", "schema_version", "stdout_utf8"];
  if (
    !response || typeof response !== "object" || Array.isArray(response) ||
    JSON.stringify(Object.keys(response).sort()) !== JSON.stringify(expectedResponseKeys)
  ) throw new Error("workspace broker response schema differs");
  if (response.schema_version !== "aidefend.workspace-broker-response.v1") {
    throw new Error("workspace broker response version differs");
  }

  const receipt = response.receipt;
  const expectedReceiptKeys = [
    "authorization_receipt_sha256", "command_id", "control", "exit_code", "expires_at",
    "issued_at", "object_identity_sha256", "outcome", "policy_sha256",
    "request_id", "stderr_sha256", "stdout_sha256", "workspace_handle",
  ];
  if (
    !receipt || typeof receipt !== "object" || Array.isArray(receipt) ||
    JSON.stringify(Object.keys(receipt).sort()) !== JSON.stringify(expectedReceiptKeys)
  ) throw new Error("filesystem broker receipt schema differs");
  if (typeof response.receipt_signature_b64 !== "string" || !ED25519_SIGNATURE_B64.test(response.receipt_signature_b64)) {
    throw new Error("filesystem broker receipt signature encoding is invalid");
  }
  const signature = Buffer.from(response.receipt_signature_b64, "base64");
  if (!verify(null, canonicalReceipt(receipt), RECEIPT_KEY, signature)) {
    throw new Error("filesystem broker receipt signature is invalid");
  }
  const now = Date.now();
  const issuedAt = Date.parse(receipt.issued_at);
  const expiresAt = Date.parse(receipt.expires_at);
  if (
    !Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) ||
    issuedAt > now + MAX_FUTURE_SKEW_SECONDS * 1000 ||
    now - issuedAt > MAX_RECEIPT_AGE_SECONDS * 1000 || expiresAt < issuedAt || expiresAt < now
  ) throw new Error("filesystem broker receipt is stale or future-dated");
  const authorizationDigest = createHash("sha256").update(authorizationReceipt, "utf8").digest("hex");
  const stdout = Buffer.from(response.stdout_utf8, "utf8");
  if (
    receipt.control !== "AID-H-018.009" || receipt.outcome !== "PASS" || receipt.exit_code !== 0 ||
    receipt.request_id !== requestId || receipt.command_id !== commandId ||
    receipt.workspace_handle !== workspaceHandle || stdout.length > MAX_STDOUT_BYTES ||
    receipt.authorization_receipt_sha256 !== authorizationDigest ||
    !SHA256.test(receipt.policy_sha256) || !SHA256.test(receipt.object_identity_sha256) ||
    !SHA256.test(receipt.stdout_sha256) || !SHA256.test(receipt.stderr_sha256) ||
    createHash("sha256").update(stdout).digest("hex") !== receipt.stdout_sha256
  ) throw new Error("filesystem broker receipt binding differs");

  return {
    stdout: response.stdout_utf8,
    confinement_receipt_sha256: createHash("sha256").update(canonicalReceipt(receipt)).digest("hex"),
  };
}
Deployment and verification

Provision the broker URL, mTLS material, Ed25519 receipt key, and numeric limits from a signed deployment profile; keep every key path root-owned and non-writable by the MCP service identity. The broker, not this handler, maps the opaque handle to an approved root, resolves the command's executable and working directory without following attacker-controlled links, executes inside the required AID-I-001 sandbox profile, and signs the exact-object receipt.

Exercise unknown command, forged or stale authorization receipt, cross-workspace handle replay, wrong request ID, altered stdout, bad receipt signature, expired receipt, oversized response, broker timeout, direct-handler bypass, traversal, symlink, reparse-point, mount, executable-substitution, and replacement-race fixtures. The filesystem fixtures are scored by AID-H-018.009; this guidance verifies only that every applicable MCP handler rejects bypass and consumes a valid receipt before returning data.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-034.003: Server-Side Tool Invocation Validation & Object-Level Authorization | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version