Blog · Remediation
From C to A grade: a week-by-week MCP server remediation plan
A C grade is not a rejection — it is a prioritized work order. The SkillAudit report tells you exactly which sub-scores dragged the overall grade down and which findings within those sub-scores are critical versus minor. This post turns that report into a concrete four-week plan: what to fix this week, what can wait until next week, and how to verify the fix actually moved the needle before you re-scan.
2026-06-05 · 14 min read
What a C grade actually means
Before diving into fixes, it helps to understand what triggers a C rather than a D or B. The SkillAudit scoring methodology grades across six axes — Security, Permissions, Credentials, Maintenance, Compatibility, and Documentation — and the overall grade is a weighted composite with Security carrying the heaviest weight (40% of the total).
A C grade (55–69 composite points) typically means one of three patterns:
- Security D, everything else B–C. One SSRF or shell-injection finding in a secondary tool path that rarely executes in practice, but the static analysis still flags it. Fixing the security finding alone often moves the overall grade to B or A.
- Credentials D, Security C–B. API keys logged at startup, environment variables echoed in error responses, or hardcoded fallback tokens in tests that got committed. No active exploitation path, but real credential exposure risk.
- Security C, Permissions D, Credentials C. The server works fine but was written without a security-first mindset: the handler uses
shell: truefor one convenience call, asks for more filesystem permissions than it uses, and stores tokens in module-level variables. Three independent issues, each minor, that compound to a weak overall grade.
The pattern matters because your remediation path is different for each. Pattern 1 is a one-day fix. Pattern 3 is a four-week systematic cleanup. The section below shows how to read the sub-score breakdown to identify which pattern you are in before you write a line of code.
Reading the report before writing code
The first ten minutes of remediation should not involve a text editor. Open the SkillAudit report and work through this triage sequence:
Step 1 — identify any F sub-scores
An F on any single axis blocks the server from an overall grade above D regardless of other scores. F findings are never "minor" — they represent confirmed exploitable paths (SSRF to internal metadata service, shell command injection with user-controlled arguments, hardcoded credential in source). Fix F findings before anything else.
Step 2 — read the Critical and High findings in the Security axis
Within the Security sub-score, findings are tagged Critical / High / Medium / Low. Critical = confirmed exploitable. High = likely exploitable under realistic conditions. Focus weeks 1 and 2 entirely on Critical and High. Medium and Low findings matter for getting from B to A but not for getting from C to B.
Step 3 — note the Permissions and Credentials sub-scores
These are often the fastest to fix once you know what they are. Permissions findings are almost always "requested scope X but only uses Y" — a one-line fix to the manifest. Credentials findings are almost always one of three things: logging, hardcoded fallbacks, or over-scoped tokens. Each has a well-known fix pattern.
Step 4 — defer Maintenance and Documentation to week 3–4
Maintenance findings (outdated dependencies, missing lock file, stale README) and Documentation findings (no runnable example, missing version field) are real but low-urgency. They never create direct exploitation paths. Fix them after the security and credential issues are resolved.
The realistic C-grade example server
To make the remediation plan concrete, let us use a representative C-grade server that appears often in SkillAudit scans: a GitHub integration server that reads issues, posts comments, and can trigger workflow dispatches.
Overall: C (60/100). The two D sub-scores are Security and Permissions. The Critical finding in Security is SSRF in the fetchReadme tool. The High finding is the workflow-dispatch tool accepting a ref argument with no branch-allowlist validation. Permissions D is because the server requests repo scope (full read/write) but only needs issues:write and actions:write. Credentials C is because the token is logged once at startup in a debug line.
Week 1: Fix critical and high security findings
Security: SSRF and injection
Target: Security sub-score from D (48) to B (78). Resolves all Critical and High findings.
Fix 1: SSRF in fetchReadme
The fetchReadme tool accepts a repoUrl argument and passes it to fetch() without hostname validation. An adversarial prompt injection that changes repoUrl to http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS metadata service) or http://10.0.0.1/admin will exfiltrate internal credentials or trigger actions on internal services.
// Dangerous: repoUrl passed directly to fetch with no hostname check
server.tool('fetchReadme', {
schema: { repoUrl: { type: 'string' } },
handler: async ({ repoUrl }) => {
const res = await fetch(repoUrl); // SSRF — any URL accepted
return res.text();
}
});
// Safe: reconstruct the URL from validated owner/repo components only
server.tool('fetchReadme', {
schema: {
owner: { type: 'string', pattern: '^[a-zA-Z0-9_.-]{1,100}$' },
repo: { type: 'string', pattern: '^[a-zA-Z0-9_.-]{1,100}$' }
},
handler: async ({ owner, repo }) => {
// Constructed from validated components — no user-supplied URL reaches fetch()
const url = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/README.md`;
const res = await fetch(url, { redirect: 'error' }); // block redirects
if (!res.ok) throw new Error(`README fetch failed: ${res.status}`);
return res.text();
}
});
The key change: the tool no longer accepts a URL. It accepts structured fields (owner and repo) that are each validated against a strict pattern, then constructs the URL deterministically. There is no longer any LLM-controlled value that reaches the network layer. The redirect: 'error' option on fetch prevents open-redirect chains that could bypass the validation via 301 responses.
Fix 2: workflow-dispatch ref without allowlist
The dispatchWorkflow tool accepts a ref argument (branch or tag name) and passes it to the GitHub API workflow dispatch endpoint. Without an allowlist, prompt injection can target any branch — including branches that have more permissive CI permissions, branch protection bypasses, or deploy-to-production workflows that only run on protected branches.
// Dangerous: any ref accepted — attacker can target any branch
server.tool('dispatchWorkflow', {
schema: {
workflow: { type: 'string' },
ref: { type: 'string' } // no validation
},
handler: async ({ workflow, ref }) => {
return octokit.actions.createWorkflowDispatch({
owner: REPO_OWNER, repo: REPO_NAME, workflow_id: workflow, ref
});
}
});
// Safe: ref must be in an explicit server-side allowlist
const ALLOWED_REFS = new Set(['main', 'develop', 'staging']);
server.tool('dispatchWorkflow', {
schema: {
workflow: { type: 'string', enum: ['ci.yml', 'deploy-staging.yml'] },
ref: { type: 'string', enum: ['main', 'develop', 'staging'] }
},
handler: async ({ workflow, ref }) => {
// Double-check even with enum — schema validation can be bypassed in some transports
if (!ALLOWED_REFS.has(ref)) throw new Error(`Ref not allowed: ${ref}`);
return octokit.actions.createWorkflowDispatch({
owner: REPO_OWNER, repo: REPO_NAME, workflow_id: workflow, ref
});
}
});
The allowlist is enforced at two layers: in the JSON Schema enum (which the MCP client validates before the handler runs) and in the handler itself as a server-side guard. The schema-level validation stops the LLM from generating an invalid value; the server-side check catches any transport or schema-bypass path. Both are needed because the JSON Schema enum constraint in MCP is a suggestion to the client, not a guaranteed enforcement point.
Week 1 checklist
- Replace
fetch(userSuppliedUrl)withfetch(constructedUrl)in all tools that fetch external resources - Add
redirect: 'error'or an explicit redirect follow-count cap (max 3) to allfetch()calls - Add server-side allowlist validation to any tool accepting a branch, tag, environment, or workflow name
- Run SkillAudit re-scan after week 1 changes to confirm Critical and High findings resolved
- Target: Security sub-score moves to B (75–82 range)
Week 2: Permissions minimization
Permissions: scope reduction and schema tightening
Target: Permissions sub-score from D (52) to A (90+). Zero code changes required — configuration and manifest only.
The over-scoped token problem
The example server requests repo scope on GitHub token creation. The repo scope grants full read/write access to code, issues, pull requests, actions, secrets, and webhooks across all private repositories the token owner can access. The server only uses three GitHub API endpoints: list issues, create comment, and trigger workflow dispatch. The minimum scopes for those operations are repo:issues (read/write issues), and actions:write (dispatch workflows).
The fix is a configuration change in the token creation step, not a code change. In the server's documentation and setup instructions, change the token scope from repo to the minimum required scopes. If the server generates tokens programmatically, update the scope parameter in the OAuth flow.
// In docs/SETUP.md — change scope from:
// Required token scopes: repo
// To:
// Required token scopes: issues:write, actions:write
// (read:issues is included in issues:write; no other scopes required)
// If your server generates tokens via GitHub App OAuth flow:
const REQUIRED_SCOPES = ['issues:write', 'actions:write']; // was: ['repo']
// At startup, verify the token has only the required scopes:
async function verifyTokenScopes(token) {
const { data } = await octokit.rest.users.getAuthenticated();
const tokenScopes = data.token_scopes?.split(', ') ?? [];
const hasUnneeded = tokenScopes.some(s => !REQUIRED_SCOPES.includes(s));
if (hasUnneeded) {
console.warn('Token has excess scopes beyond minimum required:', tokenScopes);
}
const missing = REQUIRED_SCOPES.filter(s => !tokenScopes.includes(s));
if (missing.length) throw new Error(`Token missing required scopes: ${missing.join(', ')}`);
}
Tool schema argument permissiveness
A secondary Permissions finding is that several tool arguments accept free-text strings where structured values would be safer. Free-text fields are not exploited by permissions review, but they create injection surface and appear in the Permissions score as "argument permissiveness" findings.
// Before: free-text fields for values that have a finite known set
server.tool('createIssue', {
schema: {
title: { type: 'string' },
labels: { type: 'array', items: { type: 'string' } }, // any string
assignees: { type: 'array', items: { type: 'string' } } // any username
}
});
// After: use pattern constraints and maximum length limits
const ALLOWED_LABELS = ['bug', 'enhancement', 'question', 'security', 'documentation'];
server.tool('createIssue', {
schema: {
title: {
type: 'string',
minLength: 1,
maxLength: 200,
description: 'Issue title — max 200 chars'
},
labels: {
type: 'array',
items: { type: 'string', enum: ALLOWED_LABELS },
maxItems: 5
},
assignees: {
type: 'array',
items: { type: 'string', pattern: '^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$' },
maxItems: 3
}
}
});
Week 2 checklist
- Audit every token scope in setup docs and OAuth flow against the actual API calls made — reduce to minimum
- Add
maxLengthto everytype: 'string'argument that does not already have one - Replace free-text
type: 'string'withenumfor any argument with a known finite value set - Add
maxItemsto everytype: 'array'argument - Target: Permissions sub-score moves to A (88–95 range)
Week 3: Credentials hygiene
Credentials: stop logging tokens, eliminate hardcoded fallbacks
Target: Credentials sub-score from C (60) to A (90+). Typically 2–4 specific file changes.
The startup log line
The most common Credentials finding across all C-grade servers: a startup log that includes the token value. It is almost always written defensively by the developer ("I want to see the first few characters to confirm the right token was loaded") but ends up logging the full value, or a version that is easily deobfuscated.
// Dangerous: logging the token, even partially
console.log(`GitHub token loaded: ${process.env.GITHUB_TOKEN}`);
// Also dangerous: substring does not prevent leakage
console.log(`GitHub token loaded: ${process.env.GITHUB_TOKEN?.slice(0, 8)}...`);
// Also dangerous: any operation that makes the token appear in structured logs
logger.info({ githubToken: process.env.GITHUB_TOKEN }, 'Config loaded');
// Safe: log the presence and format, never the value
function validateToken(token) {
if (!token) throw new Error('GITHUB_TOKEN env var not set');
// GitHub tokens have a known format: ghp_ prefix for personal access tokens
if (!token.startsWith('ghp_') && !token.startsWith('github_pat_')) {
console.warn('GITHUB_TOKEN does not match expected GitHub token format');
}
console.log(`GITHUB_TOKEN present, format: ${token.startsWith('ghp_') ? 'classic PAT' : 'fine-grained PAT'}, length: ${token.length}`);
return token;
}
const GITHUB_TOKEN = validateToken(process.env.GITHUB_TOKEN);
Module-level credential storage
Storing the credential in a module-level constant means it persists for the lifetime of the process. This is not a security vulnerability per se (it is in memory regardless), but it creates a credential exposure risk in two specific scenarios: error reporting tools (Sentry, Datadog) that capture local variable state in exception traces, and debugging sessions where a developer inspects the process heap. The remediation is to ensure credentials are never passed as arguments to functions that might capture them in traces:
// Risky: credential in module-level variable can appear in Sentry traces
// if it is in scope when an exception is thrown
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
// Better: wrap access in a function — closures capture less state in traces
function getGithubToken() {
const t = process.env.GITHUB_TOKEN;
if (!t) throw new Error('GITHUB_TOKEN not set');
return t;
}
// In handler: obtain token just before use, not at module load
server.tool('createIssue', {
handler: async (args) => {
const octokit = new Octokit({ auth: getGithubToken() }); // resolved here, not at startup
return octokit.issues.create(args);
}
});
// Also: ensure your error handler redacts credentials before reporting
process.on('uncaughtException', (err) => {
// Scrub any token-shaped strings before forwarding to Sentry
const sanitized = err.message.replace(/gh[ps]_[A-Za-z0-9_]{36,}/g, '[REDACTED]');
errorReporter.captureException(new Error(sanitized));
});
Week 3 checklist
- Grep codebase for
process.env.GITHUB_TOKEN(or equivalent) — ensure none appear inconsole.log,logger.*, or interpolated strings that get logged - Search for any hardcoded token fallbacks:
process.env.GITHUB_TOKEN ?? 'ghp_test' patterns - Check git history for accidentally committed tokens:
git log -p | grep -E 'ghp_|github_pat_' - If any real token found in history: revoke it on GitHub immediately, rotate, use git-filter-repo to scrub history
- Add a pre-commit hook or CI check (gitleaks, trufflehog) to prevent future credential commits
- Target: Credentials sub-score moves to A (90+)
Week 4: Maintenance and dependency hygiene
Maintenance: dependencies, lock file, advisory feed
Target: Maintenance sub-score from C (65) to A (90+). Most changes are configuration and package.json.
Pin transitive dependencies
The most common Maintenance finding at C grade: package.json uses caret ranges (^1.2.3) without a committed lock file. Dependency pinning is the single highest-leverage maintenance fix because it eliminates the entire class of supply-chain attacks where a malicious npm package update is published to a version range you already depend on.
# Step 1: generate a lock file if missing
npm install # creates package-lock.json if absent
# Step 2: commit the lock file
git add package-lock.json
git commit -m "chore: add package-lock.json"
# Step 3: convert caret ranges to exact versions for direct dependencies
# (transitive deps are already pinned by the lock file)
npm install --save-exact @octokit/rest # removes ^ from package.json for this dep
# Step 4: audit current dependencies for known CVEs
npm audit # shows known vulnerabilities
# Fix any HIGH or CRITICAL severity advisories before moving on
npm audit fix # auto-fix safe updates
npm audit fix --force # break semver only if necessary; review changes first
Keep the repository active
The Maintenance sub-score also includes a signal for repository activity: last commit date, open issue count vs. closed, and whether the repository responds to security disclosure (a SECURITY.md file). These sound administrative but they are a genuine signal for whether the server will receive timely security patches when vulnerabilities are discovered in its dependencies or in MCP protocol itself.
# Add a SECURITY.md file at the repository root
cat > SECURITY.md <<'EOF'
# Security Policy
## Supported versions
| Version | Supported |
|---------|-----------|
| 1.x | ✅ |
| <1.0 | ❌ |
## Reporting a vulnerability
Please report security vulnerabilities to security@example.com.
Do not open a public GitHub issue for security vulnerabilities.
You will receive a response within 72 hours, and a fix within 14 days
for Critical and High severity findings.
EOF
git add SECURITY.md
git commit -m "chore: add security disclosure policy"
Add a runnable example to the README
Documentation findings at C grade are almost always the same: no runnable example showing how to install and invoke the server. A minimal example that actually works (not a placeholder command) is what the Documentation sub-score looks for:
# In README.md — add an installation and quick-start section
## Installation
```bash
npm install -g @yourname/github-mcp-server
```
## Quick start
```bash
export GITHUB_TOKEN=ghp_... # fine-grained PAT with issues:write + actions:write
github-mcp-server start
```
Connect in Claude Code:
```bash
claude mcp add github-mcp-server -- github-mcp-server start
```
## Available tools
| Tool | Description |
|------|-------------|
| `listIssues` | List open issues with optional label filter |
| `createComment` | Post a comment on an issue or PR |
| `dispatchWorkflow` | Trigger a GitHub Actions workflow |
Week 4 checklist
- Commit
package-lock.json(oryarn.lock/pnpm-lock.yaml) if missing - Run
npm audit— fix all HIGH and CRITICAL advisories - Add
SECURITY.mdwith a disclosure email and response time commitment - Add a runnable installation + quick-start example to README.md
- Close or respond to open GitHub issues older than 90 days (even a "not in scope" response counts)
- Target: Maintenance sub-score moves to A (88+)
The 30-day arc: expected progress
Day 0 — initial C-grade scan
Security D (48), Permissions D (52), Credentials C (60), Maintenance C (65), Compatibility B (80), Documentation B (75). Overall: C (60).
Day 7 — re-scan after week 1 (security fixes)
SSRF and injection findings resolved. Security moves to B (78). Overall moves to B (67). Permissions D still drags the overall below B+.
Day 14 — re-scan after week 2 (permissions)
Scope reduced to minimum. Permissions moves to A (91). Overall moves to B (76). Credentials C is now the next constraint.
Day 21 — re-scan after week 3 (credentials)
Token logging removed, hardcoded fallbacks eliminated. Credentials moves to A (92). Overall moves to A- (83). Only Maintenance C remains below B.
Day 30 — re-scan after week 4 (maintenance)
Lock file committed, CVEs fixed, SECURITY.md added. Maintenance moves to A (89). Overall: A (88). All six sub-scores at B or above; no F findings anywhere.
Getting from A to A+ (90+)
An A (85–92) versus an A+ (93+) is usually the difference between "fixed all the known issues" and "built security-first from the start." The gap closes over time with three practices:
- Re-scan on every release. Add SkillAudit to your GitHub Actions workflow as a required status check. A grade that passes at version 1.2.0 can regress at 1.3.0 if a new tool introduces SSRF or a dependency update introduces a CVE. The CI gate catches regression before merge rather than after deployment. See the security policy template for the exact GitHub Action configuration.
- Write new tools security-first. Before implementing a new tool, answer three questions: what is the worst argument value the LLM could generate? what is the minimum permission scope required? does this tool return any data that an adversarial prompt could use as a secondary injection payload? Writing down the answers before writing code costs five minutes and eliminates the most common findings.
- Subscribe to the MCP security feed. New vulnerability classes are discovered in the MCP ecosystem regularly. The 2026 MCP security ecosystem report documents patterns that did not exist in the 2025 protocol revision. Staying current means your re-scans do not surface new findings from evolving detection rules.
What happens at re-scan
When you re-scan after completing the remediation plan, the SkillAudit report will show each previously-flagged finding as "Resolved" with the specific commit that resolved it (inferred from the diff between the current scan and the previous one). Findings that were not yet in the previous scan but appear in the new one are marked "New" and require attention before the grade improves further.
The most common surprise at re-scan: a "new" finding that was actually always present in the code but was below the detection threshold in the previous scan because of a detector sensitivity change between scan versions. These are not regressions in your code — they are improvements in SkillAudit's detection capability. Treat them the same as any other finding: assess severity, fix Critical and High first, defer Low to the next sprint.
The full remediation cycle — initial scan, four weeks of fixes, re-scan — typically takes 25–35 hours of engineering work for a medium-complexity MCP server (five to fifteen tools, two to three upstream API integrations). The Security and Permissions weeks are the highest-leverage: fixing the two Critical findings in week 1 alone moves the overall grade from C to B in most servers. The full four-week plan gets to A in servers where the developer was writing reasonable but not security-aware code to begin with.
Run your initial scan at skillaudit.dev. Free tier covers three audits per month on public repositories and returns the full sub-score breakdown with every Critical and High finding annotated. The Pro plan adds unlimited scans, a GitHub Action status check, and the remediation hints that map each finding to the exact code pattern to fix it.