Engineering · 2026-06-01
The MCP server permissions checklist: 5 questions before you request org scope
The permissions axis is one of the six axes SkillAudit scores, and it is the one where the blast radius math is most unforgiving. A server that requests repo,admin:org and has an SSRF vulnerability doesn't have two separate problems — it has a single full-account-takeover problem. The two findings compound multiplicatively, not additively. This checklist gives you five questions to answer before you submit to the Anthropic Skills Directory, with code you can copy for each fix.
Our corpus scan of 101 MCP servers found that 68% request org-wide API scopes when only repo-level access is needed. That number was surprising even to us when we first ran it. The cause isn't malice — it's cargo-culting. Developers copy a permission snippet from a tutorial or README that was written to "just work," not to be minimal, and it propagates across the ecosystem.
The Anthropic Skills Directory's review process now checks scope declarations explicitly. If your server requests org-wide scopes and the reviewer can't find a handler that uses them, the server gets a WARN or a manual review hold. Fixing this before submission is worth an hour of your time.
The checklist
Personal Access Tokens (PATs) are credentials tied to a person, with a scope ceiling bounded by that person's account. GitHub Apps are principals unto themselves: their permissions are declared in the app manifest, granted by the installer at repo or org scope, and limited to exactly the repos the installer chose. For an MCP server that runs in multiple users' Claude environments, this distinction is load-bearing.
The practical difference: if a PAT leaks (via a log line, a prompt-injection tool response, or an SSRF exploit), the attacker inherits the full scope of that person's token — potentially full org admin on every repo they have access to. If a GitHub App installation token leaks, the attacker gets access to the specific repos the app was installed on, for at most one hour (GitHub App installation tokens expire).
// server.ts — PAT from environment
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
// scope of GITHUB_TOKEN is unbounded: whatever
// the user configured when they created the PAT
})
import { App } from '@octokit/app'
const app = new App({
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
})
// installationId from your server's config or a DB row
const octokit = await app.getInstallationOctokit(installationId)
// token expires in 1 hour, scoped to installed repos only
WARN SkillAudit flags PAT-style credentials in MCP servers with outbound HTTP calls. The finding is WARN (not HIGH) because the scope of the PAT depends on the user's configuration, but the review note explains the blast-radius math and links to the GitHub Apps migration path.
The most common pattern in the corpus: a server requests repo,admin:org,read:user,gist,workflow from a tutorial, then implements a single tool that only needs to read file contents from a public repo. The extra scopes sit dormant — but they're live credentials that an attacker can use.
The principle is the same as principle of least privilege in any other context, but MCP servers have a specific audit path: the Skills Directory review process can diff declared scopes against handler code. If your manifest claims admin:org but no handler ever calls an org-admin endpoint, reviewers can see that automatically.
// Copied from a "getting started" tutorial
// Your server only reads public file contents
const scope = 'repo,admin:org,read:user,gist,workflow'
// admin:org lets the token:
// - add/remove org members
// - delete the org
// - manage billing
// — none of which your tool uses
// Reading public file contents needs exactly:
const scope = 'public_repo'
// Reading private file contents needs:
const scope = 'repo:contents'
// Creating a PR on a repo you have write access to:
const scope = 'repo'
// (still narrower than repo + admin:org + workflow)
The audit heuristic: write down every GitHub API endpoint your handlers call, look up the minimum scope required for each in the GitHub Docs, take the union, and use exactly that. If the union is larger than you expected, you have a scope audit bug.
HIGH SkillAudit flags org-admin scope declaration on servers that have no org-admin handler code. This is a HIGH because the gap between declared and used scope is concrete: any credential-leaking path (SSRF, log line, prompt-injection) gives an attacker admin access they have no legitimate use for.
34% of MCP servers in the corpus that make GitHub API calls use PATs or long-lived OAuth tokens exclusively, even in contexts where GitHub App installation tokens (1-hour expiry, repo-scoped) would work. A permanent token that leaks via a log line is a standing invitation. An ephemeral token that leaks via a log line is a race condition the attacker probably loses.
GitHub App installation tokens are not the only option. Many APIs support short-lived token exchange: AWS STS, Google Cloud short-lived credentials via Workload Identity, HashiCorp Vault dynamic secrets. The pattern is the same: authenticate once with a long-lived principal (the App key, the Workload Identity binding), exchange for a short-lived scoped token at call time, never store the short-lived token.
import { App } from '@octokit/app'
const app = new App({
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
})
// Called at tool invocation time, not at server startup:
async function getOctokit(installationId: number) {
// GitHub returns a token valid for exactly 1 hour
// scoped to the repos the installation covers
const octokit = await app.getInstallationOctokit(installationId)
return octokit
}
// Compare to startup-time PAT:
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
// — this token lives in memory forever, logged on startup by many frameworks
WARN SkillAudit flags long-lived token usage patterns when the API being called supports ephemeral token exchange. The finding includes a reference implementation using the appropriate SDK for the specific API.
27% of corpus servers pass credentials in URL query parameters on at least one outbound request. URLs with credentials in query strings appear in browser history, server access logs, CDN logs, Referer headers, and — most relevant to MCP — in the tool call response that the LLM echoes back to the user. A URL with a token in the query string is a credential leak waiting for a log rotation failure.
The fix is always the same: put the credential in an Authorization header, never in the URL. The second pattern to watch for is MCP-specific: if your tool accepts an argument that becomes part of a URL it fetches (even without a credential), verify that argument cannot itself include a credential in query-string form injected by a prompt-manipulated LLM.
// Token appears in URL — logged everywhere
const url = `https://api.example.com/data`
+ `?token=${process.env.API_TOKEN}`
+ `&query=${encodeURIComponent(input)}`
const res = await fetch(url)
// URL is in access logs, tool response, LLM context
// Token in Authorization header — not logged by default
const url = new URL('https://api.example.com/data')
url.searchParams.set('query', input) // only non-secret params
const res = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
},
})
The env-var source rule is equally important: never accept a credential as a tool argument from the LLM. Credentials must come exclusively from environment variables, a secrets manager, or a configuration file loaded at startup. If your tool schema has a parameter named token, api_key, secret, or similar, that is a HIGH finding regardless of how it's used.
HIGH SkillAudit flags credential-in-URL patterns as HIGH on the credentials axis and on the permissions axis when the URL is constructed from LLM-controlled arguments.
19% of corpus servers let the LLM influence credential selection, either directly (a tool argument maps to a credential key) or indirectly (an argument maps to a user ID that determines which stored credential is loaded). This is the ambient token problem: the LLM's ability to control execution should stop at the tool boundary. The moment the LLM can steer which credential a tool uses, a prompt-injected attacker gains lateral movement across all credential sets the server holds.
The pattern is subtle. It often doesn't look like "here is an argument named token." It looks like:
// WRONG — LLM-controlled account ID selects the credential
async function postComment({ accountId, repoPath, body }) {
// accountId comes from the LLM's tool call arguments
const token = credentialStore.get(accountId) // ← LLM picks which token to use
const octokit = new Octokit({ auth: token })
await octokit.rest.issues.createComment({ ... })
}
// CORRECT — credential is fixed at session/startup time, not per-call
// The session's credential is bound when the user authenticates, not per tool call
async function postComment({ repoPath, body }, session) {
// session.octokit is established at OAuth flow time
// The LLM cannot change which account's token is used
await session.octokit.rest.issues.createComment({ ... })
}
The more dangerous variant is when a tool accepts a URL as an argument and your handler fetches that URL with an Authorization header. The LLM can supply an attacker-controlled URL, causing your server to send the user's credential to an external host — a Server-Side Request Forgery + credential exfiltration combo. The check: any tool that fetches an LLM-supplied URL must strip Authorization headers when the destination host is not on an allowlist.
const ALLOWED_HOSTS = new Set(['api.github.com', 'raw.githubusercontent.com'])
async function fetchResource({ url }, session) {
const parsed = new URL(url)
// Reject non-allowlisted destinations before adding credentials
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
throw new Error(`Host ${parsed.hostname} is not on the allowed list`)
}
// Only add auth header after allowlist check passes
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${session.token}` },
})
return res.text()
}
HIGH SkillAudit flags LLM-controlled credential selection as HIGH on both the security axis (SSRF vector) and the permissions axis (lateral movement). This is one of the highest-severity combined findings in the current corpus.
What the permissions axis score means
The permissions axis is one of six in the SkillAudit report. A GREEN (A or B) on this axis means all five checklist questions above have passing answers: GitHub App or equivalent, minimal scope declaration, ephemeral token preference, no credentials in URLs, no LLM-controlled credential selection.
Here is how the findings map to axis scores:
| Finding | Severity | Axis impact |
|---|---|---|
| Org-admin scope declared, no org-admin handler | HIGH | Permissions axis score drops to D or F |
| PAT-style credential with broad scope | WARN | Permissions axis drops to C; combined with SSRF drops to F |
| Long-lived token where ephemeral available | WARN | Permissions axis loses one grade tier |
| Credential in URL query parameter | HIGH | Credentials axis HIGH + permissions axis WARN |
| LLM-controlled credential selection | HIGH | Both Security and Permissions axes drop to F |
The Anthropic Skills Directory review path
The directory's review checklist has grown significantly since the first public version. The permissions section now includes automated scope-vs-handler diffing: if your declared OAuth scopes cover endpoints that no handler in your codebase calls, that gap is flagged automatically. Reviewers then make a judgment call on whether it's a copy-paste artifact (WARN) or an intentional over-claim (rejection).
Running a SkillAudit scan before submission gives you the same information the reviewer will see — plus remediation suggestions for each finding. Authors who run a scan before submission consistently have fewer review iterations than those who don't.
Five-minute scan before you submit
Paste your GitHub repository URL into the audit form. The free plan covers public repos, gives you the full permissions axis findings, and generates the badge you'll want to embed in your README before the Skills Directory link goes live.
The permissions axis findings are the fastest to fix: they're almost always a one-line scope change, a token-type swap, or a URL construction refactor. The scan report gives you the exact line numbers and the exact code change for each.
See also:
- MCP server permission scope patterns — what the corpus shows — the research post this checklist is based on, with corpus statistics
- The MCP server security checklist — the full 12-point checklist covering all six audit axes
- GitHub Action gate: enforcing MCP security grades in CI/CD — how to gate CI on a minimum permissions axis score
- Audit methodology — how SkillAudit scores the permissions axis
Ready to check your permissions score?
Paste your GitHub URL and get the full permissions axis report in under 60 seconds.
Run a free audit → How grading works →