Secure your MCP server: an 8-point developer checklist
In November 2026, Barracuda reported that 43 agent framework components shipped with embedded vulnerabilities through supply chain compromise. Attackers didn't break into servers. They poisoned the tools that AI agents call. The OWASP Agentic Apps Top 10 now lists Supply Chain Vulnerabilities and Tool Misuse as dedicated categories.
MCP (Model Context Protocol) servers expose your APIs as callable tools for AI assistants: Claude Desktop, Cursor, VS Code Copilot, Windsurf. Every tool you register becomes an attack surface. And most MCP servers ship with no authentication, no input validation, and no logging.
This post is an 8-point checklist for securing your MCP server. Each item includes working code you can adapt. The examples use the Botoi API for schema validation, hashing, and JWT decoding, but the patterns apply to any MCP server.
The MCP security problem in one table
| Risk | What happens | Checklist item |
|---|---|---|
| No auth on MCP endpoint | Any client on the network calls any tool | #1 Add authentication |
| All tools exposed to all keys | A read-only monitoring agent triggers destructive operations | #2 Scope tools per key |
| No input validation | Agents send malformed JSON; tools crash or execute unintended operations | #3 Validate with Zod |
| Missing tool annotations | Agents can't distinguish read-only tools from destructive ones | #4 Set annotations |
| No rate limits | One looping agent exhausts your API quota in minutes | #5 Rate-limit per agent |
| No audit trail | You can't trace which agent caused a production issue | #6 Log invocations |
| Tool manifest tampering | Attacker modifies tool definitions to redirect calls | #7 Pin and hash manifests |
| No confirmation for writes | Agent creates, deletes, or modifies data without human review | #8 Sandbox destructive ops |
1. Add authentication to your MCP endpoint
"It's on localhost" is not a security model. Browser extensions, local processes, and any code running on
the same machine can reach localhost:3000. Remote MCP is growing fast; Claude Desktop, Cursor,
and Windsurf all support connecting to remote MCP servers over HTTPS.
Require a Bearer token on every request. Botoi's MCP server at api.botoi.com/mcp accepts API
keys via the Authorization header. Without a valid key, you get 5 requests per minute burst
and 100 per day; with one, the limits scale to your plan.
If your agents pass JWTs instead of static keys, decode them to extract scopes and identity. Here's how to inspect a JWT from an MCP request header:
curl -s -X POST https://api.botoi.com/v1/jwt/decode \
-H "Content-Type: application/json" \
-d '{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZ2VudC1jdXJzb3ItNDIiLCJzY29wZSI6InJlYWQ6ZG5zIHJlYWQ6c3NsIiwiaWF0IjoxNzE3MDAwMDAwfQ.signature"
}' {
"success": true,
"data": {
"header": { "alg": "RS256", "typ": "JWT" },
"payload": {
"sub": "agent-cursor-42",
"scope": "read:dns read:ssl",
"iat": 1717000000
},
"signature": "signature"
}
}
The sub claim identifies the agent. The scope claim lists which tools it can
access. Verify the signature server-side before trusting these values.
2. Scope tools per API key
Not every agent needs every tool. A monitoring agent that checks DNS records should never access a paste-creation endpoint. Create allowlists that map each API key to a specific set of tools.
// Scope tools per API key using an allowlist
interface AgentPermissions {
allowedTools: Set<string>;
maxBurst: number;
dailyLimit: number;
}
const AGENT_PERMISSIONS: Record<string, AgentPermissions> = {
"key_readonly_monitor": {
allowedTools: new Set(["lookup_dns", "lookup_ssl", "lookup_ip"]),
maxBurst: 10,
dailyLimit: 500,
},
"key_full_access": {
allowedTools: new Set(["*"]), // wildcard for all tools
maxBurst: 30,
dailyLimit: 5000,
},
};
function canUseTool(apiKey: string, toolName: string): boolean {
const perms = AGENT_PERMISSIONS[apiKey];
if (!perms) return false;
if (perms.allowedTools.has("*")) return true;
return perms.allowedTools.has(toolName);
} Botoi's MCP server exposes 49 curated tools from 150+ API endpoints. The curation itself is a form of scoping: only tools that make sense for AI assistants are registered. Your server should do the same.
3. Validate tool inputs with Zod schemas
MCP tools accept arbitrary JSON from agents. An agent can send {"domain": 42} where you
expect a string, or include fields your tool doesn't recognize. Validate every input before executing.
Botoi's MCP server converts OpenAPI path definitions into Zod schemas at startup using
schema-builder.ts. The buildZodSchema function reads your OpenAPI spec, maps
each property type to a Zod type, and marks required fields:
import { z } from "zod";
import { buildZodSchema } from "./schema-builder";
// Build a Zod schema from your OpenAPI spec for each tool
const dnsSchema = z.object(buildZodSchema("/v1/dns/lookup", "post"));
function validateToolInput(toolName: string, input: unknown) {
const schemas: Record<string, z.ZodTypeAny> = {
lookup_dns: dnsSchema,
// ... one schema per tool
};
const schema = schemas[toolName];
if (!schema) {
throw new Error(`Unknown tool: ${toolName}`);
}
const result = schema.safeParse(input);
if (!result.success) {
return {
error: "Invalid input",
issues: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
};
}
return { data: result.data };
} Generate Zod schemas from sample inputs
If you don't have an OpenAPI spec, generate Zod schemas from example tool inputs:
curl -s -X POST https://api.botoi.com/v1/schema/json-to-zod \
-H "Content-Type: application/json" \
-d '{
"json": {
"domain": "github.com",
"check_mx": true,
"timeout_ms": 5000
}
}' {
"success": true,
"data": {
"schema": "z.object({ domain: z.string(), check_mx: z.boolean(), timeout_ms: z.number() })"
}
} Validate inputs against JSON Schema at runtime
For runtime validation without Zod, use JSON Schema. Send the schema and the agent's input to
/v1/schema/validate:
curl -s -X POST https://api.botoi.com/v1/schema/validate \
-H "Content-Type: application/json" \
-d '{
"schema": {
"type": "object",
"required": ["domain"],
"properties": {
"domain": { "type": "string", "minLength": 1 },
"record_type": { "type": "string", "enum": ["A", "AAAA", "MX", "TXT", "CNAME", "NS"] }
},
"additionalProperties": false
},
"data": { "domain": "stripe.com", "record_type": "MX" }
}' Valid input returns:
{
"success": true,
"data": {
"valid": true,
"errors": []
}
}
If an agent passes "record_type": "INVALID", you get actionable errors before your tool runs:
{
"success": true,
"data": {
"valid": false,
"errors": [
{
"path": "/record_type",
"message": "must be equal to one of the allowed values"
}
]
}
} 4. Set tool annotations
The MCP protocol defines four annotation hints: readOnlyHint, destructiveHint,
idempotentHint, and openWorldHint. These tell agents whether a tool reads data,
modifies state, is safe to retry, or contacts external services. Most MCP servers ignore them.
Botoi's MCP server annotates all 49 tools. Here's what that looks like in practice:
// curated-tools.ts (from Botoi's MCP server)
export const CURATED_TOOLS: Record<string, CuratedTool> = {
lookup_dns: {
path: "/v1/dns/lookup",
method: "post",
title: "DNS Lookup",
description: "Query DNS records for a domain.",
annotations: {
readOnlyHint: true, // reads data, changes nothing
openWorldHint: true, // contacts external DNS servers
},
},
storage_paste_create: {
path: "/v1/paste/create",
method: "post",
title: "Create Paste",
description: "Store text content and return a short URL.",
annotations: {
destructiveHint: true, // creates new data
idempotentHint: false, // each call creates a new paste
},
},
}; Agents that respect annotations will avoid calling destructive tools without confirmation and prefer idempotent tools when retrying failed requests. Set them on every tool.
| Annotation | Meaning | Example |
|---|---|---|
readOnlyHint: true | Tool reads data, changes nothing | DNS lookup, SSL check, IP geolocation |
destructiveHint: true | Tool creates, updates, or deletes data | Create paste, generate short URL, send webhook |
idempotentHint: true | Safe to call multiple times with same input | Hash generation, JSON formatting, unit conversion |
openWorldHint: true | Tool contacts external services | DNS over HTTPS, WHOIS, URL metadata extraction |
5. Rate-limit per agent identity
One rogue agent stuck in a retry loop shouldn't exhaust your entire API quota. Standard rate limiting by IP address isn't enough; multiple agents can share the same IP, and a single agent can rotate IPs.
Extract the agent identity from the API key or JWT, then apply per-identity limits using a sliding window or token bucket stored in KV:
// Hono middleware for per-agent rate limiting
import type { Context, Next } from "hono";
const BURST_LIMIT = 5; // requests per minute
const DAILY_LIMIT = 100; // requests per day
async function rateLimitAgent(c: Context, next: Next) {
// Extract agent identity from API key or JWT
const apiKey = c.req.header("Authorization")?.replace("Bearer ", "");
const agentId = apiKey || c.req.header("X-Forwarded-For") || "anonymous";
// Check burst limit (sliding window in KV)
const burstKey = `rate:${agentId}:burst`;
const burstCount = Number(await c.env.KV.get(burstKey)) || 0;
if (burstCount >= BURST_LIMIT) {
return c.json({ error: "Rate limit exceeded" }, 429);
}
// Increment counters
await c.env.KV.put(burstKey, String(burstCount + 1), {
expirationTtl: 60,
});
// Check daily limit
const dailyKey = `rate:${agentId}:daily:${new Date().toISOString().slice(0, 10)}`;
const dailyCount = Number(await c.env.KV.get(dailyKey)) || 0;
if (dailyCount >= DAILY_LIMIT) {
return c.json({ error: "Daily limit exceeded" }, 429);
}
await c.env.KV.put(dailyKey, String(dailyCount + 1), {
expirationTtl: 86400,
});
await next();
} Botoi's auth middleware enforces two layers: a burst limit (5 requests per minute) and a daily cap (100 requests per day) for anonymous access. Authenticated keys get higher limits based on their Stripe subscription tier. Apply the same two-layer pattern to your MCP server.
6. Log every tool invocation with agent attribution
When something goes wrong in production, you need to answer three questions: which agent called which tool, when, and with what input. Without invocation logs, you're debugging blind.
Hash the input instead of storing raw arguments. This gives you enough to correlate and deduplicate without logging sensitive data:
// Log every tool invocation with agent identity
interface ToolInvocationLog {
timestamp: string;
agent_id: string;
tool_name: string;
input_hash: string; // SHA-256 of the input (not the raw input)
duration_ms: number;
status: "success" | "error";
}
async function logToolInvocation(
agentId: string,
toolName: string,
input: unknown,
startTime: number,
status: "success" | "error"
) {
const inputStr = JSON.stringify(input);
const inputHash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(inputStr)
);
const hashHex = Array.from(new Uint8Array(inputHash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const log: ToolInvocationLog = {
timestamp: new Date().toISOString(),
agent_id: agentId,
tool_name: toolName,
input_hash: hashHex,
duration_ms: Date.now() - startTime,
status,
};
// Send to your logging pipeline (Datadog, Loki, Cloudflare Logpush)
console.log(JSON.stringify(log));
} Send these structured logs to your existing pipeline (Datadog, Grafana Loki, Cloudflare Logpush). Build alerts for unusual patterns: a single agent making 10x its normal call volume, or a read-only agent attempting to call destructive tools.
7. Pin tool definitions and check integrity
The Barracuda report found attackers injecting malicious tool definitions into agent frameworks through compromised dependencies. If someone modifies your MCP tool manifest (names, descriptions, or parameter schemas), agents will call tools with different behavior than you intended.
Hash your tool manifest at build time. At startup, recompute the hash and compare:
import crypto from "node:crypto";
import Botoi from "@botoi/sdk";
const botoi = new Botoi();
// Your tool manifest (the source of truth)
const tools = [
{ name: "dns_lookup", path: "/v1/dns/lookup" },
{ name: "ip_lookup", path: "/v1/ip/lookup" },
{ name: "email_validate", path: "/v1/email/validate" },
// ... all your tools
];
const manifest = JSON.stringify(tools);
// Hash it at build time and store the expected hash
const EXPECTED_HASH = "a3f2b8c1d4e5f6..."; // from your CI build
// At startup, verify integrity
function verifyManifestIntegrity() {
const currentHash = crypto
.createHash("sha256")
.update(manifest)
.digest("hex");
if (currentHash !== EXPECTED_HASH) {
throw new Error(
`Tool manifest tampered. Expected ${EXPECTED_HASH}, got ${currentHash}`
);
}
console.log("Tool manifest integrity verified.");
}
// Or use the Botoi API for environments without Node crypto
async function verifyWithApi() {
const result = await botoi.hash.sha256({ input: manifest });
if (result.data.hash !== EXPECTED_HASH) {
throw new Error("Tool manifest tampered.");
}
} Generate the hash with the Botoi API
For CI environments or edge runtimes without node:crypto:
curl -s -X POST https://api.botoi.com/v1/hash \
-H "Content-Type: application/json" \
-d '{
"text": "[{\"name\":\"dns_lookup\",\"path\":\"/v1/dns/lookup\"},{\"name\":\"ip_lookup\",\"path\":\"/v1/ip/lookup\"}]",
"algorithm": "sha256"
}' {
"success": true,
"data": {
"hash": "a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678",
"algorithm": "sha256"
}
} Store the expected hash as an environment variable. Compare it at deploy time. If the hashes diverge, block the deployment.
8. Sandbox destructive operations
Tools that write data, trigger side effects, or contact external services need a confirmation flow. Don't let an agent create 1,000 pastes or fire webhooks without human review.
// Confirmation flow for destructive MCP tools
interface ToolResult {
content: Array<{ type: string; text: string }>;
isError?: boolean;
}
function handleDestructiveTool(
toolName: string,
input: Record<string, unknown>,
confirmed: boolean
): ToolResult {
const destructiveTools = new Set([
"paste_create",
"short_url_create",
"webhook_inbox_create",
]);
if (!destructiveTools.has(toolName)) {
// Non-destructive: execute immediately
return executeTool(toolName, input);
}
if (!confirmed) {
// Return a preview instead of executing
return {
content: [{
type: "text",
text: JSON.stringify({
action: "confirmation_required",
tool: toolName,
input,
message: `This tool will create new data. Pass "confirmed": true to proceed.`,
}),
}],
};
}
// Confirmed: execute the destructive operation
return executeTool(toolName, input);
}
The pattern is straightforward: destructive tools return a preview on first call. The agent shows
the preview to the user. Only after the user confirms does the agent send a second call with
"confirmed": true.
For tools that contact external services (openWorldHint: true), consider adding
a timeout and a circuit breaker. If an external API is slow or down, your MCP server shouldn't
hang forever waiting for a response.
How Botoi's MCP server applies these checks
Botoi's MCP server at api.botoi.com/mcp serves as a working reference for this
checklist. Here's how each item maps:
| Checklist item | How Botoi does it |
|---|---|
| Authentication | API keys via Authorization header; anonymous access with strict rate limits |
| Tool scoping | 49 curated tools from 150+ endpoints; only agent-appropriate tools are registered |
| Input validation | schema-builder.ts converts OpenAPI schemas to Zod; validates before execution |
| Tool annotations | Every tool in curated-tools.ts has readOnlyHint, destructiveHint, etc. |
| Rate limiting | 5 req/min burst + 100 req/day anonymous cap via KV; higher limits per API key tier |
| Logging | Cloudflare Workers observability logs for every request with agent attribution |
| Manifest integrity | Tool definitions are in version-controlled TypeScript; deployed via CI with no runtime mutation |
| Destructive sandboxing | Storage tools (paste, short-url, webhook) are separate from read-only lookup tools; annotations signal risk |
You can connect to Botoi's MCP server from Claude Desktop, Claude Code, Cursor, VS Code, or Windsurf. The MCP setup page has the configuration for each client.
Your MCP server security checklist
Here's the full checklist, condensed. Print it, pin it, or paste it into your team's runbook:
- Require authentication on every MCP endpoint (Bearer token, API key, or JWT)
- Map each API key to an allowlist of permitted tools
- Validate every tool input with Zod schemas or JSON Schema before execution
- Annotate every tool with
readOnlyHint,destructiveHint,idempotentHint, andopenWorldHint - Rate-limit per agent identity, not per IP; use both burst and daily caps
- Log every tool invocation with agent ID, tool name, input hash, duration, and status
- Hash your tool manifest at build time; verify at startup and block tampered deployments
- Return previews from destructive tools; require explicit confirmation before executing
43 agent frameworks shipped with embedded vulnerabilities in 2026. MCP servers are the next target. The eight checks above won't make your server invulnerable, but they'll close the gaps that attackers exploit first: open endpoints, unvalidated inputs, and invisible invocations.
Frequently asked questions
- Do MCP servers need authentication?
- Yes. Most MCP servers ship with no authentication, meaning any client that reaches the endpoint can invoke any tool. As remote MCP adoption grows, you need API key or JWT-based auth on every endpoint. Localhost-only is not a security guarantee; local processes and browser extensions can reach localhost too.
- What are MCP tool annotations and why do they matter?
- Tool annotations are metadata hints defined in the MCP protocol: readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. They tell AI agents whether a tool reads data, modifies state, is safe to retry, or contacts external services. Agents use these hints to make safer decisions about which tools to call and in what order.
- How do I rate-limit AI agents calling my MCP server?
- Assign each agent (or API key) an identity and apply per-identity rate limits. Use a token bucket or sliding window algorithm. Track requests in a KV store or Redis. Botoi's own MCP server enforces 5 requests per minute burst and 100 requests per day for anonymous access, with higher limits for authenticated keys.
- Can I validate MCP tool inputs with Zod?
- Yes. MCP tools receive arbitrary JSON from agents. Define a Zod schema for each tool's expected input shape and validate before executing. You can generate Zod schemas from sample JSON using the Botoi /v1/schema/json-to-zod endpoint, or convert OpenAPI path definitions to Zod objects as Botoi's schema-builder.ts does.
- How do I detect tampering with my MCP tool manifest?
- Hash your tool manifest with SHA-256 and store the hash. Before each server start or deployment, recompute the hash and compare. If the hashes differ, someone (or something) modified your tool definitions. You can compute SHA-256 hashes via the Botoi /v1/hash endpoint or with native crypto libraries.
Try this API
JWT Generate API — interactive playground and code examples
More guide posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.