Skip to main content
This guide shows you how to protect an MCP server’s tool handlers with Veto using createVetoGuard. Denied tool calls never reach your handler — Veto intercepts them and returns an MCP-compatible error response.

Prerequisites

  • A Veto account and API key from app.veto.tools
  • An existing MCP server, or follow the steps below to create one
1

Install dependencies

npm install @useveto/node @modelcontextprotocol/sdk
2

Create VetoClient and the guard wrapper

Initialize the client with your API key, then create a protect function using createVetoGuard. Every tool handler you wrap with protect will be authorized against Veto before it runs.
import { VetoClient, createVetoGuard } from "@useveto/node";

const veto = new VetoClient({
  apiKey: process.env.VETO_API_KEY!,
});

const protect = createVetoGuard(veto, {
  agentId: process.env.VETO_AGENT_ID!,
  onDenied: (tool, reason) => console.warn(`Denied: ${tool}${reason}`),
});
onDenied is optional but useful for logging and metrics. The agentId tells Veto which agent’s policies to evaluate.
3

Register an agent in Veto

If you haven’t created an agent yet, register one. You only need to do this once — store the returned id as VETO_AGENT_ID in your environment.
const agent = await veto.createAgent({
  name: "file-server",
  description: "MCP server with access to the local filesystem",
});

console.log(agent.id); // set this as VETO_AGENT_ID
4

Create a policy for this server

Create a policy that defines exactly which tools your MCP server is allowed to use. This example allows file.read and file.write but explicitly denies file.delete.
await veto.createPolicy({
  agentId: agent.id,
  name: "file-server-policy",
  rules: [
    {
      type: "tool_allowlist",
      tools: ["file.read", "file.write"],
    },
    {
      type: "tool_denylist",
      tools: ["file.delete"],
    },
  ],
});
Veto is default deny: any tool not explicitly listed in an allowlist is blocked, even without a denylist rule. The denylist here makes the intent explicit and produces a clearer denial reason in the audit log.
5

Wrap tool handlers with protect()

Use protect(vetoAction, handler) when registering each tool. The first argument is the Veto action name (what Veto evaluates against your policies). The second is your existing tool handler.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import * as fs from "fs/promises";

const server = new McpServer({ name: "file-server", version: "1.0.0" });

// Allowed — file.read is in the allowlist
server.tool(
  "file_read",
  { path: z.string() },
  protect("file.read", async ({ path }) => {
    const content = await fs.readFile(path, "utf-8");
    return { content: [{ type: "text", text: content }] };
  }),
);

// Allowed — file.write is in the allowlist
server.tool(
  "file_write",
  { path: z.string(), content: z.string() },
  protect("file.write", async ({ path, content }) => {
    await fs.writeFile(path, content, "utf-8");
    return { content: [{ type: "text", text: "Written successfully" }] };
  }),
);

// Denied — file.delete is in the denylist; the handler never executes
server.tool(
  "file_delete",
  { path: z.string() },
  protect("file.delete", async ({ path }) => {
    await fs.unlink(path);
    return { content: [{ type: "text", text: "Deleted" }] };
  }),
);
When file_delete is called, Veto returns a denial before fs.unlink is ever reached.
6

Understand fail-closed behavior

By default, if Veto is unreachable (network error, timeout, or 5xx response), createVetoGuard blocks the tool call. Your handler does not run.
const protect = createVetoGuard(veto, {
  agentId: process.env.VETO_AGENT_ID!,
  onError: "deny", // default — fail closed, recommended for production
});
You can switch to fail-open for development, but this is not recommended in production because it removes the authorization layer entirely when Veto is down:
const protect = createVetoGuard(veto, {
  agentId: process.env.VETO_AGENT_ID!,
  onError: "allow", // fail open — not recommended for production
});
Setting onError: "allow" means any Veto outage allows all tool calls through unchecked. Use "deny" in production.
7

Test with a denied tool

Call file_delete via the MCP protocol and observe the error response. No file system access occurs.
curl -s http://localhost:3200/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "mcp-session-id: $SESSION_ID" \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "file_delete",
      "arguments": { "path": "/home/user/report.pdf" }
    }
  }'
Veto returns an MCP-compatible error response with isError: true:
{
  "content": [
    {
      "type": "text",
      "text": "Authorization denied: Tool 'file.delete' is on the denylist"
    }
  ],
  "isError": true
}
The MCP client receives a structured error, and your handler code never ran.

Complete example

Here is the full server in a single file:
import { VetoClient, createVetoGuard } from "@useveto/node";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import * as fs from "fs/promises";

const veto = new VetoClient({ apiKey: process.env.VETO_API_KEY! });

const protect = createVetoGuard(veto, {
  agentId: process.env.VETO_AGENT_ID!,
  onDenied: (tool, reason) => console.warn(`Denied: ${tool}${reason}`),
});

const server = new McpServer({ name: "file-server", version: "1.0.0" });

server.tool(
  "file_read",
  { path: z.string() },
  protect("file.read", async ({ path }) => {
    const content = await fs.readFile(path, "utf-8");
    return { content: [{ type: "text", text: content }] };
  }),
);

server.tool(
  "file_write",
  { path: z.string(), content: z.string() },
  protect("file.write", async ({ path, content }) => {
    await fs.writeFile(path, content, "utf-8");
    return { content: [{ type: "text", text: "Written successfully" }] };
  }),
);

server.tool(
  "file_delete",
  { path: z.string() },
  protect("file.delete", async ({ path }) => {
    await fs.unlink(path);
    return { content: [{ type: "text", text: "Deleted" }] };
  }),
);

Using vetoMiddleware instead

If you prefer to call the authorization check manually inside your handler (for example, to add custom pre-authorization logic), use vetoMiddleware. It throws a VetoError if the action is denied.
import { VetoClient, vetoMiddleware } from "@useveto/node";

const veto = new VetoClient({ apiKey: process.env.VETO_API_KEY! });
const guard = vetoMiddleware(veto, { agentId: process.env.VETO_AGENT_ID! });

server.tool("file_read", { path: z.string() }, async ({ path }) => {
  await guard("file.read", { path }); // throws VetoError if denied
  const content = await fs.readFile(path, "utf-8");
  return { content: [{ type: "text", text: content }] };
});

What’s next