Guide · Security Scanning

MCP Tools for Snyk — REST API, vulnerability severity, and fix prioritization

Snyk exposes two distinct interfaces for programmatic vulnerability scanning: the /v1/test/ endpoints for one-shot package scans that return results without storing them, and the project-based /v1/org/{orgId}/project/{projectId}/issues endpoints for querying the historical state of monitored dependencies. When you build an MCP tool that checks for vulnerabilities — scanning a package.json for CVEs, finding upgradable dependencies, or reporting critical issues in a monitored project — three distinctions determine correctness: test mode vs monitor mode (one stores nothing, one creates a Snyk project), severity vs upgradability (a HIGH severity vuln with no fix is lower priority than a MEDIUM severity vuln that is upgradable right now), and the from[] dependency chain (the path matters — a critical vuln buried in a devDependency transitive chain is lower risk than the same vuln as a direct production dependency).

TL;DR

Use POST /v1/test/npm with the raw package.json and lock file contents for one-shot scans that leave no trace in the Snyk dashboard. Parse each vulnerability's isUpgradable and isPatchable flags before acting on severity — a CRITICAL vuln with no fix path is less immediately actionable than a MEDIUM vuln with an upgrade path. Always deduplicate by id before presenting results: the same CVE appears once per dependency chain path, not once per CVE. Retrieve your org ID dynamically via GET /v1/orgs/ rather than hardcoding it.

Authentication and organization lookup

Every Snyk v1 API request uses a bearer token in the Authorization header. The token is a UUID-formatted string found in the Snyk account settings UI or via snyk config get api after CLI authentication. Service accounts — created under Settings > Service accounts — are the correct credential type for automated tools: they are not tied to a human user's seat and can be given least-privilege org-level access.

The organization ID is required for almost every API call. Hardcoding it is fragile — the ID changes if you rename an org or if a user's workspace moves between groups. Instead, call GET /v1/orgs/ on startup to resolve the org ID dynamically from the org slug or display name. Snyk's default rate limit is 1,620 requests per minute across most endpoints, with burst tolerance, so a startup lookup does not meaningfully affect throughput.

import https from 'node:https';

class SnykClient {
  constructor(token) {
    this.token = token;
    this.baseUrl = 'https://api.snyk.io';
  }

  async request(method, path, body = null) {
    const url = new URL(path, this.baseUrl);
    const options = {
      method,
      headers: {
        'Authorization': `token ${this.token}`,
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    };

    return new Promise((resolve, reject) => {
      const req = https.request(url, options, (res) => {
        let data = '';
        res.on('data', (chunk) => { data += chunk; });
        res.on('end', () => {
          if (res.statusCode === 429) {
            const retryAfter = parseInt(res.headers['retry-after'] ?? '60', 10);
            reject(new Error(`Rate limited — retry after ${retryAfter}s`));
            return;
          }
          if (res.statusCode >= 400) {
            reject(new Error(`Snyk API error ${res.statusCode}: ${data}`));
            return;
          }
          resolve(JSON.parse(data));
        });
      });
      req.on('error', reject);
      if (body) req.write(JSON.stringify(body));
      req.end();
    });
  }

  async getOrgs() {
    const response = await this.request('GET', '/v1/orgs/');
    // response.orgs is an array of { id, name, slug, url, group }
    return response.orgs;
  }

  async getOrgBySlug(slug) {
    const orgs = await this.getOrgs();
    const org = orgs.find((o) => o.slug === slug || o.name === slug);
    if (!org) throw new Error(`Org '${slug}' not found in Snyk account`);
    return org;
  }
}

export { SnykClient };

One-shot test scans: POST /v1/test/npm

The /v1/test/npm endpoint accepts the raw contents of a package.json and optionally a package-lock.json, runs a vulnerability analysis, and returns results immediately without creating any project in the Snyk dashboard. This is the right endpoint for MCP tools that scan on-demand — the scan is ephemeral, stateless, and leaves no audit trail in Snyk. The body uses encoding: "plain" for UTF-8 string contents; the only alternative is "base64" if the file contains binary-unsafe characters, which is rare for JSON manifests.

Including the lock file significantly improves accuracy: without it, Snyk resolves transitive dependencies from the registry at scan time, which can differ from what is actually installed. With the lock file, Snyk knows the exact version of every transitive dependency and can find vulns in packages that don't appear in package.json at all. Always include the lock file when available.

For container images, the parallel endpoint is POST /v1/test/docker. Instead of file contents, you pass the image name and optionally the Dockerfile. Snyk uses the Dockerfile to distinguish base-image vulns (fixed by changing the base image) from application-layer vulns (fixed by updating your code). Without the Dockerfile, every vuln is attributed to the application layer.

async function testNpmPackage(client, orgId, packageJsonContent, lockfileContent) {
  const body = {
    encoding: 'plain',
    files: {
      target: {
        contents: packageJsonContent,
      },
    },
  };

  // Including the lockfile is strongly recommended — it eliminates
  // version resolution ambiguity for transitive dependencies
  if (lockfileContent) {
    body.files.additional = [{ contents: lockfileContent }];
  }

  // Results are returned inline and NOT stored in Snyk dashboard
  const result = await client.request(
    'POST',
    `/v1/test/npm?org=${orgId}`,
    body
  );

  // result.ok === false means vulnerabilities were found
  // result.issues.vulnerabilities — array of vuln objects
  // result.issues.licenses — array of license policy violations
  // result.dependencyCount — total number of deps scanned
  return {
    ok: result.ok,
    vulnerabilities: result.issues?.vulnerabilities ?? [],
    licenses: result.issues?.licenses ?? [],
    dependencyCount: result.dependencyCount,
    packageName: result.packageName,
    packageVersion: result.packageVersion,
  };
}

async function testDockerImage(client, orgId, imageName, dockerfileContent) {
  const body = { image: imageName };
  if (dockerfileContent) {
    body.dockerfile = { contents: dockerfileContent };
  }

  const result = await client.request(
    'POST',
    `/v1/test/docker?org=${orgId}`,
    body
  );
  return {
    ok: result.ok,
    vulnerabilities: result.issues?.vulnerabilities ?? [],
    dependencyCount: result.dependencyCount,
    docker: result.docker,
  };
}

Vulnerability object structure: severity, CVSS, CVE IDs

Each vulnerability object in the Snyk response contains the information you need to triage and prioritize it. The id field is Snyk's own identifier (e.g., SNYK-JS-LODASH-567746) and is stable across rescans. The identifiers.CVE array links to official CVE IDs — a single Snyk vuln can reference multiple CVEs if they describe the same issue in different contexts. The identifiers.CWE array provides weakness classifications.

The from[] array is one of Snyk's most useful fields and one that most tools ignore. It encodes the full dependency chain from your application root to the vulnerable package — for example, ["myapp@1.0.0", "express@4.18.0", "body-parser@1.20.0", "qs@6.11.0"]. This lets you distinguish a vulnerability reachable via a direct dependency from one buried five levels deep in a devDependency chain. The depth and path are meaningful for risk assessment: a vuln in a production dependency three levels deep is riskier than the same vuln in a devDependency used only during testing.

The upgradePath[] array tells you exactly what to upgrade. It mirrors the from[] array in length but contains the upgrade action at the position where the fix is available — for example, [false, "express@4.18.2", false, false] means upgrading the direct dependency express to 4.18.2 fixes the issue in all its transitive descendants.

// Example vulnerability object structure from Snyk API:
// {
//   id: 'SNYK-JS-LODASH-567746',
//   title: 'Prototype Pollution',
//   severity: 'high',           // critical | high | medium | low
//   cvssScore: 7.4,             // CVSS v3 decimal
//   CVSSv3: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N',
//   identifiers: {
//     CVE: ['CVE-2020-8203'],
//     CWE: ['CWE-400']
//   },
//   from: ['myapp@1.0.0', 'lodash@4.17.15'],
//   upgradePath: [false, 'lodash@4.17.21'],
//   isUpgradable: true,
//   isPatchable: false,
//   fixedIn: ['4.17.21'],
//   description: '...',
//   packageName: 'lodash',
//   version: '4.17.15',
// }

function classifyAndPrioritize(vulnerabilities) {
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };

  return vulnerabilities
    .map((vuln) => ({
      ...vuln,
      severityRank: SEVERITY_RANK[vuln.severity] ?? 0,
      // isDirect: the vulnerability is in a package your app declares directly
      isDirect: vuln.from.length === 2,
      // devOnly: check if any path segment looks like a devDependency
      // (Snyk doesn't flag this directly; you need to cross-reference package.json)
    }))
    .sort((a, b) => {
      // Primary: severity descending
      if (b.severityRank !== a.severityRank) return b.severityRank - a.severityRank;
      // Secondary: upgradable first (has a fix path)
      if (b.isUpgradable !== a.isUpgradable) return b.isUpgradable ? 1 : -1;
      // Tertiary: direct dependencies before transitive
      if (b.isDirect !== a.isDirect) return b.isDirect ? 1 : -1;
      // Quaternary: CVSS score descending
      return (b.cvssScore ?? 0) - (a.cvssScore ?? 0);
    });
}

Fix prioritization: upgradable vs patchable vs no-fix

Severity alone is a poor prioritization signal in practice. A CRITICAL severity vulnerability with no available fix — because the vulnerable package is unmaintained, or the fix requires a breaking major version upgrade — cannot be resolved by a developer today regardless of urgency. Meanwhile, a MEDIUM severity vulnerability that is upgradable with a single npm update invocation can be eliminated in minutes. An MCP tool that presents a flat list sorted only by severity gives developers an inaccurate picture of where to spend effort.

The correct prioritization model uses three dimensions: severity (risk if exploited), upgradability (can a developer fix it right now?), and patchability (can Snyk's own patch system fix it without an upgrade?). The isUpgradable flag means a new version of the direct dependency resolves the issue — Snyk has traced the upgradePath[] and confirmed the fix version exists and does not introduce new critical issues. The isPatchable flag means Snyk maintains a backport patch that modifies the vulnerable code in place, without requiring an upgrade — useful when the fix version introduces breaking API changes.

Deduplication is essential before building any fix report. The same vulnerability ID appears multiple times in Snyk results when the same package is reachable via multiple paths — for example, if both express and apollo-server depend on a vulnerable version of qs, you get two entries for the same SNYK-JS-QS-* vulnerability. Deduplicating by id and keeping the highest-priority path prevents inflating issue counts and confusing fix instructions.

function buildFixReport(vulnerabilities) {
  // Deduplicate by vuln ID, keeping the entry with the shortest from[] path
  // (shortest path = most direct = easiest to fix)
  const deduped = new Map();
  for (const vuln of vulnerabilities) {
    const existing = deduped.get(vuln.id);
    if (!existing || vuln.from.length < existing.from.length) {
      deduped.set(vuln.id, vuln);
    }
  }

  const unique = Array.from(deduped.values());

  // Priority tiers
  const priority1 = []; // CRITICAL/HIGH + upgradable — fix immediately
  const priority2 = []; // CRITICAL/HIGH + patchable — apply Snyk patch
  const priority3 = []; // CRITICAL/HIGH + no fix — accept risk or replace pkg
  const priority4 = []; // MEDIUM/LOW — schedule for next sprint

  for (const vuln of unique) {
    const isHighRisk = vuln.severity === 'critical' || vuln.severity === 'high';
    if (!isHighRisk) {
      priority4.push(vuln);
    } else if (vuln.isUpgradable) {
      priority1.push(vuln);
    } else if (vuln.isPatchable) {
      priority2.push(vuln);
    } else {
      priority3.push(vuln);
    }
  }

  return {
    summary: {
      total: unique.length,
      critical: unique.filter((v) => v.severity === 'critical').length,
      high: unique.filter((v) => v.severity === 'high').length,
      medium: unique.filter((v) => v.severity === 'medium').length,
      low: unique.filter((v) => v.severity === 'low').length,
      fixableNow: priority1.length + priority2.length,
    },
    tiers: {
      'P1 — upgrade now': priority1.map((v) => ({
        id: v.id,
        title: v.title,
        severity: v.severity,
        package: `${v.packageName}@${v.version}`,
        fixedIn: v.fixedIn,
        upgradeAction: v.upgradePath.find((p) => p !== false),
      })),
      'P2 — apply patch': priority2.map((v) => ({
        id: v.id,
        title: v.title,
        severity: v.severity,
        package: `${v.packageName}@${v.version}`,
      })),
      'P3 — no fix available': priority3.map((v) => ({
        id: v.id,
        title: v.title,
        severity: v.severity,
        package: `${v.packageName}@${v.version}`,
        cvssScore: v.cvssScore,
      })),
      'P4 — schedule': priority4.map((v) => ({
        id: v.id,
        title: v.title,
        severity: v.severity,
        package: `${v.packageName}@${v.version}`,
        isUpgradable: v.isUpgradable,
      })),
    },
  };
}

Monitoring projects via the Snyk REST v1 API

When a developer runs snyk monitor in a repository, Snyk creates a persistent project in the dashboard and begins tracking vulnerability state over time. These monitored projects are queryable via the project endpoints: listing all projects in an org, fetching current issues for a specific project, or triggering an on-demand re-test. MCP tools targeting monitored projects are useful for different scenarios than one-shot test scans — they can report trending data (is the vuln count going up or down?), check whether a previously reported vulnerability has been resolved, or surface issues that were found during CI but not during a developer's local check.

The project issues endpoint returns the same vulnerability structure as the test endpoint, but it reflects the state of the last completed scan of that project. For a project that is monitored via CI, the state updates on every CI run. For a manually monitored project, you can trigger a re-test via POST /v1/org/{orgId}/project/{projectId}/test to force a fresh scan against the current dependency state.

async function getProjects(client, orgId) {
  const response = await client.request('GET', `/v1/org/${orgId}/projects`);
  // response.projects: array of { id, name, created, origin, type, branch, ... }
  return response.projects ?? [];
}

async function getProjectVulnerabilities(client, orgId, projectId) {
  const response = await client.request(
    'GET',
    `/v1/org/${orgId}/project/${projectId}/issues`
  );
  // response.issues.vulnerabilities — current vuln state for this project
  // response.issues.licenses — current license violations
  // response.ok — false if any issues exist
  return {
    ok: response.ok,
    vulnerabilities: response.issues?.vulnerabilities ?? [],
    licenses: response.issues?.licenses ?? [],
    projectId,
  };
}

async function retestProject(client, orgId, projectId) {
  // Triggers a fresh scan; the new results will be visible via
  // getProjectVulnerabilities after the scan completes (usually seconds)
  return client.request(
    'POST',
    `/v1/org/${orgId}/project/${projectId}/test`
  );
}

// MCP tool handler: scan all projects in an org and return a summary
async function scanAllProjects(client, orgId) {
  const projects = await getProjects(client, orgId);
  const results = await Promise.all(
    projects.map(async (project) => {
      const issues = await getProjectVulnerabilities(client, orgId, project.id);
      return {
        projectName: project.name,
        projectId: project.id,
        origin: project.origin,
        ok: issues.ok,
        vulnCount: issues.vulnerabilities.length,
        criticalCount: issues.vulnerabilities.filter(
          (v) => v.severity === 'critical'
        ).length,
        highCount: issues.vulnerabilities.filter(
          (v) => v.severity === 'high'
        ).length,
      };
    })
  );
  return results.sort((a, b) => b.criticalCount - a.criticalCount);
}

Frequently asked questions

What is the difference between Snyk test mode and monitor mode?

Test mode (POST /v1/test/npm) performs an ephemeral scan: you send the manifest files, Snyk returns vulnerability results, and nothing is stored in the Snyk dashboard. No project is created, no history is tracked, and the scan does not appear in the Snyk UI. It is designed for one-off checks in MCP tools or CI steps where you want instant results without side effects. Monitor mode (snyk monitor CLI or the monitor REST endpoint) creates a persistent project in the Snyk dashboard, tracks vulnerability state over time, enables PR checks that block merges when new vulns are introduced, and participates in Snyk's notification system. Use test mode in MCP tools for ad-hoc scanning; use monitor mode for continuous tracking of production dependencies.

Why does the same CVE appear multiple times in Snyk results?

Snyk reports a vulnerability once per dependency path that leads to the vulnerable package. If your application has express and apollo-server, and both depend on a vulnerable version of qs, you will see two entries for the same SNYK-JS-QS-* vulnerability — one with from: ["myapp", "express", "qs"] and one with from: ["myapp", "apollo-server", "qs"]. The id field is identical in both entries. Always deduplicate by id before presenting results or counting vulnerabilities. When deduplicating, keep the entry with the shortest from[] path, as it represents the most direct fix path.

How do I scan a private npm package with Snyk's API?

Use the POST /v1/test/npm endpoint with the raw contents of package.json and package-lock.json in the request body. Snyk does not need to download private packages from the registry — it analyzes the dependency graph from the manifest files you provide. Private package names and versions are visible in the manifest files and are sent to Snyk's API in plaintext, so ensure your Snyk token has appropriate access controls. If your organization has a private registry, the lock file will contain private registry URLs; these are used by Snyk only to understand the dependency graph, not to download packages. For air-gapped environments, Snyk also offers an on-premises deployment where the API endpoint is your own infrastructure.

What does isPatchable mean vs isUpgradable in Snyk results?

isUpgradable means a newer version of the vulnerable package (or one of its ancestors in the dependency chain) is available in the npm registry and resolves the vulnerability. The upgradePath[] array tells you exactly which package to upgrade and to which version. This is the most straightforward fix: run npm update [package] or pin to the fixed version in package.json. isPatchable means Snyk maintains a custom backport patch — a code modification applied to the installed package files without changing the version — that neutralizes the vulnerability. Snyk patches are applied via snyk protect (now snyk fix --patch). Patches are useful when upgrading the package would introduce breaking API changes that require significant code changes, but they are not as reliable as upgrading: they must be re-applied after every npm install and may conflict with other patches. Not all vulnerabilities have patches; isPatchable is less commonly true than isUpgradable.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free