<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dishant Desle</title>
    <description>The latest articles on DEV Community by Dishant Desle (@dishant_desle).</description>
    <link>https://dev.to/dishant_desle</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201285%2F1c3782c1-c2d4-400a-afdd-659ad876538b.png</url>
      <title>DEV Community: Dishant Desle</title>
      <link>https://dev.to/dishant_desle</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dishant_desle"/>
    <language>en</language>
    <item>
      <title>The MCP Transport Layer Blind Spot</title>
      <dc:creator>Dishant Desle</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/dishant_desle/the-mcp-transport-layer-blind-spot-4f85</link>
      <guid>https://dev.to/dishant_desle/the-mcp-transport-layer-blind-spot-4f85</guid>
      <description>&lt;p&gt;&lt;em&gt;This was first published on &lt;a href="https://desledishant10.github.io/mcp-witness/docs/mcp-transport-layer-blind-spot" rel="noopener noreferrer"&gt;my site&lt;/a&gt; and is cross-posted here; the version on my site is canonical and the one I keep current. It is authorized research: I ran mcp-witness, an open-source scanner I built, across Python MCP servers published to PyPI, and reproduced the most serious finding only on infrastructure I own, under responsible disclosure with a 90-day embargo observed before anything was published. If you want the tool rather than the writeup, it is at &lt;a href="https://github.com/desledishant10/mcp-witness" rel="noopener noreferrer"&gt;github.com/desledishant10/mcp-witness&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Between May and June 2026 I disclosed six vulnerabilities across six PyPI-published Python MCP servers, in two distinct classes. &lt;strong&gt;Outbound SSRF&lt;/strong&gt; in URL-fetching tools (two packages: Anthropic's reference &lt;code&gt;mcp-server-fetch&lt;/code&gt; and the community-published &lt;code&gt;mcp-server-http-request&lt;/code&gt;) lets an agent coerced via prompt injection retrieve cloud-metadata-service credentials from any host where IMDS is reachable. &lt;strong&gt;Inbound DNS rebinding&lt;/strong&gt; on HTTP-transport MCP servers (four packages: &lt;code&gt;mcp-streamablehttp-proxy&lt;/code&gt;, &lt;code&gt;mcp-fetch-streamablehttp-server&lt;/code&gt;, &lt;code&gt;fastmcp-http&lt;/code&gt;, and &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt;) lets any web page the operator visits issue tool calls against their locally running MCP servers, with no agent prompt injection required. The worst case, &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt;, composes both: a browser-driven DNS rebind to reach the server, then an inherited pre-fix SSRF inside the wrapped fetch tool to exfiltrate IMDS credentials.&lt;/p&gt;

&lt;p&gt;The SSRF half landed a fix fast and then stalled. PR &lt;a href="https://github.com/modelcontextprotocol/servers/pull/4226" rel="noopener noreferrer"&gt;#4226&lt;/a&gt; by external contributor &lt;code&gt;@kgarg2468&lt;/code&gt; shipped within 10 days of the original disclosure, with a fix shape more defensive than the disclosure asked for (per-redirect validation closes the redirect-bypass class), and I independently verified it on 2026-05-22. But the maintainers closed the disclosure as "not planned," and as of my last check on 2026-09-10 that PR is still open and unmerged while vulnerable releases keep shipping to PyPI. The DNS-rebind half is worse: across four independently authored packages and three maintainer teams, zero fixes shipped. All four are unchanged on PyPI and still vulnerable at the disclosed versions as of 2026-09-10.&lt;/p&gt;

&lt;p&gt;Two caveats up front. (1) On Amazon Linux 2023 instances launched with default IMDSv2-Required metadata options, the SSRF half is mitigated at the host level: the package does not send the required token header, so IMDS responds 401. The bug is still real because IMDSv2 is not required on older AMIs, operators legitimately set it to Optional for compatibility, GCP and Azure metadata services have different default postures, and RFC 1918 internal services have no IMDSv2 equivalent. (2) DNS rebinding does not depend on the agent at all. That is the point. The browser is the attack surface.&lt;/p&gt;

&lt;p&gt;The takeaway is not "six buggy packages." It is "two architectural anti-patterns at the MCP transport layer, repeating across independently authored servers." Both reduce to the same shape: external constraint, missing in-package enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP is and why this surface exists
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol, adopted by Anthropic's Claude, OpenAI's ChatGPT, Cursor, Zed, and a growing list of AI hosts, lets agents discover and invoke tools defined by external MCP servers. A typical server exposes a list of tools, each with a name, a natural-language description, and a JSON Schema for arguments. The agent sees those, decides when to call them, and feeds the results back into the conversation.&lt;/p&gt;

&lt;p&gt;MCP servers come in two transport flavors. &lt;strong&gt;Stdio transport&lt;/strong&gt; is the original: the server runs as a subprocess of the agent host, communicating over stdin and stdout. &lt;strong&gt;HTTP transport&lt;/strong&gt;, including the newer streamable-HTTP and the older SSE variant, has the server running as a network service with the agent host as a client. Stdio servers face one security surface: what the agent asks them to do. HTTP servers face two: what the agent asks them to do, and what anything else on the network can ask them to do. The HTTP-transport servers in this post are vulnerable on the inbound side; the stdio fetch servers are vulnerable on the outbound side. Together they cover both halves of the transport boundary, each leaking in the way it is built to function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Class 1: outbound SSRF in URL-fetching tools
&lt;/h2&gt;

&lt;p&gt;For HTTP-fetch tools, the most common kind of MCP server by my PyPI survey, the implementation boils down to a single function: take a URL from the agent's arguments, fetch it, return the response. There is almost always a parameter named &lt;code&gt;url&lt;/code&gt; typed as &lt;code&gt;string&lt;/code&gt; with &lt;code&gt;format: uri&lt;/code&gt;. The server trusts the agent to be responsible. For most non-cloud deployments, that is fine. For cloud-hosted agent hosts, it is a credential-exfiltration primitive waiting to be activated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discovery: a static rule on the captured tools/list
&lt;/h3&gt;

&lt;p&gt;I built &lt;a href="https://github.com/desledishant10/mcp-witness" rel="noopener noreferrer"&gt;mcp-witness&lt;/a&gt; (formerly mcp-scan; renamed mid-survey to avoid collision with an established tool of the same name) as a security testing toolkit for MCP servers: a static analyzer, a dynamic harness, and a capability classifier. The static-analyzer rule &lt;strong&gt;MCP-S-009&lt;/strong&gt; flags URL-fetching tools that lack any apparent allowlist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the tool has a parameter named &lt;code&gt;url&lt;/code&gt; / &lt;code&gt;uri&lt;/code&gt; / &lt;code&gt;endpoint&lt;/code&gt; or with &lt;code&gt;format: uri&lt;/code&gt;, and&lt;/li&gt;
&lt;li&gt;no JSON Schema &lt;code&gt;pattern&lt;/code&gt; / &lt;code&gt;const&lt;/code&gt; / &lt;code&gt;enum&lt;/code&gt; constraint on that parameter, and&lt;/li&gt;
&lt;li&gt;no validation keywords in the tool description (&lt;code&gt;allowlist&lt;/code&gt;, &lt;code&gt;scheme is&lt;/code&gt;, &lt;code&gt;restricted to&lt;/code&gt;, and similar).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is a heuristic, necessary but not sufficient, and a high-precision "review this." Run against the captured &lt;code&gt;tools/list&lt;/code&gt; from &lt;code&gt;mcp-server-fetch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;mcp-witness-capture &lt;span class="nt"&gt;--server-cmd&lt;/span&gt; python &lt;span class="nt"&gt;--server-arg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;--server-arg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mcp_server_fetch &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/fetch.json
&lt;span class="nv"&gt;$ &lt;/span&gt;mcp-witness-analyze /tmp/fetch.json
&lt;span class="o"&gt;[&lt;/span&gt;HIGH] MCP-S-009  &amp;lt;captured&amp;gt;:0  fetch
    Tool has URL parameter&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; with no schema-level constraint and no
    validation keywords &lt;span class="k"&gt;in &lt;/span&gt;the description. Likely no scheme allowlist or
    host denylist. Verify against SSRF to link-local / loopback / cloud-
    metadata addresses.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same finding against &lt;code&gt;mcp-server-http-request&lt;/code&gt;, multiplied by five (one per HTTP method).&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic verification: not just a pattern, a path
&lt;/h3&gt;

&lt;p&gt;Static rules say "this might be vulnerable, look closer." For confidence you need a dynamic probe. &lt;strong&gt;MCP-D-003&lt;/strong&gt; connects to a running server and tries a battery of test URLs: the harness's own canary (proves arbitrary egress), AWS IMDSv1, the ECS task-role endpoint, GCP &lt;code&gt;metadata.google.internal&lt;/code&gt;, Azure IMDS, loopback ports, &lt;code&gt;file://&lt;/code&gt; URIs, and non-HTTP schemes (gopher, dict).&lt;/p&gt;

&lt;p&gt;Against &lt;code&gt;mcp-server-fetch&lt;/code&gt; on a dev machine, the result was interesting and misleading. The canary hit (it is a fetch tool, it fetched). The metadata addresses all failed with &lt;code&gt;"Failed to fetch robots.txt due to a connection issue"&lt;/code&gt;. The &lt;code&gt;file://&lt;/code&gt; URIs did the same. It looked like it had defenses.&lt;/p&gt;

&lt;p&gt;But the mechanism is telling. &lt;code&gt;mcp-server-fetch&lt;/code&gt; does a politeness check: it fetches &lt;code&gt;robots.txt&lt;/code&gt; first and proceeds to the requested URL only if robots.txt was reachable. On a dev machine, &lt;code&gt;169.254.169.254&lt;/code&gt; is not routable, so the robots.txt fetch fails at the TCP layer and &lt;code&gt;mcp-server-fetch&lt;/code&gt; bails. That is an accidental SSRF defense, not a deliberate one. It only works when the target host does not respond to TCP. On any cloud instance where the metadata service does respond to TCP, which is all of them, the robots.txt fetch succeeds with a 404 (IMDS has no robots.txt), and &lt;code&gt;mcp-server-fetch&lt;/code&gt; proceeds to fetch the real URL.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mcp-server-http-request&lt;/code&gt; does not even have this accidental defense. It skips robots.txt and just makes the request.&lt;/p&gt;

&lt;h3&gt;
  
  
  EC2 demonstration: real credentials
&lt;/h3&gt;

&lt;p&gt;My hypothesis was that on actual EC2 the failure mode would be different and credentials would come back. I launched a &lt;code&gt;t3.micro&lt;/code&gt; (Amazon Linux 2023, us-east-1), attached an IAM role with &lt;code&gt;AmazonEC2ReadOnlyAccess&lt;/code&gt;, set IMDSv2 to Optional (the default is Required, which itself mitigates this bug, as discussed below), installed &lt;code&gt;mcp-server-fetch&lt;/code&gt;, and asked it to fetch the IMDS credentials URL via an MCP client harness.&lt;/p&gt;

&lt;p&gt;Result, redacted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- calling fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/mcp-scan-ssrf-test-role/') ---

Contents of http://169.254.169.254/latest/meta-data/iam/security-credentials/mcp-scan-ssrf-test-role/:
{
  "Code" : "Success",
  "AccessKeyId" : "ASIA****",
  "SecretAccessKey" : "wJal****",
  "Token" : "AQoD****",
  "Expiration" : "2026-05-12T..."
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A working AWS IAM credential triplet, returned by an MCP tool, retrievable by anything that can convince an agent to call &lt;code&gt;fetch("http://169.254.169.254/...")&lt;/code&gt;. The role and instance were torn down within minutes of capture; the credentials expired with their normal lifetime. The full reproduction runbook is in the &lt;a href="https://github.com/desledishant10/mcp-witness/blob/main/docs/audit-runbook-ec2-ssrf-verification.md" rel="noopener noreferrer"&gt;mcp-witness repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;From an attacker's perspective the chain is: prompt injection, then the agent calls &lt;code&gt;fetch&lt;/code&gt; with the IMDS URL, then credentials are returned, then the attacker pivots to whatever the IAM role permits.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix that shipped fast and never merged
&lt;/h3&gt;

&lt;p&gt;The first half of this is the discovery-to-fix loop working as designed. Ten days after the coordinated disclosure was filed as &lt;a href="https://github.com/modelcontextprotocol/servers/issues/4143" rel="noopener noreferrer"&gt;modelcontextprotocol/servers#4143&lt;/a&gt;, external contributor &lt;code&gt;@kgarg2468&lt;/code&gt; opened PR &lt;a href="https://github.com/modelcontextprotocol/servers/pull/4226" rel="noopener noreferrer"&gt;#4226&lt;/a&gt;, &lt;em&gt;"fix(fetch): block private network URL fetches"&lt;/em&gt;, explicitly fixing the disclosed issue. The commit summary describes the fix as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validate that fetch URLs use the &lt;code&gt;http&lt;/code&gt; / &lt;code&gt;https&lt;/code&gt; scheme,&lt;/li&gt;
&lt;li&gt;resolve hostnames and reject targets that are not public IP addresses,&lt;/li&gt;
&lt;li&gt;block &lt;code&gt;localhost&lt;/code&gt;, private, loopback, link-local, and metadata-service targets,&lt;/li&gt;
&lt;li&gt;follow redirects manually so each redirected target is validated before the next request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last item, per-redirect validation, is a more rigorous defense than the original disclosure asked for. Without it, an attacker could host a public URL that 302-redirects to &lt;code&gt;http://169.254.169.254/...&lt;/code&gt;; the in-package scheme/host check would pass on the initial request and the metadata leak would still occur. Per-redirect validation closes that bypass.&lt;/p&gt;

&lt;p&gt;On 2026-05-22 I re-ran the same demo script that previously exfiltrated IAM credentials against the fix branch (&lt;code&gt;kgarg/harden-fetch-ssrf&lt;/code&gt;). Same request, different response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fetching private or non-public IP addresses is not allowed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refusal at the validation layer with a clean error message, no network attempt, and independent verification posted on the PR.&lt;/p&gt;

&lt;p&gt;The second half is where it stops resembling a clean loop. Issue #4143 was closed as "not planned" on 2026-07-30 and consolidated into an older, dormant umbrella issue, &lt;a href="https://github.com/modelcontextprotocol/servers/issues/3741" rel="noopener noreferrer"&gt;#3741&lt;/a&gt;. As of 2026-09-10, PR #4226 is still open and unmerged: CI green, one external technical-audit review, but no approving review from anyone with write access, which the repository requires to merge, and stale since 2026-06-21. Three releases shipped after the disclosure (v2026.6.4, v2026.7.10, v2026.8.18); the current latest still runs the unguarded fetch path, runtime-verified via the &lt;code&gt;poc/ssrf/&lt;/code&gt; harness on 2026-09-10. Coordinated-disclosure obligations were satisfied and the 90-day embargo was observed, so I do not read intent into the routing. The outcome is still that a verified, low-risk, self-contained fix for a credential-exfiltration primitive in a reference server has sat unmerged for weeks while releases shipped without it. The full arc of that one package is its own piece: &lt;a href="https://desledishant10.github.io/mcp-witness/docs/ssrf-mcp-server-fetch" rel="noopener noreferrer"&gt;I found an SSRF in Anthropic's reference MCP server&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The community companion package &lt;code&gt;mcp-server-http-request&lt;/code&gt; v0.1.0 (published by the startup statespace) has the identical class and no accidental robots.txt defense at all. I disclosed it by email the same day, since it had no public issue tracker. After thirty days of silence, cofounder Gavin Chan replied on LinkedIn on 2026-06-11: &lt;em&gt;"That's not an actively maintained package."&lt;/em&gt; I logged it as maintainer-confirmed-unmaintained. No fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Class 2: inbound DNS rebinding on HTTP-transport MCP servers
&lt;/h2&gt;

&lt;p&gt;The SSRF half is about the server reaching out. The DNS-rebind half is about the browser reaching in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why HTTP-transport MCP servers are a different attack surface
&lt;/h3&gt;

&lt;p&gt;When an MCP server runs as an HTTP service, every request to it carries an &lt;code&gt;Origin&lt;/code&gt; header (or does not, if the request is from a non-browser client). A server intended for local-only use must validate that Origin against an allowlist; otherwise any web page the operator visits can issue requests to the server's endpoints. The web's standard defense against this is the Same-Origin Policy enforced by the browser, but SOP gates on the origin (scheme plus host plus port), and an attacker who controls a domain can defeat the host check via &lt;strong&gt;DNS rebinding&lt;/strong&gt;: serve a low-TTL DNS record pointing at an attacker-controlled IP initially, then flip the record to &lt;code&gt;127.0.0.1&lt;/code&gt; after the page loads. From the browser's perspective the page's origin has not changed; from the network's perspective the requests now hit localhost.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;localhost&lt;/code&gt; bind sounds defensively reasonable but is not enough on its own. Without server-side Origin/Host validation, the rebind primitive turns any browser tab into a tool-invocation path against locally running MCP servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discovery: S-014 and the W1 through W4 detector evolution
&lt;/h3&gt;

&lt;p&gt;Static-analyzer rule &lt;strong&gt;MCP-S-014&lt;/strong&gt; flags HTTP-transport MCP servers that bind to loopback or &lt;code&gt;0.0.0.0&lt;/code&gt; without Origin/Host validation. The original v0.1 detector was a string-literal pattern match plus a coarse "does the file mention Origin anywhere" suppression. That fired on none of the four disclosure targets; each of &lt;code&gt;mcp-streamablehttp-proxy&lt;/code&gt;, &lt;code&gt;mcp-fetch-streamablehttp-server&lt;/code&gt;, &lt;code&gt;fastmcp-http&lt;/code&gt;, and &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt; needed one of the W1 through W4 patches (or manual source review) to surface.&lt;/p&gt;

&lt;p&gt;The survey itself taught the detector. Four patches followed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;W1: host=variable resolution.&lt;/strong&gt; Patterns like &lt;code&gt;uvicorn.run(app, host=host, port=port)&lt;/code&gt; where &lt;code&gt;host&lt;/code&gt; is bound to &lt;code&gt;"0.0.0.0"&lt;/code&gt; earlier (via module-level assignment or a function-parameter default) need a tree-wide pre-pass to track string bindings. Necessary for &lt;code&gt;mcp-streamablehttp-proxy&lt;/code&gt; and &lt;code&gt;fastmcp-http&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;W2: AST-based Origin suppression.&lt;/strong&gt; Substring-matching &lt;code&gt;origin&lt;/code&gt; anywhere in the file silenced the rule even when the only &lt;code&gt;origin&lt;/code&gt; mention was inside an LLM prompt string or a wildcard CORS response header. Replaced with an AST walk that looks for actual request-header reads, &lt;code&gt;request.headers["Origin"]&lt;/code&gt; or &lt;code&gt;.headers.get("Origin", …)&lt;/code&gt;. Tightens precision substantially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;W3: aiohttp bind shapes.&lt;/strong&gt; &lt;code&gt;_SERVER_BIND_METHODS&lt;/code&gt; extended with &lt;code&gt;run_app&lt;/code&gt; (keyword-host pattern: &lt;code&gt;web.run_app(app, host="…")&lt;/code&gt;) and &lt;code&gt;TCPSite&lt;/code&gt; (positional-host pattern: &lt;code&gt;web.TCPSite(runner, "…", port)&lt;/code&gt;). Necessary for &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;W4: env-var defaults.&lt;/strong&gt; &lt;code&gt;host = os.getenv("HOST", "0.0.0.0")&lt;/code&gt;, common in production-shaped code where the env-var fallback is the deployed bind. &lt;code&gt;_extract_env_default&lt;/code&gt; resolves the literal second-arg default; &lt;code&gt;_collect_string_bindings&lt;/code&gt; calls it for &lt;code&gt;Assign&lt;/code&gt; nodes whose value is a &lt;code&gt;Call&lt;/code&gt;. Necessary for &lt;code&gt;mcp-fetch-streamablehttp-server&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the patches: four of four surveyed packages correctly flagged. The detector is now what it should have been from the start, and the survey work is what made it that way. The full walkthrough, each patch tied to the specific source file that motivated it, is its own piece: &lt;a href="https://desledishant10.github.io/mcp-witness/docs/detector-evolution-s014" rel="noopener noreferrer"&gt;Detector evolution: MCP-S-014&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four packages, one pattern
&lt;/h3&gt;

&lt;p&gt;All four targets exhibit the same architectural assumption: &lt;em&gt;"some external layer handles Origin / Host enforcement."&lt;/em&gt; For two of them (&lt;code&gt;atrawog/mcp-oauth-gateway&lt;/code&gt;'s &lt;code&gt;mcp-streamablehttp-proxy&lt;/code&gt; and &lt;code&gt;mcp-fetch-streamablehttp-server&lt;/code&gt;), the external layer is Traefik configured as part of the larger monorepo, and the in-package code has inline comments explicitly delegating to it. The problem is that the package is also distributed as a standalone PyPI release with its own console script, and the standalone path ships without Traefik. For &lt;code&gt;fastmcp-http&lt;/code&gt;, the external layer is implicit: Flask's dev server has no auth and no middleware, and operators are presumed to put a reverse proxy in front. For &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt;, it is a fork of an internal-use-only experiment whose threat-model docs never got updated.&lt;/p&gt;

&lt;p&gt;The worst case is &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt;: a DNS-rebind primitive on the HTTP+SSE transport combined with an inherited pre-fix SSRF in the wrapped fetch tool. The attack chain compounds. Attacker-controlled DNS rebinds to &lt;code&gt;127.0.0.1&lt;/code&gt;; page JavaScript establishes an SSE session; the page POSTs &lt;code&gt;tools/call&lt;/code&gt; with an IMDS URL to &lt;code&gt;/message?sessionId=&amp;lt;id&amp;gt;&lt;/code&gt;; the server accepts (no Origin/Host check) and forwards to the fetch tool; the fetch tool retrieves IMDS (no scheme/host check); credentials return in the response body. Browser-tab-triggered, no agent prompt injection required. The full inbound writeup, including the honest split between the server-side flaw and the harder live-rebind delivery, is &lt;a href="https://desledishant10.github.io/mcp-witness/docs/dns-rebinding-http-mcp-servers" rel="noopener noreferrer"&gt;One assumption, four packages&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disclosure outcomes
&lt;/h3&gt;

&lt;p&gt;The coordinated-disclosure embargo was 2026-08-10, and it has passed. I checked all four packages against GitHub and PyPI on 2026-09-10. All four are unchanged on PyPI since disclosure, with no fix releases, and all four remain vulnerable at the disclosed versions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp-streamablehttp-proxy&lt;/code&gt; v0.2.0 and &lt;code&gt;mcp-fetch-streamablehttp-server&lt;/code&gt; v0.2.0&lt;/strong&gt; (atrawog). Coordinated disclosure emailed to &lt;code&gt;atrawog@gmail.com&lt;/code&gt; on 2026-05-12, with a day +21 follow-up on 2026-06-02 and a third email at day +30. No response at any point. Silent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fastmcp-http&lt;/code&gt; v0.1.4&lt;/strong&gt; (ARadRareness). Disclosed via a public GitHub issue, &lt;a href="https://github.com/ARadRareness/mcp-registry/issues/3" rel="noopener noreferrer"&gt;&lt;code&gt;ARadRareness/mcp-registry#3&lt;/code&gt;&lt;/a&gt;, on 2026-06-02, as the channel of last resort after GHSA was disabled on the repo, the maintainer profile listed no contact, and PyPI showed only a GitHub-noreply email. The maintainer closed the issue as "completed" on 2026-06-21, with no comment, no commits since, and no fix release. Closed without a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp-server-fetch-sse&lt;/code&gt; v0.1.1&lt;/strong&gt; (PyPI maintainer-of-record &lt;code&gt;jadamson@anthropic.com&lt;/code&gt;). Disclosed by email on 2026-06-02, with a parallel courtesy notice to Anthropic Security, discussed next. The maintainer never replied.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The brand-attribution problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp-server-fetch-sse&lt;/code&gt; v0.1.1 lists &lt;code&gt;Author: Anthropic, PBC.&lt;/code&gt; and &lt;code&gt;Maintainer: Jack Adamson &amp;lt;jadamson@anthropic.com&amp;gt;&lt;/code&gt; in its wheel METADATA. Its README is lifted near-verbatim from Anthropic's official &lt;code&gt;mcp-server-fetch&lt;/code&gt;: same CAUTION block, same VS Code install badges pointing at the upstream package name. The wrapped fetch implementation looks like a fork of upstream code from before PR #4226 shipped the SSRF fix. From a downstream user's perspective the package presents as Anthropic-published; from outside, I cannot verify whether the attribution is active Anthropic maintenance or attribution inherited from a fork's &lt;code&gt;pyproject.toml&lt;/code&gt;. PyPI does not verify Author claims.&lt;/p&gt;

&lt;p&gt;My disclosure approach was a primary technical disclosure to the published maintainer-of-record at &lt;code&gt;jadamson@anthropic.com&lt;/code&gt;, with a parallel courtesy notice to Anthropic Security so they could route internally if appropriate. The parallel notice took two attempts. The HackerOne program listed in Anthropic's &lt;code&gt;.well-known/security.txt&lt;/code&gt; returned a triage interstitial keyword-filtering the report as the documented &lt;code&gt;mcp-server-fetch&lt;/code&gt; SSRF class, which it is not (separate package, different attack direction, plus a class of vulnerability the interstitial does not cover at all). The interstitial included a "submitting this report could impact your reputation points" warning, HackerOne's signal that the program will not engage with this report class, so I cancelled the submission and switched channels. The email to &lt;code&gt;disclosure@anthropic.com&lt;/code&gt; (the secondary contact published on Anthropic's responsible-disclosure-policy page) returned a no-reply auto-responder routing back to HackerOne or to specialized channels, none of which fit "third-party PyPI package brand attribution."&lt;/p&gt;

&lt;p&gt;Both deflections are corporate-intake-routing artifacts, not Anthropic's position on the disclosure. No human review was reached on the Anthropic side, and the maintainer never replied. The primary technical disclosure to the listed maintainer remains the binding channel for a fix; the parallel-notice piece is documented in the disclosure record for posterity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The class-wide observation: one ecosystem norm
&lt;/h2&gt;

&lt;p&gt;Two attack directions, four maintainer teams, six packages, and one architectural anti-pattern.&lt;/p&gt;

&lt;p&gt;In the SSRF half: HTTP-fetch tools model the &lt;code&gt;url&lt;/code&gt; parameter as an unconstrained string and trust the agent to be responsible. The threat model for MCP servers explicitly includes adversarially influenced tool arguments via prompt injection, so the trust assumption is wrong. The fix is small: a scheme allowlist, an IP-class denylist, per-redirect validation. Ten lines per package, or ten lines once in a shared utility.&lt;/p&gt;

&lt;p&gt;In the DNS-rebind half: HTTP-transport MCP servers defer Origin/Host enforcement to "some external layer," Traefik in some cases, an unnamed reverse proxy in others, the operator's good judgment in the rest. The standalone deployment path ships without that external layer. The fix is small: a Starlette &lt;code&gt;TrustedHostMiddleware&lt;/code&gt; for ASGI servers, an aiohttp middleware for aiohttp servers, a Flask &lt;code&gt;before_request&lt;/code&gt; hook for Flask servers, each validating &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;Origin&lt;/code&gt; against an allowlist. Five to ten lines per package.&lt;/p&gt;

&lt;p&gt;Both are the same shape: &lt;strong&gt;external constraint, missing in-package enforcement.&lt;/strong&gt; The author assumed someone upstream would handle security: the cloud platform via IMDSv2, the operator via robots.txt being unreachable, the reverse proxy via header rewriting, the agent via judgment. In each case the assumption holds for the deployment the author had in mind and fails for the deployment users actually run.&lt;/p&gt;

&lt;p&gt;The shared fix is to &lt;strong&gt;defend in-process&lt;/strong&gt; and treat external defenses as defense-in-depth rather than the primary boundary. The corollary is that the default &lt;code&gt;pip install&lt;/code&gt; plus console-script flow should be safe out of the box; users who know what they are doing can opt out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_fetch_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsupported scheme: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gethostbyname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_loopback&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_link_local&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_reserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refusing to fetch reserved address: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten lines. Per package, or once across the ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2026-05-11:&lt;/strong&gt; discovery via the S-009 static rule; dynamic verification of D-003 on a dev machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-05-12:&lt;/strong&gt; EC2 reproduction with real IAM credentials retrieved; coordinated disclosures filed: GitHub issue #4143 (&lt;code&gt;mcp-server-fetch&lt;/code&gt;), email to maintainers (&lt;code&gt;mcp-server-http-request&lt;/code&gt;), email to atrawog (&lt;code&gt;mcp-streamablehttp-proxy&lt;/code&gt; and &lt;code&gt;mcp-fetch-streamablehttp-server&lt;/code&gt;, combined).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-05-22:&lt;/strong&gt; PR #4226 (&lt;code&gt;@kgarg2468&lt;/code&gt;) shipped against &lt;code&gt;mcp-server-fetch&lt;/code&gt;; the same demo script that previously exfiltrated IAM credentials, re-run against the fix branch, returns &lt;code&gt;"Fetching private or non-public IP addresses is not allowed"&lt;/code&gt;; independent verification posted on the PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-02:&lt;/strong&gt; day +21 follow-up pings on the four still-silent maintainers; three new disclosures dispatched: &lt;code&gt;fastmcp-http&lt;/code&gt; (&lt;code&gt;ARadRareness/mcp-registry#3&lt;/code&gt;, public-issue channel of last resort), &lt;code&gt;mcp-server-fetch-sse&lt;/code&gt; (email to &lt;code&gt;jadamson@anthropic.com&lt;/code&gt;), parallel notice to &lt;code&gt;disclosure@anthropic.com&lt;/code&gt; after the HackerOne triage interstitial declined the report class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-11:&lt;/strong&gt; day +30 escalation checkpoint; &lt;code&gt;mcp-server-http-request&lt;/code&gt; confirmed maintainer-unmaintained via the statespace cofounder's LinkedIn reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-06-21:&lt;/strong&gt; &lt;code&gt;fastmcp-http&lt;/code&gt; issue closed as "completed" with no fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-07-30:&lt;/strong&gt; &lt;code&gt;mcp-server-fetch&lt;/code&gt; disclosure #4143 closed as "not planned" and consolidated into dormant umbrella #3741.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-08-10:&lt;/strong&gt; embargo expiration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-10:&lt;/strong&gt; post-embargo verification: PR #4226 still open and unmerged, &lt;code&gt;mcp-server-fetch&lt;/code&gt; latest (v2026.8.18) still vulnerable; all four DNS-rebind packages unchanged on PyPI and still vulnerable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2026-09-11:&lt;/strong&gt; CVE requests filed with MITRE for all six packages (IDs pending assignment).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the ecosystem should do
&lt;/h2&gt;

&lt;p&gt;For MCP server authors building anything that touches network egress, inbound HTTP transport, or any IO sink with cloud-environment relevance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not trust the URL parameter.&lt;/strong&gt; Whatever the agent passes in might be attacker-controlled even if the agent itself is honest. Validate scheme and host before issuing the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reject reserved IP ranges by default&lt;/strong&gt; on outbound calls: link-local (&lt;code&gt;169.254/16&lt;/code&gt;), loopback (&lt;code&gt;127/8&lt;/code&gt;, &lt;code&gt;::1&lt;/code&gt;), RFC 1918 private (&lt;code&gt;10/8&lt;/code&gt;, &lt;code&gt;172.16/12&lt;/code&gt;, &lt;code&gt;192.168/16&lt;/code&gt;), IPv6 ULA (&lt;code&gt;fc00::/7&lt;/code&gt;). Provide an opt-in env var for users who need internal access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For HTTP-transport servers, validate &lt;code&gt;Origin&lt;/code&gt; and &lt;code&gt;Host&lt;/code&gt; headers&lt;/strong&gt; in-process. The standalone &lt;code&gt;pip install&lt;/code&gt; plus console-script flow should be safe; the reverse-proxy-in-front deployment can disable in-process checks via an explicit env var.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-constrain when possible.&lt;/strong&gt; A &lt;code&gt;pattern&lt;/code&gt; or &lt;code&gt;enum&lt;/code&gt; on the URL parameter is the simplest defense, and it pushes constraints into the protocol surface where every downstream auditor can see them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not rely on host-level mitigations.&lt;/strong&gt; IMDSv2-Required is good, but the package should not assume the host has it enabled. A coding-assistant or browser-automation MCP host will not always be on AL2023.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For MCP host operators:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set IMDSv2 to Required&lt;/strong&gt; on every cloud instance running an MCP host. It is the single highest-leverage host-level mitigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your MCP server inventory.&lt;/strong&gt; Run &lt;a href="https://github.com/desledishant10/mcp-witness" rel="noopener noreferrer"&gt;mcp-witness&lt;/a&gt; (or an equivalent) against every server you have enabled and flag any with &lt;code&gt;MCP-S-009&lt;/code&gt; (outbound SSRF) or &lt;code&gt;MCP-S-014&lt;/code&gt; (inbound DNS rebind) findings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat MCP servers as untrusted code&lt;/strong&gt; that runs with the agent's effective network identity and the operator's effective browser-localhost identity. They are, by design, speaking to the model on your behalf.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the MCP spec itself:&lt;/p&gt;

&lt;p&gt;The spec does not currently mandate validation patterns for HTTP-class tools. Whether that becomes a normative requirement or a recommended-practice section is a spec-track question, but the survey here makes the case: the ecosystem cannot be relied on to enforce security at the package level when the spec implies it is the application's responsibility. A normative section ("HTTP-fetch tools MUST allowlist scheme and reject reserved IP ranges by default" / "HTTP-transport servers MUST validate Origin and Host headers before processing tool calls") would shift the default. The disclosure record here would have looked very different if these were spec-mandated invariants.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the tool
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/desledishant10/mcp-witness" rel="noopener noreferrer"&gt;mcp-witness&lt;/a&gt; is open source under Apache 2.0. It includes the static analyzer (15 rules, 278 tests), the dynamic harness (7 scenarios, two agent drivers: a deterministic stub and a real Claude tool-use loop), the capability classifier, a calibration corpus of 11 hand-labeled MCP servers, the EC2 reproduction runbook used for the SSRF verification, and the full audit trail of all six disclosures covered in this post, including the channel-decision records for the four that needed unusual disclosure paths. If you want to audit your own MCP servers, three commands get you started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;mcp-server-fetch          &lt;span class="c"&gt;# or any MCP server you want to audit&lt;/span&gt;
mcp-witness-capture &lt;span class="nt"&gt;--server-cmd&lt;/span&gt; python &lt;span class="nt"&gt;--server-arg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;--server-arg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mcp_server_fetch &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/tools.json
mcp-witness-analyze /tmp/tools.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runbook, all findings, and the disclosure record live in the repo; contributions are welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;A few follow-ups are worth their own pieces rather than another paragraph here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AST-based vs string-pattern detection: methodology trade-offs.&lt;/strong&gt; Triggered by a productive cross-tool discussion on the #4143 thread. Different methodologies have different blind spots, and the comparison is worth a thorough treatment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-language parity.&lt;/strong&gt; The analyzer now carries a tree-sitter TypeScript/JavaScript front-end that brings the source rules to TS/JS at parity with Python, so the same transport-layer checks run against TS servers. How the two front-ends share a rule model is its own write-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP-spec engagement.&lt;/strong&gt; Whether the security recommendation above gets traction is up to the spec maintainers and the broader community; the open question is where to surface the proposal, whether an issue thread, an RFC draft, or a working-group discussion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The MCP ecosystem is in the early-adoption phase that every new protocol goes through: fast growth, many small packages, security treated as a follow-up concern. Two vulnerability classes are documented at scale here, and almost certainly not the last. The same scanner is finding patterns in command-execution tools, file-handling tools, and tool descriptions that contain agent-directed instructions. Each pattern deserves a careful audit; some will produce more disclosures, some will turn out to be false positives. That is the work.&lt;/p&gt;

&lt;p&gt;The question for the ecosystem is whether it adopts secure defaults or learns by repeated incident. Coordinated disclosure with a reasonable embargo is one way to nudge it toward the former. Publishing the runbook, the rules, the scanner, and the methodology in public is another. Both are open for anyone reading this to take further.&lt;/p&gt;

&lt;p&gt;Survey conducted May–June 2026; disclosure outcomes verified 2026-09-10.&lt;/p&gt;

</description>
      <category>security</category>
      <category>mcp</category>
      <category>appsec</category>
      <category>ai</category>
    </item>
    <item>
      <title>I looked at 1,384 tools across 28 MCP servers. Here's what the attack surface actually looks like.</title>
      <dc:creator>Dishant Desle</dc:creator>
      <pubDate>Thu, 22 Oct 2026 23:04:48 +0000</pubDate>
      <link>https://dev.to/dishant_desle/i-looked-at-1384-tools-across-28-mcp-servers-heres-what-the-attack-surface-actually-looks-like-4il0</link>
      <guid>https://dev.to/dishant_desle/i-looked-at-1384-tools-across-28-mcp-servers-heres-what-the-attack-surface-actually-looks-like-4il0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Quick context if you're new to this: an &lt;strong&gt;MCP server&lt;/strong&gt; is a plug-in that gives an AI agent a set of &lt;strong&gt;tools&lt;/strong&gt; it can call, like "fetch this URL," "run this database query," or "send this email." There are hundreds of open-source MCP servers now. This writeup measures what those tools actually let an agent do, and how often the risky combinations sit side by side.&lt;/p&gt;

&lt;p&gt;Method note. The numbers are a &lt;strong&gt;snapshot taken on 2026-10-20&lt;/strong&gt; of 28 popular Python MCP servers (plus the reference-servers repo), produced with a &lt;a href="https://github.com/agentsploit/agentsploit/tree/main/examples/mcp-survey" rel="noopener noreferrer"&gt;reproducible harness&lt;/a&gt;. Upstream repos change over time, so re-running this later gives somewhat different counts. Everything below is read from &lt;strong&gt;public source code&lt;/strong&gt;. No server was connected to or executed. The capability labels are rough heuristics (see the caveats), and this writeup deliberately does &lt;strong&gt;not&lt;/strong&gt; call anything a vulnerability.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I kept running into MCP servers where one tool fetches a web page and another tool runs a command, both handed to the same agent, with nothing telling the model which one is dangerous. Instead of guessing how common that is, I decided to measure it.&lt;/p&gt;

&lt;p&gt;So I pulled the tool definitions straight from the source code of the most popular Python MCP servers on GitHub (AWS Labs, Google Workspace, Atlassian, Home Assistant, Snowflake, the Neo4j / Redis / Qdrant / Chroma / Milvus database servers, the Office / Excel document servers, Telegram, and more), and ran &lt;a href="https://github.com/agentsploit/agentsploit" rel="noopener noreferrer"&gt;AgentSploit&lt;/a&gt;'s inventory checks plus a simple source/sink classifier over them. That comes to &lt;strong&gt;1,384 tools across 28 servers.&lt;/strong&gt; The reproducible harness is at the bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: tool descriptions are already full of instructions to the model
&lt;/h2&gt;

&lt;p&gt;A tool's description is just text, and the AI reads it. The "tool poisoning" attack is built on that fact: if an attacker can slip instructions into a description, the model may follow them. (Think of it like hiding a note in a manual that the reader obeys without question.)&lt;/p&gt;

&lt;p&gt;What the survey shows is that &lt;strong&gt;normal, legitimate servers already write their descriptions as direct orders to the model.&lt;/strong&gt; These are real examples, and all of them are completely harmless:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Before using this tool, provide a 1-3 sentence explanation…" (AWS Labs)&lt;br&gt;
"CRITICAL: YOU MUST CALL inspect_doc_structure FIRST…" (Google Workspace)&lt;br&gt;
"🔴 PREREQUISITE: Before calling this tool, you MUST first call &lt;code&gt;…&lt;/code&gt;" (AWS Labs)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are good-faith usage notes. The problem is that, at the level of plain text, they look exactly like an injected &lt;code&gt;IGNORE ALL PREVIOUS INSTRUCTIONS&lt;/code&gt;. A pattern-based scanner (AgentSploit's included) flags both the same way. I went through all &lt;strong&gt;9&lt;/strong&gt; matches by hand, spread across &lt;strong&gt;4 independent vendors&lt;/strong&gt; (AWS, Google, Home Assistant, arXiv), and every single one was &lt;strong&gt;benign&lt;/strong&gt;. That is the real finding: there's no clean way to tell a harmless instruction-style description from a malicious one, because the whole ecosystem already uses that same channel on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the risky ingredients sit side by side, everywhere
&lt;/h2&gt;

&lt;p&gt;I sorted every tool into two buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;source&lt;/strong&gt; is a tool that pulls in outside content you don't control. In the data, that means it takes an argument like &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;uri&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, or &lt;code&gt;document&lt;/code&gt;, or its name looks like &lt;code&gt;fetch&lt;/code&gt; / &lt;code&gt;read&lt;/code&gt; / &lt;code&gt;search&lt;/code&gt; / &lt;code&gt;crawl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;sink&lt;/strong&gt; is a tool that does something with real impact. That means a name like &lt;code&gt;exec&lt;/code&gt; / &lt;code&gt;run&lt;/code&gt; / &lt;code&gt;query&lt;/code&gt; / &lt;code&gt;write&lt;/code&gt; / &lt;code&gt;send&lt;/code&gt; / &lt;code&gt;delete&lt;/code&gt;, or an argument like &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt;, or &lt;code&gt;code&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The danger isn't either bucket on its own. It's having both in front of the same agent, so that something the agent &lt;em&gt;read&lt;/em&gt; can flow into something the agent &lt;em&gt;does&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Across 1,384 tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;untrusted-content &lt;strong&gt;sources&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;239&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;high-impact &lt;strong&gt;sinks&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tools that are &lt;strong&gt;both&lt;/strong&gt; (they ingest &lt;em&gt;and&lt;/em&gt; act)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;59&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;servers that expose at least one source &lt;strong&gt;and&lt;/strong&gt; one sink in the same context&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 / 28&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 59 "both" tools are the sharpest edge. A single tool that pulls in a URL or a file and also runs a query or a command is a one-call bridge from untrusted input straight to a real action, if the content it pulls is attacker-controlled. For example &lt;code&gt;search_table(url)&lt;/code&gt; that also takes a &lt;code&gt;query&lt;/code&gt;, or &lt;code&gt;run_query(db_endpoint, sql)&lt;/code&gt;, or the Google Workspace and Office document tools that read a document and write it back. And 14 of the 28 servers put sources and sinks in the same agent context with nothing marking one as untrusted or the other as dangerous. (The harness prints the full list so you can check it yourself. A few are weak matches. For instance AWS IAM's &lt;code&gt;create_user&lt;/code&gt; trips the filter on an IAM &lt;code&gt;path&lt;/code&gt; argument, which isn't really outside content.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the sinks are wide open
&lt;/h2&gt;

&lt;p&gt;Plenty of the sink tools take a raw string with &lt;strong&gt;no &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;pattern&lt;/code&gt;, or &lt;code&gt;format&lt;/code&gt; constraint&lt;/strong&gt; on it, like &lt;code&gt;executeQuery(query)&lt;/code&gt;, &lt;code&gt;run_opencypher_query(query)&lt;/code&gt;, or &lt;code&gt;read_documentation(url)&lt;/code&gt;. (Those constraints are the schema's way of saying "only these specific values are allowed." Without them, the field accepts anything.) That's by design for a query tool. It's also exactly the kind of wide-open sink that turns "the agent read a poisoned page" into "the agent ran the attacker's SQL."&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not a list of vulnerabilities
&lt;/h2&gt;

&lt;p&gt;To be clear: none of the above is a bug in these servers. A source-to-sink chain only becomes a real exploit when two things both happen: untrusted input actually reaches a source, and the agent actually carries it over to a sink. Both of those depend on how the server gets deployed and wired together, not on the code itself. Proving a real chain needs a live agent, which is what AgentSploit's permission-graph mapper and canary runner are for. That's out of scope for a static survey like this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually helps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat third-party tool descriptions as untrusted input to your own system prompt, and keep them in an isolated context instead of pasting them in raw.&lt;/li&gt;
&lt;li&gt;Constrain what sink arguments accept (&lt;code&gt;enum&lt;/code&gt; / &lt;code&gt;pattern&lt;/code&gt; / &lt;code&gt;format&lt;/code&gt;) rather than taking any raw string.&lt;/li&gt;
&lt;li&gt;Don't put untrusted-content sources and high-impact sinks in the same agent's tool set without an isolation or confirmation step in between.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The quickest way to run this against a server yourself (shipped in v1.7):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;agentsploit
agentsploit survey ./path/to/mcp-server   &lt;span class="c"&gt;# a repo directory or a single .py file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or reproduce the full multi-repo survey with the harness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/agentsploit/agentsploit &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;agentsploit
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;examples/mcp-survey            &lt;span class="c"&gt;# edit servers.yaml to pick targets&lt;/span&gt;
python survey.py collect          &lt;span class="c"&gt;# read tool definitions from public source&lt;/span&gt;
python survey.py scan             &lt;span class="c"&gt;# run agentsploit's inventory checks&lt;/span&gt;
python survey.py aggregate        &lt;span class="c"&gt;# print the findings table&lt;/span&gt;
python survey.py surface          &lt;span class="c"&gt;# run the source/sink classification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The labels are heuristic. Sources are limited to outside-content signals (&lt;code&gt;url&lt;/code&gt; / &lt;code&gt;uri&lt;/code&gt; / &lt;code&gt;path&lt;/code&gt; / &lt;code&gt;document&lt;/code&gt; arguments, &lt;code&gt;fetch&lt;/code&gt; / &lt;code&gt;crawl&lt;/code&gt; / &lt;code&gt;scrape&lt;/code&gt; names), and the matching is token-based. It's still a guess at what a tool can do, not a traced data-flow. So read the counts as "could plausibly be," and trust the overall shape more than any single number. The shape is the sturdy part: most servers mix ingestion and action, and 59 tools do both in one call.&lt;/li&gt;
&lt;li&gt;Static reading misses some tools. It can't resolve Pydantic-schema arguments or cross-file enums, and it skips custom tool frameworks (serena, mcp-obsidian, and excel-mcp-server each extracted 0, because they register tools through their own bespoke class instead of the standard SDK). It also skips every non-Python server. So every tool count here is a floor, not a ceiling.&lt;/li&gt;
&lt;li&gt;Pattern-based checks flag harmless text too. I reviewed the matches by hand, so this writeup reports the reviewed conclusion, not the raw machine count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool is Apache-2.0: &lt;a href="https://github.com/agentsploit/agentsploit" rel="noopener noreferrer"&gt;github.com/agentsploit/agentsploit&lt;/a&gt;. Ideas and pull requests welcome.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>mcp</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
