Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ node dist/index.js --transport http --port 3001

`--transport` and `--port` also accept env vars `TRANSPORT` and `PORT`.

`PROXY_MCP_UPSTREAM_PASSWORD` and `PROXY_MCP_UPSTREAM_HOST` keep an upstream
proxy password out of the transcript — see
[Keeping the upstream password out of the transcript](#keeping-the-upstream-password-out-of-the-transcript).

### Manual MCP configuration

The configured server alias controls Claude's generated tool prefix. The examples below use `proxy-mcp`, so Claude Code exposes tools as `mcp__proxy-mcp__<tool_name>`. If you rename the server key to `proxy`, use `mcp__proxy__<tool_name>` instead.
Expand Down Expand Up @@ -185,6 +189,122 @@ proxy_set_upstream --proxy_url "socks5://user:pass@upstream.example:1080"

Supported upstream URL schemes: `socks4://`, `socks5://`, `http://`, `https://`, `pac+http://`.

#### Keeping the upstream password out of the transcript

Tool calls and tool results are both persisted by the MCP client. To avoid
writing an upstream password there on every call, set it in the server's
environment and pass a URL with a username but no password.

Two variables are required, and both have to be in the environment of the
**server process**, which the MCP client spawns. Exporting them in the shell you
launch the client from may reach it — CLI clients pass their own environment
through — but that depends on the client and is lost the moment the server is
started any other way. Put them in the client's server config:

| variable | meaning |
|---|---|
| `PROXY_MCP_UPSTREAM_PASSWORD` | the password to fill in |
| `PROXY_MCP_UPSTREAM_HOST` | the only hostname it may be sent to — a bare hostname, no scheme, port or path |

```bash
claude mcp add proxy-mcp \
-e PROXY_MCP_UPSTREAM_PASSWORD=s3cret \
-e PROXY_MCP_UPSTREAM_HOST=upstream.example \
-- npx -y proxy-mcp@latest
```

```json
{
"mcpServers": {
"proxy-mcp": {
"command": "npx",
"args": ["-y", "proxy-mcp@latest"],
"env": {
"PROXY_MCP_UPSTREAM_PASSWORD": "s3cret",
"PROXY_MCP_UPSTREAM_HOST": "upstream.example"
}
}
}
}
```

Then omit the password from the call:

```bash
proxy_set_upstream --proxy_url "http://user@upstream.example:1080"
# routes as http://user:s3cret@upstream.example:1080
```

**Why the host variable exists.** Without it, a caller who cannot read the
password could still name any host and have the password delivered there — the
proxy sends it on the first request, and the transcript would show only `***`.
The hostname is matched case-insensitively and exactly, with no wildcards; the
port is not part of the match, so one variable covers a provider offering
several. A URL naming any other host is left alone. If
`PROXY_MCP_UPSTREAM_PASSWORD` is set and `PROXY_MCP_UPSTREAM_HOST` is not,
nothing is merged at all: a half-configuration fails closed rather than
becoming an unbound credential.

> **This keeps the password out of tool arguments and responses, not out of
> reach.** `interceptor_spawn` runs an arbitrary command as the server user, so
> a caller can read the client config file the password is configured in — and
> on Linux `/proc/<pid>/environ`. The variable removes the routine exposure of
> writing a credential into every tool call; it is not a sandbox, and anyone who
> can call `interceptor_spawn` should be treated as able to obtain the password.

The response reports which credential was used — `passwordSource` is `env`,
`url` or `none`. `proxy_mobile_setup` spells it `password_source`, matching the
snake_case of the rest of that tool's response. `none` means no password was applied to a URL that names a
user: either the credential is genuinely username-only, or the server does not
have both variables set for this host. The field is omitted for a URL with no
username, where the question does not arise.

Applies to `proxy_set_upstream`, `proxy_set_host_upstream` and
`proxy_mobile_setup`. A URL that already carries a password is used as-is, so
existing calls are unaffected. One credential covers all upstreams at the
pinned host; a URL without a username is left alone.

> **Username-only credentials at the pinned host cannot be expressed.** A URL
> with a username and no password is exactly the syntax that requests the
> merge, and `user:@host` cannot signal otherwise — the URL parser erases the
> empty password before the server sees it. If the pinned host authenticates on
> the username alone, unset `PROXY_MCP_UPSTREAM_PASSWORD` for that server.

> **`socks*://` upstreams: no `:` in the password.** socks-proxy-agent splits
> the credential on the first `:` and keeps only what follows, so `pa:ss` would
> authenticate as `pa`. Rather than deliver half a password silently, a socks
> upstream is **refused** with an error when `PROXY_MCP_UPSTREAM_PASSWORD`
> contains `:`. The truncation itself is a toolchain limitation, not something
> this introduces — a literal `socks5://user:pa%3Ass@host:1080` truncates the
> same way, and nothing can guard that. `http://`, `https://` and `pac+http://`
> upstreams take the whole password.

> **A `:` in the *username* is refused on every scheme.** Basic auth splits the
> decoded pair at the first colon (RFC 7617), and socks-proxy-agent does the
> same, so a username of `gro:ups` with password `s3cret` reaches the proxy as
> user `gro`, password `ups:s3cret` — the merged password silently discarded.
> No scheme can carry it, so the merge refuses rather than guess. Put the whole
> credential in `proxy_url` instead.

Responses redact credentials — the password in userinfo, with path segments
masked and the query and fragment dropped, since a `pac+http://` token may live
in any of those:

```
Global upstream set to http://user:***@upstream.example:1080/
Global upstream set to pac+http://pac.example.com/***
```

`proxy_status` and the `proxy://status` resource are redacted the same way. A
PAC URL's filename is masked along with the rest of the path, so a confirmation
message shows the host and nothing else.

**The username is not redacted.** For several providers it is configuration
rather than a secret — Apify Proxy encodes proxy group, country and
sticky-session id there — and showing it is what makes the confirmation useful.
If your provider puts a secret in the username field, do not rely on these
messages being safe to share.

Typical geo-routing examples:

```bash
Expand Down Expand Up @@ -368,6 +488,10 @@ proxy_mobile_setup \

Applies to BOTH listeners. Use `proxy_set_upstream` after the fact to change it without restarting.

As with `proxy_set_upstream`, omit the password and set
`PROXY_MCP_UPSTREAM_PASSWORD` and `PROXY_MCP_UPSTREAM_HOST` in the server's
environment to keep it out of the call.

### Verifying each step

| Check | Command | Expected |
Expand Down
9 changes: 4 additions & 5 deletions src/interceptors/camoufox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { join } from "node:path";
import type {
Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget,
} from "./types.js";
import { spawnEnv } from "../utils.js";

type CamoufoxOs = "windows" | "macos" | "linux";

Expand Down Expand Up @@ -189,14 +190,12 @@ export class CamoufoxInterceptor implements Interceptor {
const scriptPath = join(launcherDir, "launch.py");
await writeFile(scriptPath, buildLauncherScript(params, wsEndpointFile), "utf-8");

const childEnv = spawnEnv({ NO_COLOR: "1", PYTHONIOENCODING: "utf-8" });

const proc = spawn(pythonExe, [scriptPath], {
stdio: ["ignore", "pipe", "pipe"],
cwd: launcherDir,
env: {
...process.env,
NO_COLOR: "1",
PYTHONIOENCODING: "utf-8",
},
env: childEnv,
});

let handshake: WsHandshake;
Expand Down
3 changes: 2 additions & 1 deletion src/interceptors/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { spawn, type ChildProcess } from "node:child_process";
import { writeCertTempFile } from "./cert-utils.js";
import type { Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget } from "./types.js";
import { spawnEnv } from "../utils.js";

const MAX_OUTPUT_BUFFER = 8192;

Expand Down Expand Up @@ -84,7 +85,7 @@ export class TerminalInterceptor implements Interceptor {

const child = spawn(command, args ?? [], {
cwd,
env: proxyEnv,
env: spawnEnv(proxyEnv),
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
Expand Down
15 changes: 12 additions & 3 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type * as mockttp from "mockttp";
import type { CompletedRequest, CompletedResponse, ProxyConfig } from "mockttp";
import { randomUUID } from "node:crypto";
import { gunzipSync, inflateSync, brotliDecompressSync } from "node:zlib";
import { serializeHeaders, capString } from "./utils.js";
import { serializeHeaders, capString, redactProxyUrl } from "./utils.js";
import { enableServerTlsCapture, type ServerTlsCapture } from "./tls-utils.js";
import { spoofedRequest, shutdownSpoofContainer, stripHopByHopHeaders } from "./tls-spoof.js";
import { applyFingerprintHeaderOverrides } from "./spoof-headers.js";
Expand Down Expand Up @@ -342,6 +342,11 @@ function rewriteReplayUrl(originalUrl: string, targetBaseUrl?: string): string {
return new URL(`${original.pathname}${original.search}`, base).toString();
}

/** Copy an upstream config with its proxy URL redacted, for status output. */
function redactUpstreamConfig(config: UpstreamProxyConfig): UpstreamProxyConfig {
return { ...config, proxyUrl: redactProxyUrl(config.proxyUrl) };
}

// ── ProxyManager ──

let nextRuleId = 1;
Expand Down Expand Up @@ -502,8 +507,12 @@ export class ProxyManager {
port: this.port,
url: this.server?.url ?? null,
certFingerprint: this.cert?.fingerprint ?? null,
globalUpstream: this.globalUpstream,
hostUpstreams: Object.fromEntries(this.hostUpstreams),
// Redacted on the way out only: resolveProxyConfig() reads the stored
// proxyUrl to actually route, so those must keep their real credentials.
globalUpstream: this.globalUpstream && redactUpstreamConfig(this.globalUpstream),
hostUpstreams: Object.fromEntries(
[...this.hostUpstreams].map(([host, config]) => [host, redactUpstreamConfig(config)]),
),
ruleCount: this.rules.size,
trafficCount: this.traffic.length,
transparentProxy: this.getTransparentStatus(),
Expand Down
20 changes: 17 additions & 3 deletions src/tools/mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { proxyManager } from "../state.js";
import { interceptorManager } from "../interceptors/manager.js";
import { mergeUpstreamPassword, upstreamPasswordSource } from "../utils.js";

function errorToString(e: unknown): string {
if (e instanceof Error) return e.message;
Expand Down Expand Up @@ -198,7 +199,7 @@ export function registerMobileTools(server: McpServer): void {
explicit_port: z.number().optional().default(8080).describe("Port for the explicit HTTP proxy (default: 8080)."),
transparent_port: z.number().optional().default(8443).describe("Port for the transparent HTTPS listener (default: 8443)."),
block_quic: z.boolean().optional().default(true).describe("Drop UDP/443 on the AP iface so apps fall back to TCP/TLS (capturable). Default: true."),
upstream_proxy_url: z.string().optional().describe("Optional upstream proxy URL (socks5://user:pass@host:port or http://...). Sets the global upstream for BOTH listeners."),
upstream_proxy_url: z.string().optional().describe("Optional upstream proxy URL (socks5://user:pass@host:port or http://...). Sets the global upstream for BOTH listeners. If it has a username but no password, the password is filled in from PROXY_MCP_UPSTREAM_PASSWORD, but only when PROXY_MCP_UPSTREAM_HOST is also set and matches this URL's hostname. The response reports password_source: env | url | none."),
android_serial: z.string().optional().describe("ADB serial of an Android device to inject the CA on. If omitted, no cert injection is attempted."),
inject_cert: z.boolean().optional().default(true).describe("Inject the CA into the Android device's system store. Ignored if android_serial is omitted."),
},
Expand All @@ -215,6 +216,18 @@ export function registerMobileTools(server: McpServer): void {
inject_cert,
}) => {
try {
// Validate and resolve the upstream before starting anything, so a
// typo'd URL cannot leave both listeners running behind an error.
if (upstream_proxy_url && !URL.canParse(upstream_proxy_url)) {
throw new Error("upstream_proxy_url is not a parseable URL — check the scheme, e.g. socks5://host:1080");
}
let resolvedUpstream: string | undefined;
let passwordSource: ReturnType<typeof upstreamPasswordSource> = null;
if (upstream_proxy_url) {
resolvedUpstream = mergeUpstreamPassword(upstream_proxy_url);
passwordSource = upstreamPasswordSource(upstream_proxy_url, resolvedUpstream);
}

// 1. Resolve AP iface.
let apIface = ap_iface;
let ifaceReason = "user-provided";
Expand Down Expand Up @@ -254,8 +267,8 @@ export function registerMobileTools(server: McpServer): void {

// 5. Upstream (optional).
let upstreamSet = false;
if (upstream_proxy_url) {
await proxyManager.setGlobalUpstream({ proxyUrl: upstream_proxy_url });
if (resolvedUpstream) {
await proxyManager.setGlobalUpstream({ proxyUrl: resolvedUpstream });
upstreamSet = true;
}

Expand Down Expand Up @@ -306,6 +319,7 @@ export function registerMobileTools(server: McpServer): void {
transparent_port: transparentPortUsed,
block_quic,
upstream_set: upstreamSet,
...(passwordSource ? { password_source: passwordSource } : {}),
cert_injected: certInjected,
android_target_id: androidTargetId,
sudo_script: scriptPath,
Expand Down
38 changes: 32 additions & 6 deletions src/tools/upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,46 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { proxyManager } from "../state.js";
import { mergeUpstreamPassword, redactProxyUrl, upstreamPasswordSource } from "../utils.js";

/**
* A proxy_url the code cannot parse must not report success: redaction removed
* the echo that used to make a typo self-evident, and nothing downstream
* validates it either. The value is not repeated back — it may hold a password.
*/
function unparseable() {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
status: "error",
error: "proxy_url is not a parseable URL — check the scheme, e.g. socks5://host:1080",
}),
}],
};
}

export function registerUpstreamTools(server: McpServer): void {
server.tool(
"proxy_set_upstream",
"Set a global upstream proxy for all outgoing traffic. Supports socks4://, socks5://, http://, https://, and pac+http:// URLs.",
{
proxy_url: z.string().describe("Upstream proxy URL (e.g., socks5://user:pass@host:port)"),
proxy_url: z.string().describe("Upstream proxy URL (e.g., socks5://user:pass@host:port). If the URL has a username but no password, and the server has both PROXY_MCP_UPSTREAM_PASSWORD and PROXY_MCP_UPSTREAM_HOST set with the host matching this URL's hostname, the password is filled in from the environment so it need not appear in this call. Otherwise the URL is used as given; the response reports passwordSource: env | url | none."),
no_proxy: z.array(z.string()).optional().describe("Hostnames to bypass the upstream proxy"),
},
async ({ proxy_url, no_proxy }) => {
try {
await proxyManager.setGlobalUpstream({ proxyUrl: proxy_url, noProxy: no_proxy });
if (!URL.canParse(proxy_url)) return unparseable();
const resolved = mergeUpstreamPassword(proxy_url);
const passwordSource = upstreamPasswordSource(proxy_url, resolved);
await proxyManager.setGlobalUpstream({ proxyUrl: resolved, noProxy: no_proxy });
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
message: `Global upstream set to ${proxy_url}`,
message: `Global upstream set to ${redactProxyUrl(resolved)}`,
...(passwordSource ? { passwordSource } : {}),
noProxy: no_proxy || [],
}),
}],
Expand Down Expand Up @@ -57,18 +79,22 @@ export function registerUpstreamTools(server: McpServer): void {
"Set a per-host upstream proxy override. Traffic to this hostname will use the specified proxy instead of the global one.",
{
hostname: z.string().describe("Hostname to override (e.g., api.example.com)"),
proxy_url: z.string().describe("Upstream proxy URL for this host"),
proxy_url: z.string().describe("Upstream proxy URL for this host. If it has a username but no password, the password is filled in from PROXY_MCP_UPSTREAM_PASSWORD, but only when PROXY_MCP_UPSTREAM_HOST is also set and matches this URL's hostname. The response reports passwordSource: env | url | none."),
no_proxy: z.array(z.string()).optional().describe("Hostnames to bypass this proxy"),
},
async ({ hostname, proxy_url, no_proxy }) => {
try {
await proxyManager.setHostUpstream(hostname, { proxyUrl: proxy_url, noProxy: no_proxy });
if (!URL.canParse(proxy_url)) return unparseable();
const resolved = mergeUpstreamPassword(proxy_url);
const passwordSource = upstreamPasswordSource(proxy_url, resolved);
await proxyManager.setHostUpstream(hostname, { proxyUrl: resolved, noProxy: no_proxy });
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
message: `Upstream for '${hostname}' set to ${proxy_url}`,
message: `Upstream for '${hostname}' set to ${redactProxyUrl(resolved)}`,
...(passwordSource ? { passwordSource } : {}),
}),
}],
};
Expand Down
Loading
Loading