{"record":{"id":"46cc91be1fccb724","repo":"windmill-labs/windmill","slug":"mcp-server-url-is-not-allowed","errorCode":null,"errorMessage":"MCP server URL is not allowed: {}","messagePattern":"MCP server URL is not allowed: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"backend/windmill-mcp/src/client/mod.rs","lineNumber":49,"sourceCode":"    available_tools: Vec<McpTool>,\n}\n\nimpl McpClient {\n    /// Create a new MCP client from a resource configuration.\n    ///\n    /// `token`, when present, is the already-resolved bearer token sent as an\n    /// `Authorization` header. It MUST be resolved by the caller through the\n    /// permissioned (RLS + audit) variable path — `from_resource` never reads\n    /// secrets itself, so a caller cannot trick it into decrypting a variable\n    /// they are not allowed to read.\n    pub async fn from_resource(resource: McpResource, token: Option<String>) -> Result<Self> {\n        // The resource URL is author-controlled and we send a (potentially\n        // secret) bearer token to it, so it must be validated against SSRF\n        // before we connect (e.g. cloud metadata endpoints, internal services).\n        let validated = windmill_common::ssrf::validate_mcp_server_url(&resource.url)\n            .await\n            .map_err(|e| {\n                anyhow::anyhow!(\n                    \"MCP server URL is not allowed: {}\",\n                    windmill_common::ssrf::mcp_ssrf_error_message(&e)\n                )\n            })?;\n\n        // Build custom reqwest client with headers if provided\n        let mut headers = HeaderMap::new();\n        if let Some(token) = token {\n            let token = token.trim();\n            if !token.is_empty() {\n                headers.insert(\n                    HeaderName::from_static(\"authorization\"),\n                    HeaderValue::from_str(format!(\"Bearer {}\", token).as_str())?,\n                );\n            }\n        }\n        if let Some(resource_headers) = &resource.headers {\n            for (key, value) in resource_headers {","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/windmill-labs/windmill/blob/e474e8803ce2ff5c2df09a58dab51d45f5c922ca/backend/windmill-mcp/src/client/mod.rs#L31-L67","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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)"],"exampleFix":"// before\n{\"url\": \"http://localhost:8080/mcp\"}\n// after\n{\"url\": \"https://mcp.example.com/mcp\"}","handlingStrategy":"validation","validationCode":"const url = new URL(resource.url);\nif (!['https:', 'http:'].includes(url.protocol)) throw new Error('MCP url must be http(s)');\nconst res = await fetch(`https://dns.google/resolve?name=${url.hostname}&type=A`);\nconst ips = (await res.json()).Answer?.map(a => a.data) ?? [];\nconst 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);\nif (ips.some(blocked)) throw new Error('MCP url resolves to a private/metadata IP');","typeGuard":"function isPublicHttpsUrl(raw) {\n  try { const u = new URL(raw); return (u.protocol === 'https:' || u.protocol === 'http:') && !['localhost','127.0.0.1'].includes(u.hostname); }\n  catch { return false; }\n}","tryCatchPattern":"try {\n  const client = McpClient::from_resource(resource).await;\n} catch (e) {\n  if (String(e).includes('MCP server URL is not allowed')) {\n    // surface the embedded SSRF reason; ask user for a public URL\n  }\n  throw e;\n}","preventionTips":["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"],"tags":["security","ssrf","mcp"],"backgroundTag":"ssrf-url-blocked","analyzedSha":"e474e8803ce2ff5c2df09a58dab51d45f5c922ca","analyzedAt":"2026-09-03T12:38:19.024Z","contentChangedAt":"2026-09-03T12:38:19.024Z","schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}