TypeScript

A minimal MCP tool with a guard

You want an MCP server that exposes one read-only tool plus one destructive variant an agent cannot trip into by accident. Zod schema is the fence.

12 Jun 2026

Read-only by default is the house rule. The one tool that changes state demands an explicit confirm: "yes" in the schema, so a model cannot stumble into it.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
type Content = { content: { type: "text"; text: string }[]; isError?: boolean };
 
const textResult = (text: string): Content => ({ content: [{ type: "text", text }] });
const jsonResult = (data: unknown): Content => textResult(JSON.stringify(data));
const errorResult = (text: string): Content => ({ ...textResult(text), isError: true });
 
const appName = z
  .string()
  .min(1)
  .max(63)
  .regex(/^[a-z0-9-]+$/, "lowercase letters, digits, hyphens only");
 
const server = new McpServer({ name: "ops", version: "1.0.0" });
 
// Read-only: safe to call any time.
server.tool(
  "get_app_status",
  "Read the deploy status of one app. Read-only; never changes anything.",
  { app: appName },
  async ({ app }) => {
    const res = await fetch(`https://ops.internal/apps/${app}/status`);
    if (!res.ok) return errorResult(`lookup failed: ${res.status}`);
    return jsonResult(await res.json());
  },
);
 
// Destructive: guarded by a literal the model must supply on purpose.
server.tool(
  "restart_app",
  "Restart one app. Destructive; requires confirm:\"yes\".",
  { app: appName, confirm: z.literal("yes") },
  async ({ app }) => {
    const res = await fetch(`https://ops.internal/apps/${app}/restart`, {
      method: "POST",
    });
    if (!res.ok) return errorResult(`restart failed: ${res.status}`);
    return textResult(`restarted ${app}`);
  },
);
 
await server.connect(new StdioServerTransport());

Gotchas

confirm: z.literal("yes") is not cosmetic. The SDK rejects the call before your handler runs unless that exact value is present, so a model that fires restart_app on a hunch gets a schema error, not a restarted production app. Keep every state-changing tool behind one, and leave reads unguarded so the agent can look around freely.

Related
now runningwhisper_scheduleopen