DEV Community

Anup Karanjkar
Anup Karanjkar

Posted on • Originally published at wowhow.cloud

We Audited Our Own MCP Server Against 2026's 313 CVE Classes

TL;DR — We ran india-stack-mcp, the 536-line stdio MCP server we operate in production for GST validation, IFSC lookups, income-tax calculation, and public-holiday data, against the vulnerability classes defining 2026's MCP threat landscape. The MCP Security Project's public tracker indexes 313 CVEs across the MCP ecosystem as of today, the largest slice (131) command injection and the second largest (72) authentication and authorization gaps.[1] We checked our own server against four of those classes and got four different answers: one real, unmitigated credential leak at line 213; one class the server's own code structurally isn't exposed to, with a caveat one layer up; input validation that held cleanly on the two tools that build outbound URLs from arguments; and a live, unmitigated prompt-injection channel in a place static analysis tools don't usually look — third-party API fields riding unsanitized into an agent's context. Line numbers and the actual code are below.

The Server We Pointed the Audit At

india-stack-mcp is not a demo repo. It is a 536-line stdio MCP server we run to give Claude Code and Hermes five tools: validate_gstin, lookup_ifsc, calculate_income_tax, get_india_holidays, and get_gst_rates. Three of those tools call live third-party APIs — GSTINCheck for GST status, Razorpay's public IFSC database for bank branch data, and Nager.Date for public holidays. It is built on @modelcontextprotocol/sdk, wired up exactly like Pattern 1 of the Hermes Tool Gateway reference we published in May — a local child process talking JSON-RPC over its own stdin/stdout, nothing more:

const server = new McpServer({
  name: "india-stack-mcp",
  version: "1.0.0",
});

// ...

const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

That is the entire transport surface. No createServer, no .listen(), no bound port anywhere in the file — we grepped for all three. Whatever gets audited here is either a code-level bug in the tool handlers themselves, or a problem in how third-party data flows through them.

The 2026 CVE Landscape We Tested Against

The MCP Security Project's tracker organizes its 313 indexed CVEs under an OWASP-style MCP Top 10.[1] The distribution is lopsided: 131 entries under command injection and execution, 72 under insufficient authentication and authorization, 42 under privilege escalation via scope creep, and single digits for tool poisoning and audit/telemetry gaps. Prompt injection via contextual payloads — MCP06 — sits at only 7 of 313. That last number is the reason we went looking for it specifically: a class this small in a public tracker is not evidence it is rare, it is evidence it is hard to find with the kind of automated scanning that produces most CVE filings. Nobody's fuzzer flags "this JSON field contains a legal business name instead of an exploit string."

We picked four classes to check india-stack-mcp against: credential exposure (MCP01), the STDIO transport command-execution family (MCP05), tool-argument injection into outbound requests, and prompt injection via tool output (MCP06).

Finding 1: The API Key Lives in a URL, Not a Header

This is the one we already suspected before opening the file, and it held up. validate_gstin's optional live-lookup path builds the GSTINCheck request like this, at line 213:

const apiKey = process.env.GSTINCHECK_KEY;
if (!apiKey) {
  result.live_status = "SKIPPED — set GSTINCHECK_KEY env var for live lookup";
} else {
  try {
    const res = await fetch(`https://sheet.gstincheck.co.in/check/${apiKey}/${g}`);
    const data = await res.json();
Enter fullscreen mode Exit fullscreen mode

The API key is a path segment, not an Authorization header. That is GSTINCheck's own API design — we don't control their side of this — but it means the key rides in cleartext through anything that logs the request: reverse proxies, CDN access logs, any HTTP client tracing middleware layered in front of the call later, and the request line of any TLS-terminating box between us and sheet.gstincheck.co.in. This is CWE-598 in the classic sense, use of a GET request with sensitive data in the query/path, and it's exactly the shape of finding that lands in MCP01's 18 tracked CVEs.[1]

Two things narrow the blast radius without closing it. First, nothing in the 536 lines calls console.log anywhere — we checked every line, the key never hits our own stdout or a local log file. Second, the value substituted for g in that URL is already format-validated before this line runs (more on that below), so the injection surface via the GSTIN argument itself is closed. What's left is the key. We did not test whether a network failure's e.message leaks the constructed URL back into the tool's own response — that's worth a deliberate test with a forced DNS failure before we call this fully scoped, not something we're going to assert from a static read.

Finding 2: Do We Sit in the STDIO RCE Blast Radius?

In April 2026, security researchers disclosed roughly ten vulnerabilities rooted in unsafe defaults in how Anthropic's official MCP SDK handles the STDIO transport's configuration layer — present across the Python, TypeScript, Java, and Rust SDKs.[2] The core problem: the mechanism that turns an MCP client's config entry — a command plus args pair — into a spawned local process doesn't actually restrict that command to legitimate MCP server binaries. Given the right conditions it will run arbitrary OS commands, through both authenticated and unauthenticated paths, plus a hardening-bypass variant and a zero-click route where prompt injection edits an agent's own MCP configuration to add a malicious server entry — triggered just by an agent browsing a compromised MCP marketplace listing.

Here's the honest scoping question: is india-stack-mcp in that blast radius? No, and yes, depending on which side of the wire you mean.

No — server.js is the thing being spawned, not the thing doing the spawning. It never parses a command field, never calls spawn or exec, and never touches a config file describing other servers. The vulnerable code path the researchers describe lives in the client-side launcher — whatever host turns a mcpServers entry into a running process. That's Claude Desktop's, Claude Code's, or Hermes's code, not ours.

Yes — because the config entry that launches india-stack-mcp in the first place is exactly the shape of thing the April flaw targets. A clean server.js buys nothing if an attacker can tamper with the command/args pair that starts it — the zero-click path runs before our code ever executes. We can audit our own 536 lines all day; we can't audit our way out of a compromised launcher config from inside the server it launches. That's a config-integrity problem sitting one layer above the code we actually control.

One more thread worth pulling on the SDK itself: package.json pins @modelcontextprotocol/sdk at ^1.10.2, a version released April 22, 2025 — a full year before the April 2026 disclosure.[3] The caret range means npm doesn't actually install 1.10.2; checking node_modules shows the resolved version is 1.29.0, current as of today. That's almost certainly past whatever version shipped fixes for the April CVEs, but "almost certainly" isn't the same as confirmed against the SDK's own security advisories, which we haven't cross-checked version-by-version. And a loose caret floor that happens to resolve forward is not the same discipline as the exact-version pinning we argued for in the Hermes piece — where a single patch release silently changed list_directory's output format and broke a downstream parser. india-stack-mcp doesn't follow its own team's advice here.

Finding 3: Input Validation Actually Held

This is the boring finding, and boring is the goal. Two tools build outbound URLs directly from caller-supplied arguments: validate_gstin and lookup_ifsc. Both format-validate the argument with an anchored regex before it ever reaches a template string.

function validateGstinFormat(gstin) {
  const regex = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/;
  return regex.test(gstin.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

Fifteen characters, digits and uppercase letters only, anchored at both ends. Nothing that matches this pattern can carry a /, a ?, or a & into the GSTINCheck URL, which closes off path traversal and query-string injection via the gstin argument itself. lookup_ifsc does the same with /^[A-Z]{4}0[A-Z0-9]{6}$/ before hitting https://ifsc.razorpay.com/${code}, and get_india_holidays gets the same protection for free from its Zod schema — z.number().int().min(2024).max(2030) — before year lands in https://date.nager.at/api/v3/PublicHolidays/${year}/IN. A number bounded to a six-year integer range can't inject anything into a URL path.

Two arguments aren't format-restricted — state on get_india_holidays and category on get_gst_rates — but neither reaches a URL, a shell command, or a database query anywhere in the file. state gets echoed straight into result.state_context for display; category only ever gets compared against a static in-memory array with .includes(). The exposure there, if there is one, is an agent echoing back whatever the calling model already handed it — a much lower bar than the third-party data problem below, since it's the caller's own input coming back to the caller.

Finding 4: The Data We Never Touch After It Leaves gstincheck.co.in and Razorpay

This is the finding we didn't expect walking in, and it's the one worth taking seriously. validate_gstin's live-lookup branch takes these fields straight from GSTINCheck's JSON response and drops them into the tool's return value with zero transformation:

result.live_status = data.data?.sts || "Unknown";
result.legal_name = data.data?.lgnm;
result.trade_name = data.data?.tradeNam;
result.registration_date = data.data?.rgdt;
result.principal_address = data.data?.pradr?.addr;
Enter fullscreen mode Exit fullscreen mode

lookup_ifsc does the identical thing with Razorpay's IFSC database — BANK, BRANCH, ADDRESS, CITY, DISTRICT, STATE, CONTACT all pass through JSON.stringify(result, null, 2) unmodified. We grepped the whole 536 lines for anything resembling sanitization, escaping, or character stripping. There isn't one. Not a single function in this file touches a string field for safety before it goes into a tool response.

Here's why that matters more than it looks: trade_name is applicant-supplied free text at GST registration time, not a government-curated value like the holiday names Nager.Date returns. We did not test what character set GSTN's own registration portal permits in that field — that's out of scope for a server-side code audit — but we can say with certainty that nothing downstream of GSTN, not GSTINCheck's passthrough and not validate_gstin, re-validates or strips it before it lands in an agent's context window as a trusted-looking JSON field. That's the textbook shape of MCP06, prompt injection via contextual payloads — only 7 of the tracker's 313 CVEs, and now we understand why the count is that low. It doesn't crash anything. It doesn't 500. It just sits there as a business name until whatever's reading the tool output decides how much to trust it.

One mitigating factor worth stating plainly: everything in this server returns as { type: "text", text: JSON.stringify(...) }, never rendered markdown or HTML. That closes off an XSS-style rendering exploit against any UI that might display this output — the risk here is specifically an LLM context-injection channel, not a browser one.

The Scorecard

Class Verdict Where

| Credential exposure (MCP01) | Unmitigated | API key in GET URL path, line 213 |

| STDIO transport RCE (MCP05) | Not directly exposed | No spawn/exec/listen in server.js; risk sits in the launcher config one layer up |

| Tool-argument URL injection | Clean | Regex + Zod validation precedes every URL-building line |

| Prompt injection via tool output (MCP06) | Unmitigated | Third-party fields (legal_name, trade_name, address, bank/branch data) pass through unsanitized |

What Changes Next

Two of these four are real, live, and worth fixing rather than filing away. The credential-in-URL problem is bounded by GSTINCheck's own API shape — the fix on our side is confirming error paths never echo the constructed URL back to the caller, and treating any logging layer added in front of this fetch call as a secret-exposure risk by default. The tool-output sanitization gap is entirely ours to close: a basic allow-list pass on legal_name, trade_name, principal_address, and the Razorpay bank/branch fields before they leave the tool, or at minimum wrapping third-party string fields in an explicit "untrusted external data" marker so whatever reads this tool's output downstream knows not to treat a trade name as an instruction. The SDK version pin moves from a caret range to an exact version, which is the same discipline we already argued for in the Hermes reference and didn't apply to our own server until this audit forced the comparison.

None of this required guessing at what 2026's MCP CVE classes look like in the abstract — the OWASP MCP Top 10 breakdown on the tracker gave us the categories, and reading our own 536 lines against each one gave us the actual answer for this server, not a generic checklist answer. If you're running your own GST/IFSC tooling and want to poke at the same data these tools surface, the GSTIN Validator, IFSC Code Finder, and GST Rate Finder are the same lookups as standalone browser tools, and the JSON Formatter is the fastest way to eyeball a raw API response before deciding whether to trust a field in it.

Related WOWHOW guides: the Hermes Tool Gateway reference this server is wired into, the MCP 2026-07-28 auth RC breakdown for the authorization side of this same threat model, and the Least-Privilege MCP Governance Layer for the scope-and-allowlist controls that catch exactly the kind of unsanitized-output problem Finding 4 describes. The MCP Server Pack ships the version-pinning and circuit-breaker patterns referenced above already applied, included in Pro Vault.


Sources

  1. MCP CVE Project — 313 indexed CVEs, OWASP MCP Top 10 breakdown

  2. Anthropic MCP Design Vulnerability Exposes AI Agents to STDIO Command Execution — The Hacker News, April 2026

3. @modelcontextprotocol/sdk — npm registry, version history

Originally published at wowhow.cloud

Top comments (0)