Research · 2026-06-01

MCP server permission scope patterns — what the corpus shows

Over-broad OAuth scopes are the permissions axis's most prevalent finding — and the one with the highest blast radius. Our scan of 101 public MCP servers found 68% requesting org-wide API scopes when only repo-level access is needed. This post shows the exact code patterns driving that number, explains why it matters more than it looks in a static audit, and gives the scope-down implementation for each pattern that eliminates the finding without breaking functionality.

Why permissions scope is a force-multiplier, not a standalone risk

A permissions finding in a SkillAudit report is never graded in isolation. The permissions axis score feeds directly into how the engine weights other findings. An SSRF finding in a server with repo-scoped tokens gets a WARN. The same SSRF in a server with org-write tokens gets a HIGH — because the reachable blast radius is qualitatively different.

This is the blast-radius math in practice. GitHub's repo scope gives read and write access to code, issues, PRs, and commit statuses for the repositories in the token's install scope. GitHub's admin:org scope adds team membership management, org-level secrets, org-level Actions settings, and the ability to modify branch protection rules. An attacker who can exfiltrate a token with admin:org can:

None of these require any additional bug. A single credential exposure finding — a hardcoded token, a token echoed to a log, a token passed as a URL parameter — does all of this if the token carries org scope. The permissions finding and the credential finding together are not additive; they are multiplicative.

Pattern 1: OAuth app scope over-declaration

The most common over-broad scope pattern is an OAuth app manifest that requests org-level permissions as a default, often because the developer copied a working OAuth app configuration from a tutorial and never narrowed it:

// common pattern in MCP server OAuth setup — scope is over-broad
const auth = new Octokit({
  auth: process.env.GITHUB_TOKEN,
  // implied scopes if the token was generated with broad defaults:
  // repo, read:org, admin:org, workflow, write:packages
});

// The server only needs to list PRs and read diff content.
// repo:status and repo:read would be sufficient. admin:org is never needed.

The token itself carries the scope — MCP server code doesn't set scopes at runtime, it just uses whatever the token was issued with. But the server documentation (if any) and the installation flow define what scope the user is asked to grant. Most MCP servers that use GitHub OAuth apps use scope strings like repo,workflow or repo,admin:org in their installation instructions, because those strings came from a tutorial that targeted full-access integrations.

The fix is to replace the scope string with the minimum set required. For a read-only code analysis MCP:

// Instead of: repo,workflow,admin:org
// Use the minimum set your tools actually call:

// For read-only code analysis tools (list PRs, read files, read diff):
const REQUIRED_SCOPES = 'repo:status,repo:deployment,public_repo,read:repo_hook';
// Equivalent for GitHub Apps (preferred over OAuth apps for new integrations):
// contents: read, pull_requests: read, statuses: read

// For write operations on the target repo only (create PR, push comment):
const REQUIRED_SCOPES = 'public_repo';   // or: 'repo' for private repos only
// GitHub Apps equivalent: contents: write, pull_requests: write

GitHub Apps are the correct path for any MCP server that needs persistent GitHub access. Unlike OAuth apps, GitHub Apps can be scoped to specific repositories at install time — the user grants access to this repo, not all repos in my account. If your MCP server's installation flow asks users to generate a Personal Access Token with full repo scope, that is a permissions finding regardless of what the server does with it: the blast radius is the same whether the token is used narrowly or broadly.

Pattern 2: permanent tokens where ephemeral tokens are available

34% of corpus servers that use GitHub API access store permanent PATs (Personal Access Tokens) with no expiry. GitHub has offered fine-grained PATs with expiry dates since 2022 and the GitHub Apps device flow since 2021. Both eliminate the permanent-token problem entirely.

// BAD: permanent token from env var, no expiry, broad scope
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

// BETTER: GitHub Apps installation token (scoped to repos, short-lived)
// Installation tokens expire in 1 hour and are scoped to the specific
// repos the app was installed on — not the whole account.
import { App } from '@octokit/app';

const app = new App({
  appId: process.env.GITHUB_APP_ID,
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
});

// Get a short-lived installation token for a specific repo:
async function getInstallationOctokit(owner, repo) {
  const { data: installation } = await app.octokit.request(
    'GET /repos/{owner}/{repo}/installation',
    { owner, repo }
  );
  return app.getInstallationOctokit(installation.id);
  // Token: 1-hour TTL, scoped only to this installation's repos
}

The operational argument against this pattern is that it's more complex to set up. That argument is correct for a solo developer deploying once. It stops being correct the moment the MCP server is published to a marketplace and hundreds of users run it. A permanent PAT with org scope in an installed MCP is a persistent, ambient credential that survives user account rotations, org membership changes, and compromised developer workstations. A one-hour installation token expires before the breach is even discovered.

The SkillAudit permissions axis specifically checks for: PAT usage in process.env.GITHUB_TOKEN assignments, Authorization: token header construction with env vars, and Authorization: Bearer patterns without OAuth device-flow token refresh logic. Any of these without a corresponding expiry annotation gets a WARN or HIGH depending on whether org-scoped permissions are also detected in the same server.

Pattern 3: credentials in URL parameters

27% of corpus servers pass API credentials as URL query parameters — either in the requests the MCP server makes to external APIs, or in the tool arguments the server accepts from the LLM. Both are finding-generating patterns for different reasons.

// BAD: token as URL parameter — logged by proxies, Caddy, nginx, server access logs
const response = await fetch(
  `https://api.github.com/repos/${owner}/${repo}?access_token=${token}`,
  { method: 'GET' }
);
// GitHub deprecated this pattern in 2019. It still works for some APIs.
// The access token now appears in: Caddy access log, any HTTP proxy log,
// browser DevTools network tab, server-side access log, CDN edge logs.

// BAD: LLM-controlled token parameter — prompt injection can exfiltrate it
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { api_token, repo } = request.params.arguments;
  // api_token came from the LLM. If the LLM was prompt-injected via a
  // malicious README the user asked it to summarize, the injected prompt
  // can set api_token to an attacker-controlled collection endpoint.
  return await fetchRepoData(repo, api_token);
});

// CORRECT: Bearer token in Authorization header, from env var only
const response = await fetch(
  `https://api.github.com/repos/${owner}/${repo}`,
  {
    headers: {
      'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
      'Accept': 'application/vnd.github+json',
    }
  }
);

The second pattern — accepting a credential as a tool argument from the LLM — is a compound finding. It combines a permissions finding (the LLM should never control which credential is used) with a prompt injection vector (if an attacker can influence the LLM's tool arguments, they control which endpoint receives the credential). The mitigation is always the same: credentials come from server-side environment variables only, never from tool arguments. The LLM provides the data to operate on; the server provides the authorization to operate.

The ambient token problem

19% of corpus servers run in execution environments — typically GitHub Actions, Cloudflare Workers, or Fly.io deployments — where an ACTIONS_RUNTIME_TOKEN, a GITHUB_TOKEN, or a service account key is available in the environment by default. When these servers read process.env.GITHUB_TOKEN without checking what scope that token carries, they inherit whatever the execution environment's permissions happen to be.

In GitHub Actions, the default GITHUB_TOKEN has contents: write and pull_requests: write on the repository the workflow runs in. That is already broader than most MCP server tool sets require. But when an org admin has set permissions: write-all on the workflow, or when the runner uses an organization-level token for cross-repo operations, the ambient permissions can be dramatically broader than any single MCP tool needs.

The fix is a permissions audit at startup: on initialization, the server fetches its own token's scope via the GitHub /user or /meta endpoint and logs what it finds. If the scope includes anything beyond what the tool set requires, the server should warn (or, for strict deployments, refuse to start):

async function auditTokenScope() {
  const response = await fetch('https://api.github.com/user', {
    headers: { 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}` }
  });
  const scopes = response.headers.get('x-oauth-scopes') || '';
  const ALLOWED = new Set(['public_repo', 'read:user']);
  const grantedScopes = scopes.split(',').map(s => s.trim()).filter(Boolean);
  const excess = grantedScopes.filter(s => !ALLOWED.has(s));

  if (excess.length > 0) {
    console.error(
      `[skillaudit-gate] Token carries excess scopes: ${excess.join(', ')}.` +
      ` This server only requires: ${[...ALLOWED].join(', ')}.` +
      ` Consider regenerating the token with minimum scopes.`
    );
    if (process.env.STRICT_SCOPE_CHECK === '1') process.exit(1);
  }
}

This pattern does not fix the scope — only the token's issuer can do that. But it surfaces the problem at startup, before any tool is called, when the fix is still cheap. An MCP server that logs a scope warning on startup creates an audit trail that a security team can act on. An MCP server that silently uses an overpowered token never surfaces the finding.

What the SkillAudit permissions axis checks

The permissions axis in a SkillAudit report covers four checks, each with a separate finding:

Authors submitting to the public audits board who want a green permissions axis should treat these as a pre-submission checklist before running the scanner. All four checks have one-pass fixes — scope strings, token issuance method, header vs parameter placement, and tool schema field names. None require architectural changes.

The scope reduction is always available

The most common response to a permissions finding we see in author-side remediation is "I need that scope for feature X." In every case so far where we have investigated this claim, the feature in question worked correctly after scope reduction — it had just never been tested with minimum scope because minimum scope was never the starting point.

The reason this happens: most developers start with repo scope (or the GitHub Apps equivalent of full repository access) because it's the first scope that makes things work during development, then ship without narrowing it. The minimum-scope version works because GitHub's API is additive — most endpoints work with any scope that includes the resource type, not necessarily with full repo access.

The one exception: GitHub Apps that need cross-repo access on behalf of the installing org genuinely need org-level installation scope. This is architecturally correct behavior. What is not correct is using a personal token with admin:org to achieve the same result — personal tokens are not revocable at the repo level, do not surface in org audit logs under the correct actor, and persist after the developer leaves the org.

If your MCP server genuinely needs cross-repo access: use a GitHub App installation token (short-lived, repo-scoped by install manifest) rather than a personal token with org scope. The implementation is documented in the Pattern 2 section above. The blast radius drops from "full org takeover" to "the repos the app was installed on."

Further reading

Want to see your server's permissions axis score before submitting to a directory?

Run a free audit → Browse the corpus →