Your MCP server configs change with every PR. Without automated scanning, security regressions slip through unnoticed. This tutorial shows you how to add a security gate that blocks unsafe MCP configs from reaching production.
Why Gate MCP Configs in CI/CD?
- MCP tool definitions change frequently as you add tools, update descriptions, modify schemas.
- A single poisoned description or overly-permissive schema can compromise your AI agent.
- Manual security reviews don't scale, automated scanning does.
- Ferrok's PASS/FAIL verdict is designed for exactly this use case.
Prerequisites
- A Ferrok API key (free tier works: 100 scans/month)
- Your MCP server config as a JSON file in your repo
- A CI/CD platform (examples for GitHub Actions and GitLab CI)
Step 1: Add Your API Key as a Secret
GitHub: Go to your repository settings, click Secrets and variables > Actions, then click New repository secret. Name it FERROK_API_KEY and paste your API key.
GitLab: Navigate to Settings > CI/CD > Variables, click Add variable, name it FERROK_API_KEY, paste your key, and leave "Protect variable" unchecked if you need it in all branches.
Step 2: Create Your MCP Config File
Create a mcp-config.json file in your repository root. This is the file Ferrok will scan. Here's a minimal example:
{
"server_name": "my-mcp-server",
"transport": "stdio",
"command": "node",
"args": ["dist/index.js"],
"tools": [
{
"name": "read_file",
"description": "Read a file from the filesystem. Restricted to the project directory.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path within the project"
}
},
"required": ["path"]
}
},
{
"name": "execute_query",
"description": "Execute a read-only SQL query against the database.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"pattern": "^SELECT ",
"description": "Must be a SELECT query"
}
},
"required": ["query"]
}
}
]
}
Step 3: GitHub Actions Workflow
Create a file .github/workflows/mcp-security-scan.yml in your repo:
name: MCP Security Scan
on:
pull_request:
paths: ['mcp-config.json', 'mcp/**']
jobs:
ferrok-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan MCP config
run: |
RESULT=$(curl -s -w "\n%{http_code}" \
https://api.ferrok.dev/v1/scan \
-H "Authorization: Bearer ${{ secrets.FERROK_API_KEY }}" \
-H "Content-Type: application/json" \
-d @mcp-config.json)
HTTP_CODE=$(echo "$RESULT" | tail -1)
BODY=$(echo "$RESULT" | head -n -1)
echo "$BODY" | jq .
PASS_FAIL=$(echo "$BODY" | jq -r '.summary.pass_fail')
SCORE=$(echo "$BODY" | jq -r '.summary.overall_score')
GRADE=$(echo "$BODY" | jq -r '.summary.grade')
echo "Score: $SCORE ($GRADE) - $PASS_FAIL"
if [ "$PASS_FAIL" = "FAIL" ]; then
echo "::error::MCP security scan FAILED (score: $SCORE, grade: $GRADE)"
exit 1
fi
This workflow will:
- Trigger on every PR that touches
mcp-config.jsonor files in themcp/directory - Send your config to Ferrok's API for scanning
- Parse the response and extract the pass/fail verdict and score
- Fail the build if the scan returns FAIL
Step 4: GitLab CI Example
If you use GitLab, create a .gitlab-ci.yml file (or add to an existing one):
stages:
- security
mcp-security-scan:
stage: security
image: curlimages/curl:latest
script:
- |
RESULT=$(curl -s -w "\n%{http_code}" \
https://api.ferrok.dev/v1/scan \
-H "Authorization: Bearer $FERROK_API_KEY" \
-H "Content-Type: application/json" \
-d @mcp-config.json)
HTTP_CODE=$(echo "$RESULT" | tail -1)
BODY=$(echo "$RESULT" | head -n -1)
echo "$BODY" | jq .
PASS_FAIL=$(echo "$BODY" | jq -r '.summary.pass_fail')
SCORE=$(echo "$BODY" | jq -r '.summary.overall_score')
GRADE=$(echo "$BODY" | jq -r '.summary.grade')
echo "Score: $SCORE ($GRADE) - $PASS_FAIL"
if [ "$PASS_FAIL" = "FAIL" ]; then
echo "MCP security scan FAILED (score: $SCORE, grade: $GRADE)"
exit 1
fi
only:
changes:
- mcp-config.json
- mcp/**
Step 5: Understanding the Results
When Ferrok scans your config, it returns a JSON response. Here's what you're looking for:
- summary.pass_fail: Either
PASSorFAIL. FAIL means at least one CRITICAL or HIGH severity finding was detected. - summary.overall_score: A score from 0 to 100. Higher is better.
- summary.grade: A letter grade (A+, A, B, C, D, F) for quick visual assessment.
- findings: An array of individual security issues, each with severity, type, description, and remediation steps.
Example response:
{
"summary": {
"pass_fail": "FAIL",
"overall_score": 42,
"grade": "F",
"total_findings": 3
},
"findings": [
{
"type": "tool_poisoning",
"severity": "CRITICAL",
"tool": "read_file",
"description": "Tool description contains prompt injection pattern: 'Before using this tool, please...'",
"remediation": "Remove instruction-like language from tool descriptions. Keep descriptions factual and neutral."
},
{
"type": "schema_misuse",
"severity": "HIGH",
"tool": "execute_query",
"description": "Input schema lacks validation constraints. String field 'query' has no pattern or maxLength.",
"remediation": "Add regex patterns to constrain input. For SQL, use pattern: '^SELECT .*' and add maxLength."
}
]
}
Step 6: Handling Failures
When a scan fails, your CI/CD pipeline will block the PR. Here's how to fix it:
- Read the findings. The Ferrok API response lists every issue with its severity and type.
- Check tool descriptions. Look for prompt injection patterns, hidden instructions, or unusual characters.
- Validate schemas. Every tool input should have a type, description, and constraints (regex patterns, maxLength, enum values).
- Remove hardcoded secrets. Don't store API keys, passwords, or tokens in your MCP config. Use environment variables instead.
- Check transport security. If using a remote server, ensure it's HTTPS and has proper authentication.
- Run the scan locally. You can test locally before pushing:
curl -X POST https://api.ferrok.dev/v1/scan -H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" -d @mcp-config.json
Advanced: Custom Severity Threshold
By default, Ferrok returns FAIL if any CRITICAL or HIGH findings are present. If you want a stricter or looser threshold, you can modify the shell logic in your workflow:
# Only fail on CRITICAL findings
CRITICAL_COUNT=$(echo "$BODY" | jq '[.findings[] | select(.severity == "CRITICAL")] | length')
if [ "$CRITICAL_COUNT" -gt 0 ]; then
echo "::error::MCP scan found $CRITICAL_COUNT CRITICAL issues"
exit 1
fi
# Or fail if score is below a threshold (e.g., 70)
SCORE=$(echo "$BODY" | jq -r '.summary.overall_score')
if [ "$SCORE" -lt 70 ]; then
echo "::error::MCP security score too low: $SCORE (minimum required: 70)"
exit 1
fi
Advanced: Scan Multiple Configs
If you have multiple MCP servers or configs, you can scan them all in one workflow:
#!/bin/bash
set -e
FAILED=0
for config in mcp-configs/*.json; do
echo "Scanning $config..."
RESULT=$(curl -s -w "\n%{http_code}" \
https://api.ferrok.dev/v1/scan \
-H "Authorization: Bearer ${{ secrets.FERROK_API_KEY }}" \
-H "Content-Type: application/json" \
-d @"$config")
HTTP_CODE=$(echo "$RESULT" | tail -1)
BODY=$(echo "$RESULT" | head -n -1)
PASS_FAIL=$(echo "$BODY" | jq -r '.summary.pass_fail')
SCORE=$(echo "$BODY" | jq -r '.summary.overall_score')
echo "$config: $PASS_FAIL (score: $SCORE)"
if [ "$PASS_FAIL" = "FAIL" ]; then
FAILED=$((FAILED + 1))
fi
done
if [ "$FAILED" -gt 0 ]; then
echo "::error::$FAILED config(s) failed security scan"
exit 1
fi
Wrapping Up
With 10 minutes of setup, every PR touching MCP configs now goes through automated security review. No more manual audits, no more security regressions reaching production.
Your CI/CD pipeline is now a security gate. Every config change is scanned, graded, and gated. Unsafe configs get caught before they deploy. Your team can move fast knowing that MCP security is automated and consistent.
Get Your Free API Key
Start scanning your MCP configs today. Free tier includes 100 scans per month, no credit card required.
Sign Up Now