Loading...
Loading...
Secure Model Context Protocol (MCP) servers with transport encryption, tool authorization, input validation, and audit logging for safe AI agent integrations.
npx skill4agent add bagelhole/devops-security-agent-skills mcp-server-security| Threat | Vector | Impact |
|---|---|---|
| Unauthorized tool access | Agent calls tools the user should not have access to | Privilege escalation, data breach |
| Prompt injection via resources | Malicious content in MCP resources influences agent behavior | Arbitrary tool execution |
| Data exfiltration via results | Tool results leak sensitive data back to an untrusted agent | Data loss, compliance violation |
| SSRF via MCP tools | Agent tricks a tool into making internal network requests | Internal service compromise |
| Credential theft | API keys or tokens stored insecurely on the MCP server | Full account takeover |
| Denial of service | Agent floods server with tool calls or huge payloads | Service unavailability |
| Man-in-the-middle | Unencrypted transport between agent and MCP server | Eavesdropping, request tampering |
| Supply chain compromise | Malicious MCP server package or plugin | Arbitrary code execution |
# /etc/nginx/sites-enabled/mcp-server.conf
server {
listen 443 ssl http2;
server_name mcp.internal.example.com;
ssl_certificate /etc/ssl/certs/mcp-server.crt;
ssl_certificate_key /etc/ssl/private/mcp-server.key;
ssl_protocols TLSv1.3;
ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
ssl_prefer_server_ciphers on;
ssl_session_timeout 1d;
ssl_session_tickets off;
# HSTS header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE-specific: disable buffering so events stream immediately
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
}# Add to the server block above
ssl_client_certificate /etc/ssl/certs/agent-ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;# Create CA (one-time)
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-days 365 -noenc -keyout ca-key.pem -out ca-cert.pem \
-subj "/CN=MCP Agent CA"
# Create agent client cert signed by the CA
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
-noenc -keyout agent-key.pem -out agent-csr.pem \
-subj "/CN=agent-orchestrator-01"
openssl x509 -req -in agent-csr.pem -CA ca-cert.pem -CAkey ca-key.pem \
-CAcreateserial -out agent-cert.pem -days 90// claude_desktop_config.json - restrict stdio server permissions
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/safe-dir"],
"env": {
"NODE_OPTIONS": "--experimental-permission --allow-fs-read=/home/user/safe-dir --allow-fs-write=/home/user/safe-dir"
}
}
}
}// src/auth.ts - OAuth 2.1 token validation middleware for an MCP server
import { createServer } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import jwt from "jsonwebtoken";
const app = express();
// OAuth 2.1 token validation middleware
function validateBearerToken(req: express.Request, res: express.Response, next: express.NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "missing_token" });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.OAUTH_PUBLIC_KEY!, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "mcp-server",
});
// Attach scopes for downstream authorization
(req as any).tokenScopes = (payload as any).scope?.split(" ") || [];
(req as any).userId = (payload as any).sub;
next();
} catch {
return res.status(403).json({ error: "invalid_token" });
}
}
app.use("/mcp", validateBearerToken);// src/apikeys.ts
import { createHash, timingSafeEqual } from "crypto";
interface ApiKeyRecord {
hashedKey: string;
userId: string;
allowedTools: string[];
rateLimit: number; // requests per minute
expiresAt: Date;
}
// Store hashed keys, never plaintext
const apiKeyStore: Map<string, ApiKeyRecord> = new Map();
export function registerApiKey(plainKey: string, record: Omit<ApiKeyRecord, "hashedKey">) {
const hashed = createHash("sha256").update(plainKey).digest("hex");
apiKeyStore.set(hashed, { ...record, hashedKey: hashed });
}
export function validateApiKey(plainKey: string): ApiKeyRecord | null {
const hashed = createHash("sha256").update(plainKey).digest("hex");
const record = apiKeyStore.get(hashed);
if (!record) return null;
if (new Date() > record.expiresAt) {
apiKeyStore.delete(hashed);
return null;
}
return record;
}# config/tool-policy.yaml
policies:
- role: developer
allowed_tools:
- "read_file"
- "search_code"
- "run_tests"
denied_tools:
- "execute_command"
- "write_file"
- "delete_file"
- role: admin
allowed_tools: ["*"]
denied_tools: []
- role: readonly-agent
allowed_tools:
- "read_file"
- "list_directory"
- "query_database:SELECT"
denied_tools: ["*"]
dangerous_tools:
- name: "execute_command"
risk: critical
requires_approval: true
max_executions_per_session: 5
- name: "write_file"
risk: high
requires_approval: true
- name: "query_database"
risk: medium
allowed_operations: ["SELECT"]// src/toolAuth.ts
import { readFileSync } from "fs";
import { parse } from "yaml";
interface ToolPolicy {
role: string;
allowed_tools: string[];
denied_tools: string[];
}
const config = parse(readFileSync("config/tool-policy.yaml", "utf-8"));
const policies: ToolPolicy[] = config.policies;
export function isToolAllowed(userRole: string, toolName: string): boolean {
const policy = policies.find((p) => p.role === userRole);
if (!policy) return false;
// Explicit deny takes precedence
if (policy.denied_tools.includes(toolName)) return false;
if (policy.denied_tools.includes("*") && !policy.allowed_tools.includes(toolName)) return false;
// Check allow
if (policy.allowed_tools.includes("*")) return true;
if (policy.allowed_tools.includes(toolName)) return true;
return false;
}
// MCP server integration: wrap the tool handler
export function authorizedToolHandler(server: any) {
const originalCallTool = server.callTool.bind(server);
server.callTool = async (request: any, context: any) => {
const userRole = context.session?.userRole || "readonly-agent";
const toolName = request.params.name;
if (!isToolAllowed(userRole, toolName)) {
return {
content: [{ type: "text", text: `Access denied: tool "${toolName}" is not permitted for role "${userRole}".` }],
isError: true,
};
}
return originalCallTool(request, context);
};
}// src/validation.ts
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true, removeAdditional: true });
addFormats(ajv);
// Schema registry keyed by tool name
const toolSchemas: Record<string, object> = {
read_file: {
type: "object",
properties: {
path: {
type: "string",
pattern: "^[a-zA-Z0-9_/\\-.]+$", // No path traversal chars
maxLength: 256,
},
},
required: ["path"],
additionalProperties: false,
},
query_database: {
type: "object",
properties: {
query: { type: "string", maxLength: 2048 },
database: { type: "string", enum: ["analytics", "public_catalog"] },
parameters: {
type: "array",
items: { type: ["string", "number", "boolean"] },
maxItems: 20,
},
},
required: ["query", "database"],
additionalProperties: false,
},
};
export function validateToolInput(toolName: string, params: unknown): { valid: boolean; errors?: string } {
const schema = toolSchemas[toolName];
if (!schema) return { valid: false, errors: `No schema registered for tool: ${toolName}` };
const validate = ajv.compile(schema);
if (validate(params)) return { valid: true };
const errorMsg = validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join("; ");
return { valid: false, errors: errorMsg };
}// src/safeSql.ts
const FORBIDDEN_PATTERNS = [
/;\s*(DROP|ALTER|TRUNCATE|DELETE|UPDATE|INSERT|CREATE|GRANT|REVOKE)/i,
/UNION\s+SELECT/i,
/INTO\s+OUTFILE/i,
/LOAD_FILE\s*\(/i,
/xp_cmdshell/i,
];
export function sanitizeSqlQuery(query: string): { safe: boolean; reason?: string } {
for (const pattern of FORBIDDEN_PATTERNS) {
if (pattern.test(query)) {
return { safe: false, reason: `Query matches forbidden pattern: ${pattern}` };
}
}
// Only allow SELECT statements
const trimmed = query.trim().toUpperCase();
if (!trimmed.startsWith("SELECT")) {
return { safe: false, reason: "Only SELECT queries are permitted" };
}
return { safe: true };
}
// Usage in a database tool handler
export async function handleDatabaseQuery(params: { query: string; parameters?: any[] }, db: any) {
const check = sanitizeSqlQuery(params.query);
if (!check.safe) {
throw new Error(`Query rejected: ${check.reason}`);
}
// Always use parameterized execution
return db.query(params.query, params.parameters || []);
}// src/safePath.ts
import path from "path";
const SANDBOX_ROOT = "/home/mcpuser/workspace";
export function resolveSafePath(userPath: string): string {
// Resolve to absolute, then verify it is inside the sandbox
const resolved = path.resolve(SANDBOX_ROOT, userPath);
if (!resolved.startsWith(SANDBOX_ROOT + path.sep) && resolved !== SANDBOX_ROOT) {
throw new Error(`Path traversal blocked: "${userPath}" resolves outside sandbox.`);
}
// Block symlink escape
const real = require("fs").realpathSync.native(resolved);
if (!real.startsWith(SANDBOX_ROOT)) {
throw new Error(`Symlink escape blocked: "${userPath}" -> "${real}"`);
}
return resolved;
}#!/usr/bin/env bash
# run-mcp-sandboxed.sh - Launch MCP server with Linux namespace sandboxing
exec unshare --map-root-user --mount --pid --fork -- bash -c '
# Create a read-only bind mount for the workspace
mount --bind /home/mcpuser/workspace /home/mcpuser/workspace
mount -o remount,ro,bind /home/mcpuser/workspace
# Make the writable output directory available
mount --bind /home/mcpuser/output /home/mcpuser/output
# Block access to sensitive host paths
mount -t tmpfs tmpfs /etc/ssh
mount -t tmpfs tmpfs /root
mount -t tmpfs tmpfs /home/mcpuser/.ssh
# Run the MCP server
exec node /opt/mcp-server/dist/index.js
'-- PostgreSQL: MCP server database user
CREATE ROLE mcp_readonly WITH LOGIN PASSWORD 'use-a-vault-generated-secret';
-- Grant read-only access to specific schemas only
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly;
-- Restrict row-level access where needed
ALTER TABLE customer_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY mcp_access ON customer_data
FOR SELECT TO mcp_readonly
USING (sensitivity_level < 3);
-- Set resource limits to prevent expensive queries
ALTER ROLE mcp_readonly SET statement_timeout = '10s';
ALTER ROLE mcp_readonly SET work_mem = '64MB';// src/networkPolicy.ts
import { URL } from "url";
import net from "net";
const BLOCKED_CIDRS = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"169.254.169.254/32", // Cloud metadata endpoint
"fd00::/8",
];
const ALLOWED_DOMAINS = [
"api.github.com",
"registry.npmjs.org",
];
export function validateOutboundUrl(urlString: string): { allowed: boolean; reason?: string } {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return { allowed: false, reason: "Invalid URL" };
}
// Block non-HTTPS
if (parsed.protocol !== "https:") {
return { allowed: false, reason: "Only HTTPS is allowed" };
}
// Domain allowlist
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
return { allowed: false, reason: `Domain ${parsed.hostname} is not in the allowlist` };
}
return { allowed: true };
}// src/rateLimit.ts
interface RateBucket {
tokens: number;
lastRefill: number;
}
const buckets = new Map<string, RateBucket>();
const DEFAULT_RATE = 60; // requests per minute
const DEFAULT_BURST = 10; // max burst
export function checkRateLimit(
clientId: string,
maxPerMinute: number = DEFAULT_RATE,
burst: number = DEFAULT_BURST
): { allowed: boolean; retryAfterMs?: number } {
const now = Date.now();
let bucket = buckets.get(clientId);
if (!bucket) {
bucket = { tokens: burst, lastRefill: now };
buckets.set(clientId, bucket);
}
// Refill tokens based on elapsed time
const elapsed = now - bucket.lastRefill;
const refill = (elapsed / 60000) * maxPerMinute;
bucket.tokens = Math.min(burst, bucket.tokens + refill);
bucket.lastRefill = now;
if (bucket.tokens < 1) {
const waitMs = ((1 - bucket.tokens) / maxPerMinute) * 60000;
return { allowed: false, retryAfterMs: Math.ceil(waitMs) };
}
bucket.tokens -= 1;
return { allowed: true };
}// src/tokenBudget.ts
interface SessionBudget {
usedInputTokens: number;
usedOutputTokens: number;
maxInputTokens: number;
maxOutputTokens: number;
}
const sessionBudgets = new Map<string, SessionBudget>();
export function initSessionBudget(sessionId: string, maxInput = 500_000, maxOutput = 100_000) {
sessionBudgets.set(sessionId, {
usedInputTokens: 0,
usedOutputTokens: 0,
maxInputTokens: maxInput,
maxOutputTokens: maxOutput,
});
}
export function consumeTokens(
sessionId: string,
inputTokens: number,
outputTokens: number
): { allowed: boolean; remaining: { input: number; output: number } } {
const budget = sessionBudgets.get(sessionId);
if (!budget) return { allowed: false, remaining: { input: 0, output: 0 } };
budget.usedInputTokens += inputTokens;
budget.usedOutputTokens += outputTokens;
const remaining = {
input: budget.maxInputTokens - budget.usedInputTokens,
output: budget.maxOutputTokens - budget.usedOutputTokens,
};
if (remaining.input < 0 || remaining.output < 0) {
return { allowed: false, remaining };
}
return { allowed: true, remaining };
}// src/auditLog.ts
import { randomUUID } from "crypto";
interface AuditEntry {
id: string;
timestamp: string;
userId: string;
sessionId: string;
toolName: string;
parameters: Record<string, unknown>;
result: "success" | "error" | "denied";
durationMs: number;
error?: string;
}
const REDACT_KEYS = ["password", "token", "secret", "api_key", "authorization"];
function redactSensitive(params: Record<string, unknown>): Record<string, unknown> {
const redacted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (REDACT_KEYS.some((k) => key.toLowerCase().includes(k))) {
redacted[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
redacted[key] = redactSensitive(value as Record<string, unknown>);
} else {
redacted[key] = value;
}
}
return redacted;
}
export function logToolInvocation(entry: Omit<AuditEntry, "id" | "timestamp">): AuditEntry {
const full: AuditEntry = {
...entry,
id: randomUUID(),
timestamp: new Date().toISOString(),
parameters: redactSensitive(entry.parameters),
};
// Write structured JSON to stdout for collection by log aggregator
process.stdout.write(JSON.stringify(full) + "\n");
return full;
}// src/otelTracing.ts
import { trace, SpanStatusCode, context, propagation } from "@opentelemetry/api";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { Resource } from "@opentelemetry/resources";
const provider = new NodeTracerProvider({
resource: new Resource({
"service.name": "mcp-server",
"service.version": "1.0.0",
}),
});
provider.addSpanProcessor(
new BatchSpanProcessor(
new OTLPTraceExporter({ url: "http://otel-collector:4318/v1/traces" })
)
);
provider.register();
const tracer = trace.getTracer("mcp-server");
export async function traceToolCall<T>(
toolName: string,
userId: string,
params: Record<string, unknown>,
fn: () => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(`mcp.tool.${toolName}`, async (span) => {
span.setAttribute("mcp.tool.name", toolName);
span.setAttribute("mcp.user.id", userId);
span.setAttribute("mcp.params.keys", Object.keys(params).join(","));
try {
const result = await fn();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err: any) {
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
span.recordException(err);
throw err;
} finally {
span.end();
}
});
}# Dockerfile.mcp-server
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY src/ src/
COPY tsconfig.json ./
RUN npm run build
FROM node:22-slim
RUN groupadd -r mcp && useradd -r -g mcp -d /home/mcp -s /usr/sbin/nologin mcp
# Remove unnecessary packages
RUN apt-get purge -y --auto-remove curl wget && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
COPY /app/package.json ./
# Create minimal writable directories
RUN mkdir -p /home/mcp/workspace /home/mcp/output && \
chown -R mcp:mcp /home/mcp
USER mcp
# Health check
HEALTHCHECK \
CMD node -e "fetch('http://localhost:3001/health').then(r => process.exit(r.ok ? 0 : 1))"
EXPOSE 3001
CMD ["node", "dist/index.js"]# k8s/network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mcp-server-policy
namespace: ai-platform
spec:
podSelector:
matchLabels:
app: mcp-server
policyTypes:
- Ingress
- Egress
ingress:
# Only allow traffic from the agent orchestrator
- from:
- podSelector:
matchLabels:
app: agent-orchestrator
ports:
- protocol: TCP
port: 3001
egress:
# Allow DNS
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
# Allow access to the internal database
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow HTTPS outbound to specific external APIs
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"read", "write", "close", "fstat", "lseek", "mmap", "mprotect",
"munmap", "brk", "rt_sigaction", "rt_sigprocmask", "ioctl",
"access", "pipe", "select", "sched_yield", "mremap", "madvise",
"dup", "dup2", "nanosleep", "getpid", "socket", "connect",
"accept", "sendto", "recvfrom", "bind", "listen", "getsockname",
"getpeername", "setsockopt", "getsockopt", "clone", "execve",
"exit", "wait4", "fcntl", "getdents64", "getcwd", "chdir",
"openat", "newfstatat", "readlinkat", "exit_group", "epoll_create1",
"epoll_ctl", "epoll_wait", "eventfd2", "futex", "set_robust_list",
"clock_gettime", "getrandom", "statx", "pread64", "pwrite64"
],
"action": "SCMP_ACT_ALLOW"
}
]
}# k8s/deployment.yaml (relevant snippet)
spec:
containers:
- name: mcp-server
image: registry.example.com/mcp-server:1.0
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: Localhost
localhostProfile: profiles/mcp-server-seccomp.json
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "250m"// tests/toolAuth.test.ts
import { describe, it, expect } from "vitest";
import { isToolAllowed } from "../src/toolAuth";
describe("Tool Authorization", () => {
it("should deny developer access to execute_command", () => {
expect(isToolAllowed("developer", "execute_command")).toBe(false);
});
it("should allow developer access to read_file", () => {
expect(isToolAllowed("developer", "read_file")).toBe(true);
});
it("should allow admin access to everything", () => {
expect(isToolAllowed("admin", "execute_command")).toBe(true);
expect(isToolAllowed("admin", "delete_file")).toBe(true);
});
it("should deny unknown roles", () => {
expect(isToolAllowed("unknown-role", "read_file")).toBe(false);
});
it("should deny readonly-agent access to write_file", () => {
expect(isToolAllowed("readonly-agent", "write_file")).toBe(false);
});
});// tests/validation.test.ts
import { describe, it, expect } from "vitest";
import { validateToolInput } from "../src/validation";
describe("Input Validation", () => {
const maliciousInputs = [
{ path: "../../../etc/passwd" },
{ path: "/etc/shadow" },
{ path: "file\x00.txt" },
{ path: "a".repeat(1000) },
{ path: "valid.txt", extraField: "injected" },
];
for (const input of maliciousInputs) {
it(`should reject malicious read_file input: ${JSON.stringify(input)}`, () => {
const result = validateToolInput("read_file", input);
expect(result.valid).toBe(false);
});
}
it("should accept valid read_file input", () => {
const result = validateToolInput("read_file", { path: "src/index.ts" });
expect(result.valid).toBe(true);
});
const sqlInjections = [
{ query: "SELECT 1; DROP TABLE users;", database: "analytics" },
{ query: "SELECT * FROM t UNION SELECT password FROM users", database: "analytics" },
{ query: "DELETE FROM users WHERE 1=1", database: "analytics" },
];
for (const input of sqlInjections) {
it(`should reject SQL injection: ${input.query.slice(0, 40)}...`, () => {
const result = validateToolInput("query_database", input);
// Even if schema validation passes, the SQL sanitizer should catch it
expect(result.valid).toBe(true); // schema is valid
// The SQL check happens at the handler level - tested separately
});
}
});// tests/networkPolicy.test.ts
import { describe, it, expect } from "vitest";
import { validateOutboundUrl } from "../src/networkPolicy";
describe("Outbound URL Validation", () => {
const blockedUrls = [
"http://169.254.169.254/latest/meta-data/",
"http://localhost:8080/admin",
"https://evil.com/exfiltrate",
"ftp://internal-server/data",
"https://10.0.0.1:8443/internal",
"http://[::1]/admin",
];
for (const url of blockedUrls) {
it(`should block: ${url}`, () => {
expect(validateOutboundUrl(url).allowed).toBe(false);
});
}
it("should allow requests to explicitly allowed domains", () => {
expect(validateOutboundUrl("https://api.github.com/repos").allowed).toBe(true);
expect(validateOutboundUrl("https://registry.npmjs.org/express").allowed).toBe(true);
});
});// tests/rateLimit.test.ts
import { describe, it, expect } from "vitest";
import { checkRateLimit } from "../src/rateLimit";
describe("Rate Limiting", () => {
it("should allow requests within the burst limit", () => {
const clientId = "test-burst-" + Date.now();
for (let i = 0; i < 10; i++) {
expect(checkRateLimit(clientId).allowed).toBe(true);
}
});
it("should deny requests exceeding the burst limit", () => {
const clientId = "test-exceed-" + Date.now();
for (let i = 0; i < 10; i++) {
checkRateLimit(clientId);
}
const result = checkRateLimit(clientId);
expect(result.allowed).toBe(false);
expect(result.retryAfterMs).toBeGreaterThan(0);
});
});[ ] TLS 1.3 enabled on all HTTP transports
[ ] mTLS configured for server-to-server communication
[ ] OAuth 2.1 or API key authentication enforced on every endpoint
[ ] Tool allowlist defined per role/user
[ ] Dangerous tools flagged and require explicit approval
[ ] JSON Schema validation on every tool's parameters
[ ] SQL queries use parameterized statements and are restricted to SELECT
[ ] Filesystem access sandboxed to explicit directories
[ ] Outbound network requests limited to an allowlist (SSRF mitigation)
[ ] Per-client rate limits and session token budgets enforced
[ ] Every tool invocation logged with user, params (redacted), and result
[ ] OpenTelemetry tracing integrated for observability
[ ] Container runs as non-root with read-only filesystem
[ ] Seccomp profile applied to restrict syscalls
[ ] Network policies restrict pod-to-pod communication
[ ] Authorization and input validation tests pass in CI