windmill-labs/windmill · error
MCP server URL is not allowed: {}
Error message
MCP server URL is not allowed: {} What it means
Before connecting to an MCP server defined by a resource, the client validates the server URL with the SSRF validator (validate_mcp_server_url). Because a (potentially secret) bearer token is sent to the URL, URLs resolving to private/loopback/metadata addresses or otherwise disallowed targets are rejected and mapped to this message.
Source
Thrown at backend/windmill-mcp/src/client/mod.rs:49
available_tools: Vec<McpTool>,
}
impl McpClient {
/// Create a new MCP client from a resource configuration.
///
/// `token`, when present, is the already-resolved bearer token sent as an
/// `Authorization` header. It MUST be resolved by the caller through the
/// permissioned (RLS + audit) variable path — `from_resource` never reads
/// secrets itself, so a caller cannot trick it into decrypting a variable
/// they are not allowed to read.
pub async fn from_resource(resource: McpResource, token: Option<String>) -> Result<Self> {
// The resource URL is author-controlled and we send a (potentially
// secret) bearer token to it, so it must be validated against SSRF
// before we connect (e.g. cloud metadata endpoints, internal services).
let validated = windmill_common::ssrf::validate_mcp_server_url(&resource.url)
.await
.map_err(|e| {
anyhow::anyhow!(
"MCP server URL is not allowed: {}",
windmill_common::ssrf::mcp_ssrf_error_message(&e)
)
})?;
// Build custom reqwest client with headers if provided
let mut headers = HeaderMap::new();
if let Some(token) = token {
let token = token.trim();
if !token.is_empty() {
headers.insert(
HeaderName::from_static("authorization"),
HeaderValue::from_str(format!("Bearer {}", token).as_str())?,
);
}
}
if let Some(resource_headers) = &resource.headers {
for (key, value) in resource_headers {View on GitHub (pinned to e474e8803c)
Solutions
- Change the resource URL to a publicly reachable HTTPS endpoint
- If self-hosting, expose the MCP server via a public domain/TLS ingress instead of an internal address
- Check the embedded mcp_ssrf_error_message for the exact rejected reason (scheme, IP range, redirect)
Example fix
// before
{"url": "http://localhost:8080/mcp"}
// after
{"url": "https://mcp.example.com/mcp"} Defensive patterns
Strategy: validation
Validate before calling
const url = new URL(resource.url);
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('MCP url must be http(s)');
const res = await fetch(`https://dns.google/resolve?name=${url.hostname}&type=A`);
const ips = (await res.json()).Answer?.map(a => a.data) ?? [];
const blocked = ip => ip.startsWith('10.') || ip.startsWith('192.168.') || ip.startsWith('169.254.') || /^127\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
if (ips.some(blocked)) throw new Error('MCP url resolves to a private/metadata IP'); Type guard
function isPublicHttpsUrl(raw) {
try { const u = new URL(raw); return (u.protocol === 'https:' || u.protocol === 'http:') && !['localhost','127.0.0.1'].includes(u.hostname); }
catch { return false; }
} Try / catch
try {
const client = McpClient::from_resource(resource).await;
} catch (e) {
if (String(e).includes('MCP server URL is not allowed')) {
// surface the embedded SSRF reason; ask user for a public URL
}
throw e;
} Prevention
- Use public HTTPS endpoints for MCP resources
- Avoid localhost/internal IPs in resource URLs
- Read the embedded ssrf error message to learn why a URL was rejected
- Re-check DNS after infra changes — a hostname can start resolving into a blocked range
When it happens
Trigger: Creating an MCP resource whose url points at localhost, 169.254.169.254 (cloud metadata), an internal RFC1918 address, a non-http(s) scheme, or a hostname resolving to a blocked IP; self-hosted MCP servers on private networks in strict environments.
Common situations: Pointing an MCP resource at a dev server on localhost; a DNS record that now resolves to an internal IP; cloud deployments where internal ranges are hard-blocked.
Related errors
- bundle produced no javascript:\n${buildOutput}
- raw app path ${JSON.stringify(relPath)} escapes the app fold
- The tool list for ${path} changed while it was loading. Try
- A connection already exists at ${resourcePath}. Pick another
- Variable at path ${path} already exists. Delete it or pick a
AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03).
Data as JSON: /api/errors/46cc91be1fccb724.
Report an issue: GitHub.