sf-mcp-proxy
by gokeel
README.md
# sf-mcp-proxy (Python)
> **Disclaimer.** Independent, unofficial project — not affiliated with, endorsed
> by, or sponsored by Salesforce, Inc. "Salesforce" and related marks are
> trademarks of Salesforce, Inc.
Salesforce's **Hosted MCP servers** (`sobject-reads`, `sobject-all`, `flows`,
`invocable-actions`, …) sit behind OAuth 2.0, and Salesforce does **not** support
Dynamic Client Registration — so every client must be pre-wired with a Consumer
Key from a Salesforce **External Client App** and run the full Authorization
Code + PKCE browser flow before it can call a single tool.
`sf-mcp-proxy` does that once and then gives you:
1. an importable **library** — an authenticated, in-process `mcp.ClientSession`
that **LangChain**, **LangGraph**, and **Google ADK** can build agents on with
no subprocess and no second OAuth layer;
2. a **CLI** to log in, inspect, and call tools;
3. a **proxy** (`sf-mcp-proxy proxy`) that re-exposes one Salesforce MCP server as
a plain MCP server (stdio or Streamable HTTP), optionally behind its own OAuth;
4. a **chat** loop — an LLM in a tool-use loop over one server's tools.
This is a Python port of the TypeScript
[`sf-mcp-proxy`](https://github.com/ockyharli/sf-mcp-proxy). The token cache
directory, cache-file naming, `StoredSession` JSON shape, and encryption-at-rest
envelope are **byte-compatible** with the TS CLI, so the two share
`~/.sf-mcp-proxy/`.
The OAuth machinery (RFC 9728 discovery, PKCE, the `resource` indicator, token
exchange/refresh) is handled by the official [`mcp`](https://github.com/modelcontextprotocol/python-sdk)
SDK; this package supplies a Salesforce-specific `OAuthClientProvider`.
> **API scope.** Salesforce owns the Hosted MCP API and can change it without
> notice; behaviour may drift on later releases and support is best-effort.
---
## Install
```bash
pip install sf-mcp-proxy # library + CLI + proxy
pip install "sf-mcp-proxy[langchain]" # + LangChain / LangGraph adapters
pip install "sf-mcp-proxy[adk]" # + Google ADK toolset
pip install "sf-mcp-proxy[chat]" # + `sf-mcp-proxy chat`
pip install "sf-mcp-proxy[all]"
```
Python 3.10–3.13.
---
## 1. Create a Salesforce External Client App (ECA)
Do this once, in the org you want to connect to.
1. **Setup → External Client App Manager → New External Client App.**
2. **Basic Information** — name it, set a contact email, Distribution State **Local**.
3. **API (Enable OAuth Settings)** → **Enable OAuth**:
- **Callback URL:** `http://localhost:8000/callback` (must exactly match
`OAUTH_REDIRECT_URI`).
- **OAuth Scopes:** *Access MCP servers (`mcp_api`)* and *Perform requests at
any time (`refresh_token`)*.
- Check **Require PKCE**.
- **Uncheck** *Require secret for Web Server Flow* / *Refresh Token Flow* to run
as a public client (PKCE only). If you leave them checked, set
`SF_CONSUMER_SECRET`.
4. **Policies → OAuth Policies:** set *Permitted Users*, and *Issue JWT-based
access tokens for named users* (so `whoami` can decode claims).
5. **Settings → OAuth Settings → Consumer Key** — copy it.
6. **Wait ~30 min** for the ECA to propagate before first use.
---
## 2. Configure
Everything can be passed to the library explicitly, or read from the environment
/ a `.env` file in the working directory.
| Variable | Notes |
|---|---|
| `SF_CONSUMER_KEY` | **Required.** Consumer Key from the ECA. |
| `SF_CONSUMER_SECRET` | Only if the ECA still requires a secret (→ `client_secret_post`). |
| `SF_ENV` | `platform` (production, default) or `sandbox`. |
| `OAUTH_REDIRECT_URI` | Must match an ECA Callback URL. Default `http://localhost:8000/callback`. |
| `OAUTH_CALLBACK_PORT` | Loopback port. Default `8000`. |
| `SF_LOGIN_URL` | Discovery fallback. Default `https://login.salesforce.com`. |
| `SF_AUTH_SERVER_URL` | Your My Domain host (e.g. `https://acme.my.salesforce.com`) — bypasses discovery. |
| `SF_TOKEN_PASSPHRASE` | Encrypt the token cache at rest (scrypt + AES-256-GCM). |
| `HOST` `PORT` `PUBLIC_URL` `SF_PROXY_AUTH_TOKEN` | `proxy --http` defaults. |
| `SFCHAT_*`, `ANTHROPIC_API_KEY`, … | `chat` (see the provider table). |
**`servers.json`** (searched in the working directory, then
`~/.sf-mcp-proxy/servers.json`). The reliable way is to copy the exact URL from
Salesforce Setup → MCP servers into `url`:
```json
{
"servers": [
{ "name": "sobject-reads", "url": "https://api.salesforce.com/platform/mcp/v1/sandbox/platform/sobject-reads" },
{ "name": "flows", "env": "platform" }
]
}
```
If `url` is omitted the URL is built as
`https://api.salesforce.com/platform/mcp/v1/{group}/{name}` (production) or
`…/mcp/v1/sandbox/{group}/{name}` (sandbox); `group` defaults to `platform`.
When absent entirely, a built-in default list is used (`sobject-reads`,
`sobject-all`, `flows`, …). `-s/--server` also accepts a full URL directly.
---
## 3. CLI
```bash
sf-mcp-proxy login -s sobject-reads -e sandbox # browser OAuth, caches tokens
sf-mcp-proxy tools -s sobject-reads
sf-mcp-proxy call query -s sobject-reads -a '{"soql":"SELECT Id, Name FROM Account LIMIT 3"}'
```
| Command | What it does |
|---|---|
| `servers` | list configured servers + resolved URLs |
| `login -s <name\|url> [-e platform\|sandbox]` | browser OAuth + PKCE; prints the cache path |
| `logout -s …` | delete the cached session file |
| `whoami -s …` | decode & print the cached access token's JWT claims |
| `tools -s … [--json]` | list tools, or raw tool defs as JSON |
| `toolspec -s …` | print `{ "tools": [...] }` (warns on stderr past 10 KB) |
| `call <tool> -s … -a '<json>' [--json]` | call one tool, print the result |
| `repl -s …` | interactive: `tools`, `schema <tool>`, `call <tool> [json]`, `resources`, `read <uri>`, `prompts`, `help`, `quit` |
| `chat -s … [-p <provider>] [-m <model>] [--base-url <url>] [-y] [--confirm-all]` | LLM tool-use loop |
| `proxy -s … [--http] [--host …] [--port …] [--auth-token …] [--oauth] [--public-url …] [--no-login]` | re-expose one server as MCP |
| `oauth-clients -s … [--revoke <id>]` | list / revoke clients registered against `proxy --oauth` |
`-s/--server` may be omitted if exactly one server is configured. `-e/--env`
overrides the tier for that run.
---
## 4. Library
```python
import asyncio
from sf_mcp_proxy import SalesforceMCP
async def main():
# One-time interactive login (opens a browser, caches tokens).
await SalesforceMCP.login("sobject-reads", env="sandbox")
# Authenticated session. Uses the cached token, auto-refreshes, and by
# default does NOT open a browser (interactive=False).
async with SalesforceMCP.connect("sobject-reads", env="sandbox") as sf:
tools = await sf.list_tools() # list[mcp.types.Tool]
result = await sf.call_tool("query", {"soql": "SELECT Id FROM Account LIMIT 1"})
session = sf.session # raw mcp.ClientSession
asyncio.run(main())
```
`connect()` accepts the same server spec as the CLI (name | bare name | full URL),
plus `env=`, `group=`, and explicit `consumer_key=` / `consumer_secret=` /
`settings=` / `passphrase=` overrides so a library caller never needs a `.env`
file. `list_tools` / `call_tool` transparently reconnect once on an authorization
failure (a port of the TS `withReauth`). `SalesforceMCP.connect_sync(...)` is a
blocking facade for notebooks and the CLI.
---
## 5. Framework integrations
### LangChain / LangGraph (`sf-mcp-proxy[langchain]`)
```python
from sf_mcp_proxy.integrations.langchain import SalesforceToolkit
async with SalesforceToolkit("sobject-reads", env="sandbox") as tools:
# `tools` is list[langchain_core.tools.BaseTool] — pass to any agent
...
```
```python
from sf_mcp_proxy.integrations.langgraph import create_salesforce_agent
agent = await create_salesforce_agent(
model="anthropic:claude-opus-5",
server="sobject-reads",
env="sandbox",
)
async for step in agent.astream({"messages": [("user", "how many open opps?")]}):
...
await agent.aclose()
```
`load_salesforce_tools(sf)` wraps a live session you already own. Tier B —
`salesforce_stdio_connection(...)` — returns a `langchain-mcp-adapters` connection
pointed at a spawned `sf-mcp-proxy proxy`.
### Google ADK (`sf-mcp-proxy[adk]`)
```python
from google.adk.agents import LlmAgent
from sf_mcp_proxy.integrations.adk import SalesforceToolset
toolset = SalesforceToolset(server="sobject-reads", env="sandbox")
agent = LlmAgent(model="gemini-2.5-pro", name="sf", tools=[toolset])
...
await toolset.close()
```
Tier B — `salesforce_stdio_connection_params(...)` returns
`StdioConnectionParams` for ADK's own `McpToolset`.
Runnable examples are in [`examples/`](examples/).
---
## 6. Proxy mode
`sf-mcp-proxy proxy` re-exposes **one** Salesforce MCP server as a plain MCP
server. The proxy owns the Salesforce OAuth (the token cached by `login`), so
downstream clients need no Salesforce auth of their own. Every request runs **as
the Salesforce user who authorized the proxy** (one identity per proxy process).
**stdio** — for clients that spawn a subprocess:
```json
{
"mcpServers": {
"salesforce-sobject-reads": {
"command": "sf-mcp-proxy",
"args": ["proxy", "-s", "sobject-reads", "-e", "sandbox", "--no-login"]
}
}
}
```
**Streamable HTTP** — for clients that take a URL:
```bash
sf-mcp-proxy proxy -s sobject-reads -e sandbox --no-login \
--http --port 9000 --auth-token "$(openssl rand -hex 16)"
# → endpoint: http://127.0.0.1:9000/mcp (send: Authorization: Bearer <token>)
```
`--host`/`--port`/`--public-url`/`--auth-token` fall back to
`$HOST`/`$PORT`/`$PUBLIC_URL`/`$SF_PROXY_AUTH_TOKEN`. For a remote client, bind
`--host 0.0.0.0` and put a TLS reverse proxy in front (the proxy speaks plain
HTTP), and always set `--auth-token` or `--oauth`.
### `--oauth`
Runs a minimal, spec-shaped OAuth 2.0 Authorization Server + Resource Server on
the same port: RFC 9728 / RFC 8414 discovery, RFC 7591 Dynamic Client
Registration (`POST /register`), Authorization Code + PKCE (S256), and **an
Approve/Deny page on every `/authorize` request**. A spec-compliant client
discovers everything from the 401 it gets hitting `/mcp`. Manage clients with
`sf-mcp-proxy oauth-clients -s … [--revoke <id>]`. Behind a reverse proxy, pass
`--public-url https://your.host` so the discovery documents advertise the right
address. This layer only gates *who reaches the proxy* — every approved client
still acts as the one identity that ran `login`.
### Docker
A [`Dockerfile`](Dockerfile) runs `proxy --http --no-login` and reads `$HOST`,
`$PORT`, `$PUBLIC_URL`, `$SF_PROXY_AUTH_TOKEN` from the environment.
```bash
docker build -t sf-mcp-proxy .
docker run --rm -p 8080:8080 \
-e SF_CONSUMER_KEY=... -e SF_PROXY_AUTH_TOKEN=... -e SF_TOKEN_PASSPHRASE=... \
-v ~/.sf-mcp-proxy:/home/app/.sf-mcp-proxy \
sf-mcp-proxy -s sobject-reads -e sandbox
```
There's no browser in the container, so `--no-login` is on: run `sf-mcp-proxy
login` **once on your machine** and make the token cache available to the
container. On a stateless host (Cloud Run, etc.) mount a **persistent** volume at
`/home/app/.sf-mcp-proxy` (the Salesforce refresh token rotates and is written
back), set `SF_TOKEN_PASSPHRASE` from a secret, and pin `--max-instances 1
--min-instances 1` (a Streamable HTTP session is held in memory by the instance
that created it).
---
## 7. Chat mode (`sf-mcp-proxy[chat]`)
```bash
sf-mcp-proxy chat -s sobject-reads
you> how many open opportunities are closing this quarter?
⚙ soqlQuery({"soql":"SELECT COUNT() FROM Opportunity WHERE IsClosed = false AND ..."})
run soqlQuery? [y/N] y
```
Tool calls whose name looks like a write (`create`/`update`/`delete`/`run`/…)
prompt for confirmation. `-y` approves everything; `--confirm-all` prompts for
every call. In-chat commands: `/tools`, `/reset`, `/quit`.
| `--provider` | Base URL | API key env | Default model |
|---|---|---|---|
| `anthropic` (default) | — | `ANTHROPIC_API_KEY` | `claude-opus-5` |
| `openai` | `api.openai.com/v1` | `OPENAI_API_KEY` | `gpt-4.1` |
| `deepseek` | `api.deepseek.com` | `DEEPSEEK_API_KEY` | `deepseek-chat` |
| `kimi` / `moonshot` | `api.moonshot.ai/v1` | `MOONSHOT_API_KEY` | `kimi-k2-0711-preview` |
| `qwen` | `dashscope-intl.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` | `qwen-plus` |
| `openrouter` | `openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | — (set `--model`) |
| `custom` | `--base-url` / `SFCHAT_BASE_URL` | `SFCHAT_API_KEY` | — (set `--model`) |
`SFCHAT_API_KEY` works as the key for any provider; `SFCHAT_MODEL` /
`SFCHAT_BASE_URL` persist per-run overrides. The model must support tool calling.
---
## How auth works
1. First request to the MCP server returns `401`. The SDK discovers
`https://api.salesforce.com/.well-known/oauth-protected-resource/…`, which
names the org's authorization server.
2. It builds the authorize URL with PKCE (`S256`), the `mcp_api refresh_token`
scopes, and `resource=<server URL>` (RFC 8707), then opens the browser.
3. Salesforce redirects to `http://localhost:<port>/callback` with a `code`; a
one-shot loopback server catches it and the SDK exchanges it for tokens.
4. Tokens are written to `~/.sf-mcp-proxy/<sha256(url)[:32]>.json` (dir `0700`,
files `0600`; encrypted if `SF_TOKEN_PASSPHRASE` is set). Later runs reuse and
silently refresh the access token.
`SF_AUTH_SERVER_URL` (a My Domain host) bypasses discovery and uses
`{base}/services/oauth2/{authorize,token,revoke}` directly.
## Troubleshooting
| Symptom | Fix |
|---|---|
| `redirect_uri_mismatch` | The ECA Callback URL must byte-for-byte equal `OAUTH_REDIRECT_URI`. |
| `invalid_client_id` right after creating the ECA | Wait ~30 min for propagation. |
| Browser shows the wrong org / login | Set `SF_AUTH_SERVER_URL` to your My Domain, or `SF_LOGIN_URL=https://test.salesforce.com` for sandboxes. |
| `Port 8000 is not available` | Change `OAUTH_CALLBACK_PORT` **and** the ECA Callback URL. |
| `No cached token …` from `connect()` | Run `sf-mcp-proxy login -s <name>` first — non-interactive mode never opens a browser. |
| Stuck / bad token | `sf-mcp-proxy logout -s <name>`, then `login` again. |
---
## Development
```bash
uv sync --all-extras --group dev
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run mypy sf_mcp_proxy
```
## License
MIT © Ocky Harliansyah — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues