Skip to content

fix(core): harden MCP client transport setup - #1389

Open
zcxGGmu wants to merge 3 commits into
VoltAgent:mainfrom
zcxGGmu:fix/issue-1382-mcp-client-hardening
Open

fix(core): harden MCP client transport setup#1389
zcxGGmu wants to merge 3 commits into
VoltAgent:mainfrom
zcxGGmu:fix/issue-1382-mcp-client-hardening

Conversation

@zcxGGmu

@zcxGGmu zcxGGmu commented Aug 1, 2026

Copy link
Copy Markdown

Summary

  • Validates MCP HTTP/SSE/streamable HTTP server URLs before constructing transports.
  • Rejects unsupported URL protocols and direct local/private host targets.
  • Limits stdio server environment inheritance to a small safe allowlist plus explicitly configured env vars.
  • Adds regression tests for the transport-level hardening paths.

Notes

This addresses the transport setup and stdio environment portions of #1382. The response-injection concern is intentionally left separate because it needs an agreed policy for how VoltAgent should transform or annotate MCP tool output without breaking existing tool result semantics.

Test Plan

  • vitest run packages/core/src/mcp/client/index.spec.ts --config vitest.config.mts
  • pnpm --filter @voltagent/core typecheck
  • pnpm --filter @voltagent/core build
  • biome check packages/core/src/mcp/client/index.ts packages/core/src/mcp/client/index.spec.ts

Related to #1382


Summary by cubic

Hardened MCP client transport setup by validating server URLs and restricting stdio env inheritance. Prevents local/private (including IPv4‑mapped IPv6 and CGNAT) connections and avoids leaking parent process secrets (addresses #1382 transport and stdio env).

  • Bug Fixes
    • Only allow http/https URLs and apply the same checks to SSE and the SSE fallback.
    • Block localhost, private, link‑local, loopback, CGNAT, and IPv4‑mapped IPv6 hosts across HTTP/SSE/streamable HTTP (including dotted forms).
    • Limit stdio server env to a small allowlist (e.g., PATH, HOME) plus explicit vars; do not forward secrets by default.
    • Added regression tests for URL validation, private host detection, and env filtering.

Written for commit 1ed799f. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Security Enhancements
    • Improved MCP connection security by allowing only HTTP and HTTPS server URLs.
    • Blocked connections to localhost, private, loopback, link-local, and other reserved addresses.
    • Restricted environment variables passed to stdio connections while preserving approved overrides.
  • Bug Fixes
    • Applied consistent URL validation across supported MCP transport types.

Validate MCP HTTP/SSE URLs before constructing transports and block direct local/private targets.\n\nLimit stdio server environment inheritance to a small allowlist plus explicitly configured env vars so parent process secrets are not passed by default.\n\nAdds regression tests for the transport-level issues reported in VoltAgent#1382.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1ed799f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The MCP client now filters stdio environment variables and preserves explicit overrides. It validates MCP server URLs to allow HTTP(S) schemes and reject local, private, loopback, link-local, and reserved hosts across supported transports. Tests cover both controls.

Changes

MCP transport security

Layer / File(s) Summary
Environment and URL validation
packages/core/src/mcp/client/index.ts
The client builds a restricted stdio environment and validates URL protocols and host addresses.
Transport validation wiring
packages/core/src/mcp/client/index.ts
HTTP, SSE, streamable HTTP, SSE fallback, and stdio transports use the new validation and environment logic.
Security behavior tests
packages/core/src/mcp/client/index.spec.ts
Tests verify environment filtering, explicit overrides, unsupported protocol rejection, local and private host rejection, and public hostname acceptance.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant URLValidation
  participant MCPTransport
  MCPClient->>URLValidation: validate configured MCP server URL
  URLValidation-->>MCPClient: return approved URL
  MCPClient->>MCPTransport: construct transport with approved URL
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the MCP client transport hardening changes.
Description check ✅ Passed The description explains the changes, testing, issue context, and reviewer notes, but does not use all template headings or checklist items.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/core/src/mcp/client/index.ts (1)

92-113: 🚀 Performance & Scalability | 🔵 Trivial

IPv4 range list omits several reserved/non-routable blocks.

The allow-list of blocked ranges misses 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.18.0.0/15 (benchmarking), 198.51.100.0/24 and 203.0.113.0/24 (TEST-NET-2/3), and the multicast/reserved ranges 224.0.0.0/4 and 240.0.0.0/4. These are lower-risk than the core private ranges already covered, but they are still not legitimate public MCP server targets. See the consolidated recommendation below to replace this hand-rolled logic with a maintained IP-range library.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/mcp/client/index.ts` around lines 92 - 113, Update
isPrivateIPv4 to block the omitted non-routable and reserved IPv4 ranges,
including 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24,
203.0.113.0/24, 224.0.0.0/4, and 240.0.0.0/4; preferably replace the hand-rolled
range checks with a maintained IP-range library as recommended, while preserving
existing validation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/mcp/client/index.ts`:
- Around line 115-123: Update isPrivateIPv6 in
packages/core/src/mcp/client/index.ts (lines 115-123) to reject IPv4-mapped IPv6
addresses, including the ::ffff:0:0/96 range, while preserving existing private
IPv6 checks. Add regression cases in packages/core/src/mcp/client/index.spec.ts
(lines 200-216) for the two IPv4-mapped addresses and the 10.0.0.1, 192.168.1.1,
and 172.16.0.1 private IPv4 URLs, verifying each is rejected.
- Around line 83-90: Harden isLocalOrPrivateHost and the connection path so
DNS-resolved addresses are validated at connection time, preventing rebinding or
short-TTL DNS from bypassing private-host checks; use the existing
resolver/agent policy where available rather than relying only on the initial
URL hostname. Normalize hostnames by removing trailing dots before localhost and
IP comparisons, while preserving bracket handling and private IPv4/IPv6
detection.

---

Nitpick comments:
In `@packages/core/src/mcp/client/index.ts`:
- Around line 92-113: Update isPrivateIPv4 to block the omitted non-routable and
reserved IPv4 ranges, including 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15,
198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, and 240.0.0.0/4; preferably
replace the hand-rolled range checks with a maintained IP-range library as
recommended, while preserving existing validation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: faf06026-6653-416c-b7ab-a2b89c011101

📥 Commits

Reviewing files that changed from the base of the PR and between 3377f6d and f4b16f8.

📒 Files selected for processing (2)
  • packages/core/src/mcp/client/index.spec.ts
  • packages/core/src/mcp/client/index.ts

Comment on lines +83 to +90
function isLocalOrPrivateHost(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (!normalized || normalized === "localhost" || normalized.endsWith(".localhost")) {
return true;
}

return isPrivateIPv4(normalized) || isPrivateIPv6(normalized);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the WHATWG URL parser strip a trailing dot from non-numeric domain hostnames?

💡 Result:

No, the WHATWG URL parser does not strip a trailing dot from a domain hostname [1][2]. The parser preserves the trailing dot if it is present in the input [1][3]. In the WHATWG URL Standard, the hostname is parsed and maintained as a string, and explicit logic exists to handle the trailing dot separately when performing operations such as determining the public suffix or the registrable domain [4][3]. For example, when calculating these properties, the standard explicitly defines a "trailingDot" variable that captures the dot if it exists at the end of the host, allowing algorithms to account for it while treating the rest of the host according to established rules [4][3]. This behavior contrasts with some other specifications or implementations that may normalize hostnames by removing trailing dots [5][2]. Because the WHATWG parser keeps the trailing dot, it is possible for a URL with a trailing dot (e.g., example.com.) to be treated as distinct from one without it (e.g., example.com) in some contexts, such as string-based security or filtering mechanisms [5][2]. The standard itself notes that while certificate comparison requires a host equivalence check that ignores the trailing dot, the URL parser does not enforce such restrictions on the host component [4][3][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the target file and relevant implementation/imports.
if [ -f packages/core/src/mcp/client/index.ts ]; then
  echo "== outline =="
  ast-grep outline packages/core/src/mcp/client/index.ts --view expanded || true
  echo
  echo "== target lines =="
  sed -n '1,140p' packages/core/src/mcp/client/index.ts | nl -ba
else
  echo "target file not found"
  fd -a 'index.ts' . | sed -n '1,20p'
fi

echo
echo "== isLocalOrPrivateHost usages =="
rg -n "isLocalOrPrivateHost|privateIPv4|privateIPv6|isPrivateIPv" packages/core/src/mcp packages -S || true

echo
echo "== DNS resolution / connector usage mentions =="
rg -n "lookup|family|createServer|createClient|http\.|https\.|net\.|Agent|RequestInit|initTimeout|timeout|ssrf|mcp.*/client|serverTransport|ClientTransport" packages/core/src packages -S | sed -n '1,160p'

Repository: VoltAgent/voltagent

Length of output: 2525


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target lines =="
sed -n '1,130p' packages/core/src/mcp/client/index.ts

echo
echo "== connect and HTTP/SSE construction excerpts =="
sed -n '130,420p' packages/core/src/mcp/client/index.ts

echo
echo "== isLocalOrPrivateHost usages =="
rg -n "isLocalOrPrivateHost|privateIPv4|privateIPv6|isPrivateIPv" . -S || true

echo
echo "== DNS resolution / connector usage mentions =="
rg -n "lookup|family|createServer|createClient|http\.|https\.|net\.|Agent|RequestInit|initTimeout|timeout|ssrf|mcp.*/client|serverTransport|ClientTransport|fetch\\(" packages/core/src packages -S 2>/dev/null | sed -n '1,240p'

Repository: VoltAgent/voltagent

Length of output: 36592


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Node WHATWG URL hostname behavior =="
node - <<'JS'
for (const raw of ["http://example.com", "http://example.com.", "http://localhost", "http://localhost.", "http://[::1]", "http://[::1]."]) {
  const url = new URL(raw)
  console.log(`${raw} -> protocol=${url.protocol}; host=${url.host}; hostname=${url.hostname}; hostEndsWithDot=${url.host.endsWith(".")}; hostnameEndsWithDot=${url.hostname.endsWith(".")}`)
}
JS

echo
echo "== MCP package version / lockfiles =="
fd -a 'package.json' packages/core packages/sdk | while read -r f; do
  echo "-- $f"
  sed -n '1,140p' "$f"
done

echo
echo "== lockfile mentions of mcp sdk =="
rg -n "`@modelcontextprotocol/sdk`|modelcontextprotocol" package.json pnpm-lock.yaml yarn.lock packages -S 2>/dev/null | sed -n '1,220p'

Repository: VoltAgent/voltagent

Length of output: 1490


Guard DNS and trailing-dot variants before connecting.

isLocalOrPrivateHost() checks the URL hostname string once before transport construction. Add connection-time IP validation or a custom resolver/agent policy to close rebind/short-TTl DNS bypasses. Also normalize/truncate trailing dots on hostnames before comparison, because new URL("...://localhost.") preserves hostname as localhost..

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/mcp/client/index.ts` around lines 83 - 90, Harden
isLocalOrPrivateHost and the connection path so DNS-resolved addresses are
validated at connection time, preventing rebinding or short-TTL DNS from
bypassing private-host checks; use the existing resolver/agent policy where
available rather than relying only on the initial URL hostname. Normalize
hostnames by removing trailing dots before localhost and IP comparisons, while
preserving bracket handling and private IPv4/IPv6 detection.

Comment thread packages/core/src/mcp/client/index.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/mcp/client/index.ts
Comment thread packages/core/src/mcp/client/index.ts
@zcxGGmu

zcxGGmu commented Aug 1, 2026

Copy link
Copy Markdown
Author

Addressed the MCP URL hardening review feedback in e624db10a:

  • Normalizes trailing-dot hostnames before local/private host checks.
  • Rejects private IPv4-mapped IPv6 host forms such as ::ffff:127.0.0.1, ::ffff:10.0.0.1, and ::ffff:192.168.0.1.
  • Added regression coverage for the blocked URL forms.

Validation run:

  • vitest run src/mcp/client/index.spec.ts -t "should reject MCP server URLs that target local or private addresses" --reporter=verbose — passed
  • vitest run src/mcp/client/index.spec.ts --reporter=default — 41/41 passed
  • biome check --formatter-enabled=false src/mcp/client/index.ts src/mcp/client/index.spec.ts — passed
  • tsc --noEmit -p tsconfig.json — passed

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/mcp/client/index.ts Outdated
Comment thread packages/core/src/mcp/client/index.ts Outdated
Comment thread packages/core/src/mcp/client/index.spec.ts
@zcxGGmu

zcxGGmu commented Aug 2, 2026

Copy link
Copy Markdown
Author

Thanks for the review — I pushed 1ed799ff8 with additional MCP URL hardening and regression coverage.

Changes made:

  • Normalize literal hosts before private/local checks and keep trailing-dot forms blocked.
  • Limit IPv6 ULA/link-local checks to actual IPv6 literals, so public DNS names such as fc-public.example.com are not rejected just because they start with fc/fd.
  • Reject the full fe80::/10 link-local range (fe80 through febf).
  • Keep IPv4-mapped IPv6 private-address rejection covered, including metadata/link-local examples.
  • Expand regression coverage across http, sse, and streamable-http server types.

Validation:

  • pnpm vitest run src/mcp/client/index.spec.ts -t "should reject MCP server URLs that target local or private addresses|should allow public hostnames that start with private IPv6 prefixes" --typecheck — passed, 2 tests passed, no type errors.
  • pnpm run typecheck in packages/core — passed.
  • pnpm exec biome check packages/core/src/mcp/client/index.ts packages/core/src/mcp/client/index.spec.ts — passed.

Note: I did not add DNS rebind / connection-time resolver enforcement in this pass because the current MCP SDK transport construction used here does not expose a resolver/agent hook to pin or filter resolved addresses safely. This patch covers the deterministic literal-host bypasses without changing transport internals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants