feat: add Salesforce connector (OAuth App + custom MCP tools) - #1459
feat: add Salesforce connector (OAuth App + custom MCP tools)#1459yiboyasss wants to merge 24 commits into
Conversation
Same static-client OAuth + custom FastMCP pattern already used for
GitHub/Linear/Jira/Slack/Zoom/HubSpot: a seeded oauth_providers/
public_mcp_apps row pair, and a new tools/mcp/salesforce.py module
wrapping the Salesforce REST API (SOQL query, SOSL search, sobject
listing/describe, and full record CRUD -- generic enough to cover any
standard or custom object, since Salesforce orgs are highly
customizable).
Salesforce "Connected Apps" need no platform-level review to function --
registering one and sharing the consumer key/secret is immediately usable
by any Salesforce org via the generic login.salesforce.com/
test.salesforce.com entry points (which resolve to the user's actual org
during login).
One real architectural gap this connector's own OAuth quirk required
closing, not just a per-provider `if` branch: Salesforce's token response
returns a per-org API host (`instance_url`) instead of using a fixed
domain, and every subsequent API call must go through it. Since no
existing connector needed anything beyond the access token itself, this
required:
- A new `user_oauth.instance_url` column (migration + model).
- Persisting it generically in generic_oauth_callback (initial connect)
and refresh_oauth_token_if_needed (Salesforce can return a new
instance_url on refresh, e.g. after an org migration).
- Threading it through _LegacyOAuthTokenResolution and
_build_oauth_mcp_stdio_transport_config so the launch_config's
env_mapping can map a second field ("instance_url", alongside the
existing "access_token") into a launch env var.
The OIDC userinfo endpoint is the one exception: it's documented as the
fixed login.salesforce.com host (not instance_url) since Salesforce
routes it internally based on the token, so salesforce_get_current_user
calls that fixed URL directly rather than going through instance_url like
every other tool here.
There was a problem hiding this comment.
Code Review
This pull request introduces a built-in Salesforce (OAuth) MCP connector, adding support for per-org API hosts by persisting an instance_url column in the user_oauth table. It implements several Salesforce tools for querying, searching, and managing records, along with database migrations and comprehensive tests. Feedback on the changes highlights a potential path traversal vulnerability in the request path, a runtime crash in the search tool due to an unexpected API response format, and the use of logging.basicConfig() which overrides global configurations. Additionally, it is recommended to use sa.inspect(bind) instead of the legacy Inspector.from_engine(bind) in the migration script.
The salesforce oauth_providers row omitted redirect_uri, unlike every sibling provider, so the bulk seed insert crashed with "value is required for bind parameter 'redirect_uri'" on a fresh database. This was the root cause of the failing Pytest/e2e/Postgres-migration CI jobs on this PR. Also documents the three SALESFORCE_* env vars in example.env, matching the other OAuth connectors' sections.
Newer Salesforce orgs enforce PKCE on this OAuth flow at the org level with no per-app opt-out (Setup > External Client Apps > Security > "Require Proof Key for Code Exchange" is locked once enabled), so the authorize redirect was being rejected with "missing required code challenge". Generates a code_verifier, carries it inside the existing signed oauth_state token, and sends code_challenge/code_challenge_method on the authorize request and code_verifier on the token exchange - scoped to the salesforce provider only.
- Reject ".." in _request's path: sobject_type/record_id reach it straight from LLM tool arguments, so a crafted value could redirect the request to an unintended endpoint on the org's instance_url. - Use sa.inspect(bind) instead of the deprecated Inspector.from_engine in the instance_url migration, matching this PR's other migration.
…lient-credentials-449d8c # Conflicts: # src/xagent/web/api/auth.py # src/xagent/web/builtin_mcp_registry.py
main gained 20260818_seed_jira_mcp_app (merged independently) while this branch's own salesforce migration chain was in review, leaving two migration heads once main was merged in. Standard alembic merge-heads revision joining them back into one, same pattern as 975d150.
|
/gemini review |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
1 similar comment
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
rogercloud
left a comment
There was a problem hiding this comment.
PR Summary
This PR adds a Salesforce connector following the existing OAuth App + custom FastMCP tool template used by other connectors (e.g. HubSpot, Jira). Because Salesforce, unlike other providers, requires a per-org API host (instance_url) returned at token-exchange time and needed on every subsequent API call, the PR generalizes the OAuth/launch-config plumbing to carry a second provider-specific value alongside the access token, plus a migration adding instance_url to UserOAuth. The core template reuse and migration are sound, but the generalization is only half-wired through the codebase, and the token lifecycle (refresh, reconnection signaling) for this specific provider isn't fully modeled.
Blocking: yes — recommended event: REQUEST_CHANGES
Design-level assessment (Round 0)
Verdict: acceptable-with-reservations. The decision to persist instance_url in the schema and thread it through the OAuth token resolver is the right shape for this provider — a "second generic launch-config field" alongside the access token, matching the template's per-connector customization points elsewhere. However the threading is incomplete (see Major #1), and the refresh-time update path this PR adds is unreachable in production (see Major #2), so the overall provider integration isn't yet functionally complete.
A design-level comparison to Jira's connector was considered during this review — Jira resolves its instance host at runtime via Atlassian's accessible-resources discovery endpoint, and the question was whether Salesforce should do the same instead of persisting instance_url in the schema. This was checked and ruled inapplicable: Salesforce's userinfo endpoint does not expose an instance host the way Atlassian's dedicated discovery endpoint does, so schema-based persistence of instance_url is technically necessary here, not an unjustified divergence from the Jira pattern. No action needed on this point.
Findings
Major (blocking)
1. src/xagent/web/tools/config.py:3264-3271 — hook-path OAuth token resolver silently drops instance_url
The hook-token call site of _build_oauth_mcp_stdio_transport_config omits the instance_url= kwarg entirely, unlike the legacy DB-path call site at config.py:3312-3317 which passes it correctly. Root cause is structural: _ResolvedHookToken (config.py:257-261) and the public ResolvedToken contract (config.py:128-140) are frozen dataclasses with no field to carry instance_url at all. Any embedder using a custom OAuth token-resolver hook gets a Salesforce connector that launches successfully but fails on the first real tool call with ValueError: SALESFORCE_INSTANCE_URL environment variable is missing (src/xagent/web/tools/mcp/salesforce.py:44-54) — every tool except salesforce_get_current_user breaks. No test exercises this path (tests/web/tools/test_oauth_token_resolver_hook.py has zero references to Salesforce/instance_url). This is a regression introduced by this PR's own diff, which added the instance_url parameter but wired only one of its two call sites.
Fix: add an instance_url field to _ResolvedHookToken/ResolvedToken and pass it through at the hook-path call site, symmetric with the DB path.
2. src/xagent/web/api/auth.py:1665-1671 and src/xagent/web/tools/config.py:562-563,687-692 — refresh-time instance_url update logic this PR adds is dead code in production
auth.py:1665-1671 only sets UserOAuth.expires_at when the token response contains expires_in; Salesforce's OAuth token response does not include expires_in (per the PR's own fixture, tests/web/test_salesforce_oauth.py:96-107). config.py:562-563 treats a NULL expires_at as "assume valid, no refresh needed", so refresh_oauth_token_if_needed returns early before ever reaching the refresh POST for any Salesforce grant. The new instance_url-on-refresh code added at config.py:687-692 is therefore only reachable in the one test that manually forges a past expires_at (test_salesforce_oauth.py:235) — never in real usage. Compounding this, no connector under src/xagent/web/tools/mcp/ implements 401/session-expiry-triggered re-authentication, so once a Salesforce org session expires server-side there's no automatic recovery — just a raw RuntimeError on the next tool call requiring manual reconnect. This ships a code path that looks like working refresh support but is silently unreachable, creating false confidence with no test to catch the gap.
Fix: either derive an appropriate expires_at for Salesforce (Salesforce sessions don't have a fixed lifetime, so this may require a different signal), or explicitly document/handle the "no expiry info" case and add a real reconnect-signal path for 401s.
Minor (non-blocking)
3. src/xagent/web/tools/mcp/salesforce.py:126-134 — path/query injection via unencoded path segments beyond the literal ".." check
_request's only guard is if ".." in path: raise ValueError(...) — a literal-substring blocklist with no percent-decoding and no rejection of /, ?, #. Call sites (~241, 272-293, 307, 331, 343-361) interpolate LLM-controlled sobject_type/record_id raw into the URL path. Sibling connectors hubspot.py:262-274 and jira.py:66-73 both percent-encode with quote(value, safe=""). Concretely: record_id="001x?fields=Id" injects query params; sobject_type="Account/001abc" can retarget the endpoint; percent-encoded traversal (%2e%2e%2f) bypasses the ".." check entirely. Not a privilege-boundary crossing, but an endpoint-redirection/parameter-injection primitive on a connector with DELETE capability. Existing test test_request_rejects_path_traversal (tests/web/tools/test_salesforce_mcp.py:69-78) only covers the literal ".." case.
Note: gemini-code-assist flagged the shallower literal-".." gap on this line in the prior review round; that narrower suggestion was silently adopted (code now has the ".." check). This finding is the broader gap gemini didn't originally catch.
Fix: percent-encode each interpolated segment like the sibling connectors do.
4. src/xagent/web/tools/config.py:3038-3045 — env_mapping dispatch silently drops the env var when instance_url is falsy
elif token_type == "instance_url" and instance_url: env[env_key] = instance_url — when instance_url is None (pre-migration row, hand-restored row, or via the hook-path gap in #1), the connector is still built as "launchable" and only fails later with a raw exception, instead of surfacing the existing oauth_token_required-style reconnect signal used elsewhere for missing access tokens. Distinct root cause from #1 (dispatch logic vs. missing kwarg) — fixing one doesn't fix the other.
Fix: when token_type == "instance_url" and the value is missing, surface the same reconnect-required signal used for missing access tokens instead of silently omitting the env var.
5. src/xagent/web/builtin_mcp_registry.py:225-232 — userinfo_url: "" justified by a comment the connector's own code contradicts
The comment claims "no fixed URL this callback's lookup could use" for Salesforce identity, but salesforce.py:19-22,138-158 hardcodes exactly such a URL (https://login.salesforce.com/services/oauth2/userinfo) and uses it successfully via the same Bearer-token scheme the generic callback already uses (auth.py:1613-1617). Consequence: UserOAuth.email/provider_user_id stay NULL for every Salesforce grant (intentional and tested per tests/web/test_salesforce_oauth.py:89-137), so the connected-account UI label never populates for Salesforce, though is_connected itself still works. user_id_path/email_path remain dead config as a result. No test exercises /api/mcp/apps for Salesforce's is_connected/connected_account combination (comparable coverage exists for Meta: tests/web/test_meta_oauth.py:1220).
Fix: either point userinfo_url at the fixed endpoint salesforce.py already uses, or correct the comment and add a Meta-style test asserting the intentional "connected but unlabeled" state.
6. src/xagent/web/api/auth.py:1237-1242 — PKCE code_verifier travels through the browser inside a signed-but-unencrypted JWT state, untested
state_payload embeds code_verifier before create_access_token (HS256-signed, not encrypted — decodable by anyone without the secret). This state goes out as a URL query param to Salesforce's /authorize and returns via the callback, exposing the verifier to browser history, Referer headers, and proxy/access logs — the exact channel PKCE protects against. The inline comment at auth.py:1233-1236 claiming it "never leaves the server unencrypted" is factually incorrect. The repo's own MCP OAuth flow does this correctly (tests/web/api/test_mcp_oauth_flow.py:640-650: verifier stored server-side, encrypted, never on the wire). Severity is tempered because the token exchange still requires the server-held client_secret (~auth.py:1561-1562), so this is defense-in-depth, not directly exploitable alone. No test covers this path (test_salesforce_oauth.py has zero occurrences of code_verifier/code_challenge).
Fix: correct the misleading comment, add test coverage, and consider aligning with the existing encrypted server-side pattern.
7. src/xagent/web/tools/mcp/salesforce.py:162-181 — no pagination beyond first 2000 SOQL rows; no response-size cap the other direction
salesforce_query reports truncated=not result.get("done", True) but drops nextRecordsUrl and provides no continuation tool — a >2000-row result's remainder is permanently unrecoverable. Separately, salesforce_get_record (line 272) has no default/bounded field list, so fields="" or SELECT FIELDS(ALL) can return arbitrarily large payloads into the LLM context.
Fix: add a salesforce_query_more-style continuation tool using nextRecordsUrl, and consider a default field cap for get_record.
8. src/xagent/web/tools/mcp/salesforce.py:295-311 — salesforce_create_record discards the errors array on failure
On a {"success": false, "errors": [...]} response shape, the code returns {"id": None, "success": false} with errors dropped. Note: Salesforce's create endpoint typically returns validation failures via non-2xx status (already surfaced through the exception path in _request_absolute), so this specific shape may not be reachable today — still worth guarding defensively.
Fix: propagate errors in the return payload when present.
9. salesforce.py:272 (salesforce_get_record), salesforce.py:343 (salesforce_delete_record) — no non-empty validation on record_id/sobject_type
No check that record_id/sobject_type are non-empty before building URLs (an empty record_id collapses to a collection-level URL). salesforce_update_record guards empty fields (line 328) but nothing guards empty ids anywhere. Sibling connector hubspot.py:246-274 has exactly this helper (_require_clean_identifier/_url_path_id) that this connector lacks.
Fix: add an identifier-validation helper mirroring HubSpot's, applied to all id/type-bearing calls.
10. src/xagent/web/builtin_mcp_registry.py:222-223 — hardcoded login.salesforce.com, sandbox orgs cannot connect
Hardcodes the production auth host; the registry's own comment names test.salesforce.com as the sandbox alternative, but no config/env var exposes switching — a sandbox-org user has no path to connect except an admin creating a duplicate provider row.
Fix: expose a config/env toggle for the sandbox host, or at minimum document this limitation in example.env.
11. tests/web/test_salesforce_oauth.py:152-205 — test_non_salesforce_callback_does_not_persist_instance_url can't fail for the reason its name suggests
It asserts instance_url is None when the token response lacks the key, but token_data.get("instance_url") also returns None for a missing key regardless of whether the if "instance_url" in token_data: guard exists — deleting that guard wouldn't break this test, so it doesn't actually prove the guard's presence.
Fix: strengthen the test to actually exercise the guard (e.g. assert a sentinel isn't overwritten, or mock the dict-membership check directly).
12. src/xagent/web/tools/mcp/salesforce.py:67-86 — untested dict-shaped error branch in _extract_error_detail
When the JSON error payload is a dict (not a list), the function returns None and falls back to raw response text — genuinely untested. All existing error-path tests in tests/web/tools/test_salesforce_mcp.py mock array-shaped error bodies (realistic for /services/data/* endpoints), but salesforce_get_current_user's OIDC userinfo endpoint actually returns dict-shaped errors ({"error": ..., "error_description": ...}) on failure — that fallback path has zero coverage.
Fix: add a test covering the dict-shaped error response for the userinfo endpoint.
Simplification opportunities
src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:36: shrink: redundant secondsa.table()definition (OAUTH_PROVIDERS_TABLE, onlyprovider_name) duplicatingFULL_OAUTH_PROVIDERS_TABLE. The equivalent Jira migration (20260818_seed_jira_mcp_app.py) selectsFULL_OAUTH_PROVIDERS_TABLE.c.provider_namedirectly with no second table object — drop the redundant one here to match.
net: -3 lines possible
Review completeness notes (checked, no action needed)
A few items raised in the prior gemini-code-assist review round were re-verified this round and require no further action:
sa.inspect(bind)vs. legacyInspector.from_engine(bind)in20260818_add_instance_url_to_user_oauth.py: confirmed fixed in a later commit (e3ca2bbef) matching gemini's suggestion, even though the author never replied to that thread — safe to resolve.- SOSL search response shape (
searchRecordsdict access): the author's rebuttal is confirmed technically correct — Salesforce's SOSL endpoint does return a dict, matching the existing code/test. No action. logging.basicConfig()insalesforce.py: the author's rebuttal is confirmed correct — this module only runs as a standalone subprocess entrypoint, and 19 other connector modules use the identical pattern. No action.
Additionally, two items were considered but downgraded/dropped after verification: the env_mapping if/elif dispatch is fine as-is for 2 fields (a full dict refactor would be premature abstraction) — only worth a light note to log a warning on unmapped token_type values; and the lack of host/scheme validation on SALESFORCE_INSTANCE_URL is a structural trait shared by every OAuth provider row in this system, not Salesforce-specific, so it's downgraded to an informational note rather than a standalone finding.
Blocking status & recommended decision
Blocking: yes
Blocking issues:
[new]Major #1 — hook-path OAuth token resolver silently dropsinstance_url(config.py:3264-3271)[new]Major #2 — refresh-timeinstance_urlupdate logic is unreachable dead code in production (auth.py:1665-1671,config.py:562-563,687-692)
Neither issue was raised in the prior gemini-code-assist review round — both are newly identified in this review.
Recommended event: REQUEST_CHANGES
The DB-token call site passed instance_url= to _build_oauth_mcp_stdio_transport_config, but the OAuth token-resolver hook call site did not - ResolvedToken/_ResolvedHookToken had no field for it at all. An embedder using a custom hook got a Salesforce connector that launched successfully but failed on the first real tool call with "SALESFORCE_INSTANCE_URL environment variable is missing", since every tool but salesforce_get_current_user needs it. Also make a declared-but-missing instance_url env mapping surface as oauth_token_required/unavailable instead of silently building a connector missing a value it declared as required (same root cause, different call path: a pre-migration row, a hand-restored row, or the hook gap above all hit this).
…e create errors
The literal '"..".in path' blocklist added earlier misses '/' and '?',
which redirect the request to a different endpoint or inject query
params without ever containing "..". sobject_type/record_id reach
every call site straight from LLM-controlled tool arguments and get
interpolated raw into the URL path. Percent-encoding each segment -
matching hubspot.py's/jira.py's _url_path_id - closes this off
regardless of which specific character does it, and also rejects
empty/whitespace ids (get_record and delete_record had no such check;
update_record only guarded empty fields).
Also stop discarding the errors array on a
{"success": false, "errors": [...]} create_record response.
…ration table The comment claimed there's "no fixed URL this callback's lookup could use" for Salesforce identity, but salesforce.py's own USERINFO_URL is exactly such a fixed host (login.salesforce.com, same as auth_url/ token_url) -- it directly contradicts salesforce.py's own comment about the same endpoint. userinfo_url is still left empty, but for the real reason: populating it would add a network round-trip to every OAuth connect for identity data this connector doesn't otherwise need, since it's fetched lazily via salesforce_get_current_user instead (already covered by test_salesforce_oauth.py's dedicated test for this connected-but-unlabeled state). No behavior change. Also drops the migration's redundant single-column OAUTH_PROVIDERS_TABLE, which only duplicated a lookup FULL_OAUTH_PROVIDERS_TABLE already covers - matching the equivalent Jira migration, which has no such duplicate.
code_verifier rode inside state_payload before create_access_token
(HS256-signed, not encrypted -- it's base64, decodable by anyone
without the secret). That state goes out as a URL query param on the
redirect to Salesforce's /authorize and back through the callback,
landing in browser history/Referer headers/proxy logs -- the exact
channel PKCE is meant to protect against. The comment claiming it
"never leaves the server unencrypted" was wrong. Now encrypted with
the same encrypt_value/decrypt_value helpers already used for stored
client secrets elsewhere in this file. Token exchange still requires
the server-held client_secret regardless, so this was defense-in-depth
either way, not the only thing standing between an interceptor and a
token -- but the comment shouldn't claim protection the code didn't
have.
Also drops the now-inert `if "instance_url" in token_data` guard on
the callback's persistence path: oauth_account is always freshly
created there (never an update to an existing row), so
token_data.get("instance_url") already returns the correct final value
whether the key is present or not -- the guard changed no observable
behavior, which is also why the existing regression test for the
missing-key case couldn't actually distinguish guarded from
unguarded.
login.salesforce.com is hardcoded for auth/token; sandbox orgs authenticate through test.salesforce.com instead and have no way to connect through this built-in provider today. A real env-configurable toggle is a larger change (would need a distinct provider row or a runtime host override) - documenting the limitation here is the cheap, immediate half of the fix.
Self-review of the prior review-fixing round (run before pushing, per request) surfaced one real regression and a few smaller gaps: - ResolvedToken.instance_url was inserted between expires_at and generation instead of appended at the end. Since ResolvedToken is the embedder-facing public contract for the OAuth token-resolver hook, this silently corrupted any positional construction (a third positional arg meant as generation would land in instance_url instead, with generation reverting to None, no exception raised). Moved to the end, matching _ResolvedHookToken's existing field order for the same field. - _OAuthInstanceUrlRequired now carries the env_mapping key that triggered it (mirroring _OAuthLaunchConfigInvalid.field), so both catch sites' log lines can say which key was missing instead of just the server name. - salesforce_create_record's errors key is now only included when Salesforce actually returned one, not unconditionally on every success -- an always-present errors:[] could read as a partial- failure signal to a caller checking for key presence, a natural idiom given Salesforce's own bulk/collections APIs always include one. Added dedicated tests for both the present and absent case. - Fixed a docstring that named a jira.py function (_url_path_id) which doesn't exist there -- jira.py's actual helper is _path_segment, and behaves more loosely (no empty-id rejection) than what this file's own tests require. - Added the missing InvalidInstanceUrl regression test the other three sibling validation branches already had.
rogercloud
left a comment
There was a problem hiding this comment.
PR Review: feat: add Salesforce connector (OAuth App + custom MCP tools) — Re-review (Round 2)
1. PR Summary
This PR adds a Salesforce connector via OAuth App plus a set of custom MCP tools (query, get/create/update/delete record, describe sobject, list sobjects, get current user). Unlike every other existing connector (GitHub/Linear/Jira/Slack/Zoom/HubSpot), Salesforce's OAuth token response returns a per-org instance_url that every subsequent API call must target instead of a fixed base URL. The PR generalizes the OAuth/launch-config plumbing with a data-driven env_mapping mechanism so a provider can carry a second value (instance_url) alongside the access token, rather than special-casing Salesforce in the transport layer. Migrations are correct (single head, idempotent, verified against alembic's own ScriptDirectory.get_heads() and the full migration test suite), and leaving instance_url unencrypted is consistent with the sibling access_token/refresh_token columns already unencrypted in the same table.
Blocking: yes — recommended event: REQUEST_CHANGES
2. Update Summary
Six commits landed in response to the prior review round, fixing 9 of 12 prior findings outright (2 of those 9 with minor test/doc caveats noted below), while one prior finding (dead refresh-time instance_url code, no session-expiry recovery path) remains open and one (a tautological non-regression test) remains open. A fresh design and discovery pass on the current head surfaced 14 new findings — mostly minor/nit — but two of them are MAJOR test-coverage gaps in the very PKCE feature this update round just added and fixed.
3. Round 0 Design Verdict
Acceptable with reservations — one likely-blocking correctness gap. The core architectural choice — carrying instance_url through OAuth/launch-config via a data-driven env_mapping rather than hardcoding Salesforce into the transport layer — is the right shape for a provider whose API origin is per-org rather than fixed. It generalizes cleanly and doesn't special-case Salesforce where it doesn't need to. The unencrypted instance_url column is a correct, deliberate choice, not a security gap: encrypting only the new field while the sibling access_token/refresh_token columns in the same table remain unencrypted would be security theater. A JSON-metadata-blob alternative was considered and correctly rejected — it would still require a schema decision, would risk unfiltered persistence of sensitive token_data keys, and is premature abstraction at N=1. The per-field touch-point cost (~9 places per new field) is real but doesn't yet justify a Mapping-based refactor at N=2 — YAGNI applies. The one correctness gap that keeps this from a clean accept is the dead refresh path / no session-expiry recovery (prior finding #2, still open — see Blocking section).
4. Prior Findings Status
- FIXED — Hook-path OAuth resolver silently dropped
instance_url, asymmetric with the DB-path call site. Fixed by0574904cd/671f5f667: both call sites now passinstance_url=symmetrically, field added toResolvedToken/_ResolvedHookToken, new tests added. - NOT FIXED — MAJOR, BLOCKING. Refresh-time
instance_urlupdate code (config.py:709-716) is unreachable in production: Salesforce's token response has noexpires_in(auth.py:1681only setsexpires_atwhen that key exists), soconfig.py:584-585'sif not oauth_account.expires_at: return Trueskips the refresh check entirely for every real Salesforce grant. No 401-triggered reconnect signal exists either (salesforce.pytools blanket-catchExceptioninto success-shaped error JSON). The promised follow-up issue tracking this was not found filed on this repo. This is still open and blocking. - FIXED — Path/query injection via unencoded id/type segments. Fixed by
1462508d5: new_url_path_id()(_require_clean_identifier()+quote(..., safe="")) applied at all 5 call sites, with tests reproducing the original injection payloads. - FIXED —
env_mappingsilently dropped the env var on falsyinstance_urlinstead of signaling reconnect. Fixed by0574904cd/671f5f667: new_OAuthInstanceUrlRequiredexception routes to the sameoauth_token_requiredunavailable-config signal used for missing access tokens. - FIXED — Self-contradictory
userinfo_url: ""comment. Fixed by3f3a35d58(doc-only correction); behavior unchanged, existing test covers the resulting state. - FIXED — PKCE
code_verifiertraveled in a signed-but-unencrypted JWTstate, with a misleading comment. Fixed by4e278a02d: verifier now genuinely encrypted viaencrypt_value()/decrypt_value()(Fernet), non-Salesforce flows unaffected, comment corrected. The underlying encryption mechanism is correctly implemented. (Note: separately re-flagged by new findings K/L below for lacking real regression coverage — that's a distinct, newly-found test gap.) - STILL OPEN — non-blocking. No pagination for
salesforce_query(nextRecordsUrldropped,truncated=Trueis a dead end) and no field cap forsalesforce_get_record. Author agreed but scoped as follow-up; no tracking issue found filed. - FIXED —
salesforce_create_recorddiscarded theerrorsarray on{success:false, errors:[...]}. Fixed by1462508d5, refined by671f5f667. - FIXED (code); test coverage PARTIAL. No non-empty validation on
record_id/sobject_type. Fixed by1462508d5via_require_clean_identifier()at all 5 call sites; dedicated rejection tests exist only forget_record/delete_record, notdescribe_sobject/create_record/update_record. - FIXED (documentation, satisfies the original either/or bar). Hardcoded
login.salesforce.comwith no sandbox path. Documented inexample.env(6b354a792) — meets the "at minimum document" bar (see new finding F for a completeness follow-up). - NOT FIXED — minor.
test_non_salesforce_callback_does_not_persist_instance_url(tests/web/test_salesforce_oauth.py:152-205) can't catch a regression in the"instance_url" in token_dataguard — it only assertsNoneon a fresh row, true either way. Fix: pre-seed an existinginstance_urland assert it's preserved unchanged. - FIXED — Untested dict-shaped error branch in
_extract_error_detail. New testtest_get_current_user_falls_back_to_raw_text_on_dict_shaped_errorconfirmed to exercise the branch.
5. New Findings
Major
K. Tautological PKCE-encryption test (empirically verified). tests/web/test_generic_oauth_login.py:170-210 — its assertions (encrypted_verifier != code_challenge, and a decrypt round-trip) pass 18/18 even with encryption removed entirely from the source, because a verifier trivially differs from its own derived challenge and decrypt_value passes through non-ciphertext unchanged. The test's docstring claims to prove the verifier isn't left as plaintext; it does not. Suggest asserting state_payload["code_verifier"] != decrypted_verifier, or that the stored value looks like a Fernet token.
L. Zero test coverage of the PKCE callback path (empirically verified). tests/web/test_salesforce_oauth.py:76-87 — removing the line in auth.py that adds code_verifier to the token-exchange POST body still leaves all 21 tests (across both this file and test_generic_oauth_login.py) passing. Combined with K, the entire PKCE feature — the subject of dedicated fix commit 4e278a02d for a prior review finding — is untested end-to-end, even though the encryption mechanism itself is correctly implemented. Suggest asserting mock_post.call_args.kwargs["data"]["code_verifier"] == <original unencrypted verifier>.
Minor
A. src/xagent/web/tools/mcp/salesforce.py _instance_url() (~L45-55), config.py (~L2929-2936), auth.py (~L1680) — no scheme/host validation on instance_url (only non-empty check). Salesforce is the only connector where the entire outbound API origin comes from provider-returned data rather than a hardcoded constant. Every practical attack route already requires privileged access, so this is defense-in-depth, not exploitable — but worth validating https:// + *.salesforce.com/*.force.com suffix at persist time.
B. src/xagent/web/api/auth.py (~L1243-1245) — PKCE gated on literal provider.lower() == "salesforce"; OAuthProvider has no PKCE-capability column. An admin-created row like "salesforce-sandbox" would silently get no PKCE and fail opaquely against a PKCE-enforcing org. Suggest a capability column or at minimum .startswith("salesforce").
D. src/xagent/web/tools/config.py (~L3072-3078, ~L3374) — the env_mapping dispatch if/elif chain has no else/fallback for an unrecognized token_type; a typo'd mapping value would silently produce neither env var nor error. Also the legacy/DB-path's except _OAuthInstanceUrlRequired handler (~L3374) has no dedicated test (only the hook-path equivalent is tested).
E. src/xagent/web/tools/mcp/salesforce.py — no 429/Retry-After handling, unlike jira.py/slack.py/intercom.py. Salesforce enforces hard per-org daily API limits and commonly returns 429/503, making this the connector family where it matters most. Follow-up, not blocking.
F. example.env — the new sandbox-org note is accurate about the env var but could mislead readers into thinking there's no workaround at all; an admin can edit the oauth_providers.auth_url/token_url row directly (same path as finding B), though salesforce.py's USERINFO_URL is a separate hardcoded constant unaffected by that edit.
H. src/xagent/web/api/auth.py (~L1494-1496) — the callback uses non-strict decrypt_value() for the PKCE verifier, which silently returns raw ciphertext on decryption failure (e.g. ENCRYPTION_KEY rotation mid-flight) instead of raising. decrypt_value_strict() already exists and is documented for exactly this case.
I. src/xagent/web/api/auth.py (~L1249) — encrypt_value() doesn't swallow the ValueError get_cipher() raises when ENCRYPTION_KEY is unset in non-development. Since PKCE is exclusive to Salesforce, /api/auth/salesforce/login uniquely 500s on this misconfiguration while every other provider degrades to a plaintext-secret pass-through.
J. src/xagent/web/tools/mcp/salesforce.py _require_clean_identifier/_url_path_id (~L132-162) — byte-identical logic to hubspot.py's equivalents (~L246-275), differing only in docstrings. mcp/utils.py already exists and is already imported by salesforce.py.
M. tests/web/tools/test_salesforce_mcp.py (~L455-471) test_create_record_propagates_errors_on_failure — mocks HTTP 200 with {"success": false, "errors": [...]}, but real Salesforce create failures return non-2xx with a JSON-array body (a different, already-tested path).
Nit
G. .codespellrc — adds createable to the repo-wide ignore-words-list, when the codebase already has precedent for scoped # codespell:ignore <word> inline comments (e.g. checkin). Suggest inline suppression at the 3 salesforce.py usage sites instead.
N. tests/web/tools/test_salesforce_mcp.py (~L53-66) test_request_uses_instance_url_and_headers — uses /services/oauth2/userinfo as its example path, but in production salesforce_get_current_user always calls the fixed-host USERINFO_URL via _request_absolute, never through _request. Assertions are correct for what's tested, but the example path is misleading about actual routing.
6. Simplification Opportunities
L132-162: shrinkDuplicate_require_clean_identifier/_url_path_idvshubspot.py:246-275. Extract tomcp/utils.py.L1: shrinkGlobal codespell ignore forcreateable. Use inline# codespell:ignoreat the 3salesforce.pyusage sites instead.
net: -10 lines possible
7. Blocking Status & Recommended Decision
Blocking: yes
[prior]Finding #2 — dead refresh-timeinstance_urlcode, no session-expiry recovery path.src/xagent/web/api/auth.py:1681,src/xagent/web/tools/config.py:584-585.[new]Finding K — tautological PKCE encryption regression test.tests/web/test_generic_oauth_login.py:170-210.[new]Finding L — zero test coverage of the PKCE callback token-exchange path.tests/web/test_salesforce_oauth.py:76-87.
Recommended event: REQUEST_CHANGES
|
Two round-2 findings that don't map to a specific inline thread, addressing here: #11 (test_non_salesforce_callback_does_not_persist_instance_url can't prove the guard). The suggested fix (pre-seed an existing instance_url, assert it's preserved across reconnect) doesn't fit the actual persistence model: Codespell |
Round-2 review findings on the PKCE fix from the previous round:
- PKCE was gated on provider.lower() == "salesforce" exactly; a hand-
created row for sandbox-org support (e.g. "salesforce-sandbox", the
documented workaround for the sandbox-host limitation) would
silently get no PKCE and fail opaquely against a PKCE-enforcing org.
Switched to .startswith("salesforce").
- encrypt_value() raises ValueError when ENCRYPTION_KEY is unset
outside development; uncaught, this made /api/auth/salesforce/login
uniquely 500 with an opaque traceback while every other provider's
login route never calls encrypt_value at all. Now caught and
reported via the same clear config-error page used for a missing
client_id/secret.
- decrypt_value's lenient fallback would have silently sent the raw
ciphertext to Salesforce as code_verifier instead of failing clearly
if the encrypted value were present but corrupted/rotated (e.g.
ENCRYPTION_KEY changed mid-flight within the state token's 10-minute
window). Swapped to decrypt_value_strict, which raises
EncryptionDecodeError for exactly this case; the callback now
returns a clear "session expired, retry" page instead.
- test_salesforce_provider_includes_pkce_code_challenge's key assertion
(encrypted_verifier != code_challenge) was tautological -- a verifier
trivially differs from its own derived S256 hash regardless of
whether encryption ever ran. Fixed to assert the verifier is
genuinely different from its decrypted form and looks like a Fernet
token.
- No test exercised the full authorize-to-token-exchange round trip for
the verifier at all: removing the line that adds code_verifier to the
token-exchange POST body left every existing test passing. Added
test_callback_sends_decrypted_code_verifier_in_token_exchange, plus
an assertion that a non-PKCE callback's POST body omits the key
entirely.
… path The env_mapping dispatch if/elif chain had no else branch: a typo'd token_type value would silently forward neither an env var nor an error -- the exact opaque failure mode _OAuthInstanceUrlRequired was introduced to prevent for the instance_url case specifically. Adds a log.warning for the unrecognized-value case (launch_config is developer-authored, not user input, so this can only be a bug in this codebase's own registry). Also adds the legacy/DB-path's regression test for _OAuthInstanceUrlRequired -- only the hook-path equivalent had one.
rogercloud
left a comment
There was a problem hiding this comment.
PR summary
This PR adds a built-in Salesforce OAuth/MCP connector and persists each grant's Salesforce instance_url alongside its OAuth tokens. The callback, refresh, hook/legacy resolver, and stdio launch paths then carry that per-grant origin into a FastMCP process whose tools cover SOQL/SOSL, object metadata, and generic CRUD for standard and custom objects. It also adds the registry/seed migrations, PKCE handling, and the associated production and test wiring.
Blocking: yes — recommended event: REQUEST_CHANGES
Update summary since 671f5f667
Since 671f5f667, four commits landed: d394ecc4 hardens PKCE tests, 57ab5b3c warns on unknown env_mapping token types and adds a legacy test, 7b16bb0f validates Salesforce instance hosts, moves the Salesforce identifier helper into shared utils, and adds tests, and 38bcb49d catches missing-key ValueError during PKCE decrypt. These updates fix the previously reported hook/env and PKCE/decrypt issues, add the host-validation and Salesforce-side helper changes, and preserve error-array propagation; residual dot-segment handling, call-site coverage, and the HubSpot half of the helper migration remain open, while the output-bound and migration-provenance issues below are new confirmed blockers.
Approach verdict
Acceptable with reservations. The per-grant instance_url flow through UserOAuth, callback/refresh, hook and legacy resolution, launch environment mapping, and Salesforce stdio transport is coherent for production orgs, and the generic object tools avoid a per-object registry expansion. The reservations are material at the connector output and migration-ownership boundaries; production/sandbox realm coexistence and an explicit PKCE capability remain deferred to the tracked #1542 follow-up rather than modeled end-to-end here.
Canonical prior-status table
| Root | Verified status | Disposition |
|---|---|---|
| G01 | FIXED | Hook-path resolver now preserves instance_url. |
| G02 | NOT FIXED; DROPPED/DEFERRED | Tracked in open #1540; no duplicate follow-up here. |
| G03 | PARTIAL | Slash/query/encoded traversal and empty/whitespace validation are fixed; literal dot-segment residual is reported below. |
| G04 | FIXED | Missing instance_url now prevents a half-configured launch. |
| G05 | SPLIT | Contradictory comment is FIXED; the identity/connected_account label remains NOT FIXED and is reported below. |
| G06 | FIXED | PKCE verifier is no longer exposed in plaintext state. |
| G07 | NOT FIXED; DROPPED/DEFERRED | SOQL continuation is tracked in open #1541; no duplicate follow-up here. |
| G08 | SPLIT | Omitted-field get_record behavior is DROPPED/DEFERRED to #1541; query/list/describe output capping is confirmed and reported below. |
| G09 | SPLIT | Error propagation is FIXED; the unsupported synthetic false-success candidate is DROPPED; the create-failure test contract is severity-adjusted to minor and reported below. |
| G10 | SPLIT | Production validation is FIXED; call-site coverage is PARTIAL and reported below. |
| G11 | SPLIT | Original hardcoded-host documentation is FIXED; stronger coexistence design is DROPPED/DEFERRED to #1542. |
| G12 | REFACTORED | No residual tautological callback-test issue. |
| G13 | FIXED | Dict-shaped error handling has direct coverage. |
| G14 | DROPPED/DEFERRED | PKCE capability modeling is tracked in #1542. |
| G15 | FIXED | Unknown env_mapping token types now warn. |
| G16 | DROPPED/DEFERRED | Rate-limit handling is tracked in #1543. |
| G17 | FIXED | PKCE callback decryption is strict. |
| G18 | FIXED | Missing encryption-key handling is covered. |
| G19 | PARTIAL | Salesforce now uses shared utils; HubSpot's duplicate implementation remains and is reported below. |
| G20 | DROPPED | The author/API-field explanation is valid; no simplification finding remains. |
| G21 | FIXED | The userinfo test path is corrected. |
| G22 | FIXED | PKCE encryption tests now verify the actual contract. |
| G23 | FIXED | PKCE callback token exchange has regression coverage. |
| G24 | FIXED | The migration inspector concern is resolved. |
| G25 | DROPPED | The author/API response-shape explanation is correct. |
| G26 | DROPPED | The standalone subprocess logging contract is valid. |
| G27 | CONFIRMED [new] | Migration downgrade provenance/data-loss issue; reported below. |
| G28 | CONFIRMED [new] | Structured-error length-cap issue; reported below. |
Findings
Major
G08 [new] — Query/list/describe output is not bounded before serialization
Location: src/xagent/web/tools/mcp/salesforce.py:190 — major
This is the residual half of the prior output-bound concern; it intentionally excludes the separately tracked SOQL continuation/pagination and get_record omitted-fields work. salesforce_query, list_sobjects, and describe_sobject still pass full record, object, field, and picklist collections to _success before any connector-level bound. The generic filter can then append a truncation marker to the serialized text, producing oversized or invalid JSON for large/customizable orgs and breaking MCP/LLM consumers. Cap or project the data before serialization with a valid JSON envelope and explicit truncated state, preserving a safe continuation where the endpoint supports one.
G27 [new] — Seed downgrade can delete pre-existing operator rows
Location: src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:143 — major
upgrade() skips insertion when app_id='salesforce' or provider_name='salesforce' already exists, but downgrade() unconditionally deletes any public_mcp_apps row with that app ID. Its provider guard matches only the provider name, display name, and standard auth/token URLs while intentionally ignoring credentials; deleting the app first can also make the remaining-app guard pass, allowing both pre-existing operator rows to be removed. Track migration ownership/provenance, or conservatively preserve rows that predated the upgrade, and add same-ID, standard-endpoint, and combined-collision upgrade/downgrade tests.
Minor
G03 [prior] — Exact dot-segment identifiers still collapse URL paths
Location: src/xagent/web/tools/mcp/utils.py:35 — minor
The shared helper fixes the prior slash/query/encoded-traversal and empty/whitespace cases, but quote(value, safe="") leaves exact . and .. unchanged. Requests normalizes those URL dot-segments, so an identifier of .. can collapse the Salesforce API path, including for DELETE-capable callers. Reject exact ./.. before quoting and add regression and prepared-request tests.
G05-identity [prior] — Connected Salesforce grants still have no account label
Location: src/xagent/web/builtin_mcp_registry.py:236 — minor
The contradictory provider comment is fixed, but the identity behavior is not: userinfo_url remains empty, so callback skips the fixed USERINFO_URL, persists NULL email/provider_user_id, and /api/mcp/apps reports the app as connected without connected_account. Either populate identity from the fixed endpoint or a verified token response, or explicitly make the unlabeled state the tested and documented API/UI contract.
G09-M [prior] — Create failure test does not exercise the real failure contract
Location: tests/web/tools/test_salesforce_mcp.py:517 — minor
The prior errors-array propagation fix is present, but this test uses MockResponse's default HTTP 200 and asserts only errors. It therefore does not exercise the documented single-record create failure path (a non-2xx response with an error array) or prove the create-level status == "error" and message contract. Use a non-2xx mock at the salesforce_create_record boundary and assert the complete error envelope; keep any synthetic HTTP-200 success:false check as optional defensive coverage rather than the endpoint's primary contract.
G10 [prior] — Two identifier-validation call sites lack negative coverage
Location: tests/web/tools/test_salesforce_mcp.py:121 — minor
The production validation fix is present, but call-site tests still omit empty sobject_type for get_record and delete_record. Add both cases, assert an error, and assert that requests.request was not called, so either call site cannot regress while the shared-helper unit test remains green.
G19 [prior] — HubSpot was not migrated to the new shared helper
Location: src/xagent/web/tools/mcp/utils.py:7 — minor
The Salesforce copy was moved into mcp/utils.py, but HubSpot still carries byte-identical private helpers and call sites. That leaves two implementations that can diverge on future validation or encoding fixes. Finish the migration by importing the shared helpers in HubSpot, deleting its private definitions and direct quote import, and retaining provider integration assertions while moving direct helper assertions to shared utils.
G28 [new] — Structured Salesforce error details bypass the length cap
Location: src/xagent/web/tools/mcp/salesforce.py:109 — minor
_extract_error_detail joins every message in a structured error array without applying MAX_ERROR_RESPONSE_TEXT_CHARS, while _request_absolute caps only the raw-text fallback when extraction returns None. Long or numerous structured errors can therefore inflate logs and MCP/LLM-facing error JSON despite the stated bound. Apply the final cap and a clear truncation marker to structured details as well, and add regression coverage for long single- and multi-item arrays.
Blocking status & recommended decision
[new]src/xagent/web/tools/mcp/salesforce.py:190— major — query/list/describe results can exceed the connector boundary and become invalid JSON after generic truncation (G08).[new]src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:143— major — downgrade can delete pre-existing colliding app/provider rows (G27).
Blocking: yes — recommended event: REQUEST_CHANGES
…n downgrade Addresses this round's review findings: - salesforce_query/search/list_sobjects/describe_sobject built their JSON string via a shared _success() without any size bound. A large org's SOQL result, sobject list, or object schema can serialize past the generic output filter's per-string character threshold and get hard-truncated there into invalid JSON. Added a _success_with_capped_list() halving helper (matching hubspot.py's existing _paged_list/_success_with_capped_dict pattern for the exact same failure mode) and applied it at all four call sites -- the reviewer only cited three, but salesforce_search has the identical unbounded-list shape and was equally exposed. - url_path_id()'s percent-encoding closes off "/" and "?" but not a literal "." or "..": those are always-unreserved characters that quote() never touches, and requests/urllib3 normalize dot-segments out of the final URL before sending it. Verified directly: a record_id of ".." collapses ".../sobjects/Account/.." to ".../sobjects/", a different (still valid) endpoint. Now rejected explicitly in the shared mcp/utils.py helper. - hubspot.py's own private _require_clean_identifier/_url_path_id were byte-identical duplicates of what got moved to mcp/utils.py earlier this round -- finishing that migration (import the shared versions, delete the private copies) means hubspot also picks up the dot-segment fix as a side effect, closing the same latent gap there as a bonus rather than a second fix. - _extract_error_detail's structured (JSON array) error messages bypassed MAX_ERROR_RESPONSE_TEXT_CHARS entirely; only the raw-text fallback branch was capped. Applied the cap to both. - The seed migration's downgrade() deleted the public_mcp_apps row by app_id unconditionally, without the shape guard its own oauth_providers deletion already has. A pre-existing operator row that happened to occupy app_id "salesforce" before this migration ever ran (upgrade()'s own existence check would have skipped inserting over it) would be deleted anyway, and that deletion could then make the remaining-apps count wrongly read as zero, letting the oauth_providers row underneath it be deleted too. Added the same shape guard used for the provider row. - Added regression tests for all of the above, plus the two remaining empty-sobject_type/record_id call-site gaps (get_record, delete_record) and a realistic non-2xx create-failure test (the existing one only covered a synthetic 200-with-success:false shape). - Added a test proving Salesforce's "connected but unlabeled" account state (userinfo_url intentionally left empty) at the actual /api/mcp/apps-equivalent surface, not just the UserOAuth row -- mirroring Meta's existing test for the same pattern. jira.py's _path_segment and intercom.py's inline quote() calls have the same unguarded dot-segment gap url_path_id had, but weren't touched: consolidating those isn't a mechanical no-op like hubspot's was (jira's helper has different empty-value/type-coercion behavior), and both are already-shipped, unrelated connectors this PR doesn't otherwise own.
Update since 38bcb49Addressed this round's review (commit 2eac27d): Major
Minor
Not fixed, filed as follow-ups (both are pre-existing gaps in already-shipped, unrelated connectors, not introduced by this PR):
All touched test suites (~280 tests across salesforce/hubspot/oauth/encryption/migrations) pass. |
…lient-credentials-449d8c # Conflicts: # example.env # src/xagent/web/builtin_mcp_registry.py
rogercloud
left a comment
There was a problem hiding this comment.
Follow-up Review — Salesforce connector (OAuth App + custom MCP tools)
What this PR does
Adds a Salesforce connector built on the existing static-client OAuth + custom FastMCP pattern already used by GitHub, Linear, Jira, Slack, Zoom and HubSpot: a seeded oauth_providers + public_mcp_apps row pair, plus src/xagent/web/tools/mcp/salesforce.py wrapping SOQL query, SOSL search, sobject listing/describe, and full record CRUD.
The genuinely new architectural piece is that Salesforce's token response carries a per-org API host (instance_url) that every subsequent API call must target — something no prior connector in this codebase needed, since all of them required only an access token. That drove a new user_oauth.instance_url column, persistence on connect and refresh, and threading the value through the OAuth resolver / launch-config plumbing shared by every connector. The PR also lands this codebase's first PKCE implementation, gated to the Salesforce provider.
Update since the last round
The last full round was the 28-item G01–G28 table. This pass re-verified every prior finding from scratch against the current code rather than trusting the prior round's claims. Result: the large majority of prior findings are solidly FIXED with real regression tests; 4 items remain open but are explicitly deferred to tracked GitHub issues; 4 author rebuttals were independently checked and hold up. This pass also found one place where a prior round's "FIXED (tested)" claim was inaccurate (the env_mapping else branch — see minor findings), and surfaced 6 findings no prior round raised, one of which is blocking.
Round 0 design verdict — acceptable with reservations
The design is correct on the paths it covers, unusually well tested for a connector of this size, and the shared-plumbing changes are additive and backward compatible. Two reservations:
-
Token lifetime gap. Because Salesforce's token response has no
expires_in,expires_atstays NULL and the refresh path is never entered — which means the connector's own headline feature (re-persistinginstance_urlon refresh) is effectively unreachable in production. Deferred to issue #1540; noted here because it is a design-level, not cosmetic, consequence. -
Generalization of
instance_url(non-blocking observation). A reasonable concern is whether this should have been a generic per-provider slot rather than a Salesforce-shaped field baked into shared OAuth infra. Independent checking softens the specific critique: the proposed alternative — discoveringinstance_urlat runtime via a userinfo "urls map", following the Jira/Intercom precedent — does not actually apply here, because Salesforce returnsinstance_urlin the token-exchange response for free, and the alternative would add a round-trip the current design avoids. The Jira/Intercom comparison remains directionally valid (per-org routing solved one layer down without a schema change is an established pattern here), but it is an observation, not a finding requiring a change.
Prior findings — verified status
FIXED (verified against current code; no action needed)
- Hook-path OAuth resolver now threads
instance_urlthrough —ResolvedToken/_ResolvedHookTokengained the field and both call sites insrc/xagent/web/tools/config.pypass it. Tested. env_mappingno longer silently drops the env var wheninstance_urlis falsy — it raises_OAuthInstanceUrlRequiredand surfaces a reconnect signal (src/xagent/web/tools/config.py:3075-3078). Both call sites tested.- Path/query injection via unencoded path segments closed by shared
url_path_id()/require_clean_identifier()insrc/xagent/web/tools/mcp/utils.py, which explicitly reject./..dot-segments, applied at every CRUD call site. Tested. - PKCE
code_verifieris now Fernet-encrypted (not merely JWT-signed) before entering the OAuthstate, and decrypted withdecrypt_value_strictin the callback. Tested — and the previously tautological encryption regression test was fixed to actually assertencrypted != decryptedand_is_encrypted(...). - The PKCE token-exchange path is no longer untested: a real test asserts the decrypted verifier is sent in the token-exchange POST body.
- Query / search / list_sobjects / describe_sobject output is bounded via a shared
_success_with_capped_list()halving-loop helper applied at all four call sites. Tested. salesforce_create_recordpreserves theerrorsarray on failure instead of discarding it, and the unrealistic "200-with-errors" mock was replaced by a genuine non-2xx failure test.- Non-empty validation on
record_id/sobject_typeadded via the shared identifier helper at every CRUD call site, including the previously missingget_record/delete_recordempty-string cases. - Dict-shaped error responses in
_extract_error_detail(OIDC userinfo path) now have dedicated coverage. - Misleading test example path for
salesforce_get_current_userrouting corrected; routing behavior is accurately tested. - Structured (array-shaped) Salesforce error details now respect
MAX_ERROR_RESPONSE_TEXT_CHARS, matching the raw-text fallback. - Seed migration
downgrade()now guards on the full expected row shape before deleting, mirroringupgrade()'s skip logic, so pre-existing operator rows are safe. Tested; the migration head graph independently resolves to exactly one head with no orphaned branches. - Migration inspector calls moved off legacy
Inspector.from_engine(bind)tosa.inspect(bind)at both call sites. - The Salesforce identity /
userinfo_urlgap is now a deliberate, documented and tested contract:connected_accountintentionally reports no label for Salesforce. One documentation-precision note for the record — the earlier round's comparison to "Meta's pattern" was imprecise (Meta does populateuserinfo_urland reachesNonethrough an unrelated policy-check path). Linear is the accurate analogue: it also has an emptyuserinfo_urland no eager identity lookup. No action needed. hubspot.pyfinished migrating onto the sharedmcp/utils.pyidentifier helpers (via re-exported aliases to preserve existing test call sites — cosmetic indirection only).- The redundant second
sa.table()definition foroauth_providersin the seed migration was already dropped later in this same PR (3f3a35d58); the migration now matches the sibling Jira seed migration. Not re-raised.
PARTIAL (one minor open item)
- PKCE decrypt error path — test gap. Decryption was correctly hardened to
decrypt_value_strict(raises on bad ciphertext rather than silently passing it through) and the missing-encryption-key branch is covered, but no test exercises the corrupted/tampered-ciphertext-with-key-present branch (InvalidToken→ error path).src/xagent/web/api/auth.py(PKCE callback decrypt, ~L1600-1620); test filetests/web/test_salesforce_oauth.py. Code is correct; this is coverage only.
DROPPED — author rebuttals independently verified as correct
test_non_salesforce_callback_does_not_persist_instance_urlbeing tautological: verified the guard branch it originally targeted was genuinely removed (the row is always freshly recreated on each callback; no update/merge path remains), so there is nothing meaningful left to assert. Rebuttal holds..codespellrcglobalcreateableignore entry:createableis a real Salesforce API field name, and one of its three occurrences sits inside an LLM-facing tool docstring where an inline suppression cannot cleanly apply. Rebuttal holds.- SOSL
result.get("searchRecords")response shape: code and tests are internally consistent with a dict-shaped response; this is external API contract not verifiable from the codebase, and nothing here contradicts it. Dropped. logging.basicConfig()insalesforce.py: the module is only ever a standalone subprocess entrypoint and is never imported into the main app; five spot-checked sibling connectors use the identical pattern. Rebuttal holds.
Still open, but deferred to tracked issues (not blocking, not re-raised)
- Tokens are never refreshed — no
expires_inmeansexpires_atstays NULL, andrefresh_oauth_token_if_neededtreats NULL as "assume valid", so the refresh path (including instance_url-on-refresh) is unreachable in production; the connector will silently 401 after the org's session timeout with no reconnect signal.src/xagent/web/api/auth.py:1859-1866,src/xagent/web/tools/config.py:582-585,709-714. → #1540 - No SOQL pagination beyond 2000 rows (
nextRecordsUrldropped, only atruncatedflag surfaced) and no field cap onget_record.src/xagent/web/tools/mcp/salesforce.py(salesforce_query, ~L216-227). → #1541 - No sandbox-org support / no PKCE capability modeling —
login.salesforce.comis hardcoded with no per-user toggle, and PKCE gating usesprovider.lower().startswith("salesforce")string matching rather than a capability column. Note: the code did change from==to.startswithsince an earlier round, but the structural fix never landed. Real-world risk is low today because provider rows come from a fixed developer-controlled seed set.src/xagent/web/builtin_mcp_registry.py:222-223,src/xagent/web/api/auth.py:1320-1322. → #1542 - No 429 / Retry-After rate-limit handling, unlike sibling connectors
jira.py,slack.pyandintercom.pywhich all implement it.src/xagent/web/tools/mcp/salesforce.py. → #1543
New findings this pass
MAJOR — unguarded destructive delete tool, broader blast radius than any peer connector, no explicit product sign-off
salesforce_create_record / salesforce_update_record / salesforce_delete_record (src/xagent/web/tools/mcp/salesforce.py:346-421) accept any sobject_type string with only URL-safety validation via url_path_id — no business-level allowlist, no dry run, no confirmation step.
Verified repo-wide: the HTTP DELETE method appears in only three connector modules — onedrive.py (a personal file), outlook.py (a personal calendar event), and salesforce.py (an arbitrary standard or custom business record in a shared org). Salesforce is the only connector able to delete shared business-of-record data. HubSpot, the other CRM connector in this codebase, deliberately exposes zero delete-capable tools (its tool surface is search / get / create / update / notes / analytics only).
There is also no generic mitigation one layer up: no confirmation, allowlist, or HITL gating layer exists anywhere in the codebase. The closest thing found is a docstring on an unrelated Gmail-send tool claiming it "triggers an interactive user confirmation" — that claim has no backing implementation in this repo.
Recommendation: make this an explicit author/product decision rather than something that ships silently. Either scope salesforce_delete_record (and arguably create/update) behind an explicit sobject-type allowlist or a confirmation gate, or drop the delete tool the way HubSpot does.
MINOR — instance_url validation has real, if narrow, gaps
- Persist time, no validation at all.
src/xagent/web/api/auth.py:1859andsrc/xagent/web/tools/config.py:709-714write the token-response-suppliedinstance_urlstraight to the DB with no type or non-empty check — in contrast to the hook-path resolver atsrc/xagent/web/tools/config.py:2929-2932, which does checkisinstance(..., str)and non-empty. - Use time, incomplete parsing.
_instance_url()insrc/xagent/web/tools/mcp/salesforce.py(~L75-104) checksscheme == "https"and a hostname suffix ofsalesforce.com/force.com, but never checks port, path, query, or userinfo. Since the validated value is used as a raw string prefix (f"{_instance_url()}{path}"), values likehttps://acme.my.salesforce.com/evil/pathorhttps://user:pw@acme.my.salesforce.compass validation and their extra components flow silently into every outbound request URL. force.comin the allowlist has no documented justification narrower than "known Salesforce domain". It is considerably broader than the*.my.salesforce.comAPI-instance pattern and covers Salesforce Sites / Experience Cloud pages that can serve customer-authored content.
The scheme + host check does block the primary SSRF vector (redirecting the Bearer token to a wholly different origin). What is missing is finer hardening: canonicalize to scheme://hostname before use, reject path/query/userinfo, check port, tighten or document the force.com inclusion, and add to the DB-persist path the same type/non-empty check the hook path already performs. Severity minor: defense in depth, not immediately exploitable given the value originates from Salesforce's own TLS-protected token endpoint.
MINOR — an env_mapping branch was reported "FIXED with a test" in a prior round but is untested and effectively dead
src/xagent/web/tools/config.py:3079-3092 logs a warning for an unrecognized env_mapping.token_type. Verified: (1) no test anywhere exercises this branch — the test cited by the prior round (test_legacy_missing_instance_url_retains_unavailable_server) covers a different, pre-existing branch (the instance_url-required exception), as its own docstring states; (2) token_type is strictly internal — every env_mapping entry in builtin_mcp_registry.py is a fixed string literal ("access_token" or "instance_url"), and no external input, user, or admin action can produce a third value. The branch is reachable only via a typo in this repo's own source.
Recommendation: either delete the branch, or add a real test if it is being kept as defensive tooling. What should not persist is a branch a past review recorded as test-covered when it is not. No functional impact — the behavior, if ever reached, is a harmless warning log.
MINOR — example.env sandbox-workaround note is factually wrong and incomplete
The note at example.env:657-666 claims that editing an oauth_providers row's auth_url / token_url to point at test.salesforce.com is "harmless" with respect to salesforce.py's USERINFO_URL, on the grounds that the oauth_providers.userinfo_url column is unused. This conflates two different things: the DB column is indeed unused, but the module-level constant USERINFO_URL (hardcoded to the production login host) is used unconditionally by salesforce_get_current_user on every call via _request_absolute. Following the documented workaround would send a sandbox-issued token to the production userinfo endpoint, which fails — the opposite of "harmless".
The note also omits that a working sandbox setup needs a second public_mcp_apps row with its own app_id / launch_config / env_mapping, not just a second oauth_providers row; public_mcp_apps is never mentioned anywhere in example.env. This was raised previously and is still unfixed.
MINOR — instance_url parameter design in _build_oauth_mcp_stdio_transport_config
At src/xagent/web/tools/config.py:3053-3060, instance_url is an optional keyword parameter defaulting to None, yet both current call sites (hook path and legacy DB path) always pass it explicitly. The default buys nothing today and lets a future call site silently omit it, surfacing as a confusing user-facing "reconnect required" error instead of a call-time failure. Making it required keyword-only is safe given current callers.
Related: the two except _OAuthInstanceUrlRequired handlers (~L3330-3341 and ~L3388-3399) are duplicated boilerplate apart from their log line; a small extracted helper (e.g. _oauth_token_required_response(server, log_msg)) would remove the duplication without merging the two otherwise-distinct call paths.
MINOR (cosmetic) — two small nits in salesforce.py
- The comment at ~L26-30 states Salesforce "keeps every past API version working indefinitely". That is an absolute claim; Salesforce has historically retired old REST API versions. A hedged phrasing ("supported for many years") would be more accurate. No functional impact.
_extract_error_detail's array-error fallbackstr(item.get("message") or item)(~L133) emits a Python dict-repr string (e.g.{'message': '', 'errorCode': 'X'}) when an error element has an empty-stringmessage. Cosmetic, bounded by the existing length cap, low real-world likelihood.
Simplification opportunities
src/xagent/web/api/auth.py:L1321: yagni: PKCE gate matches provider names via string prefix (`.startswith("salesforce")`) instead of a capability flag on the provider row; no second provider exists yet to justify pattern-matching at all. Add a `requires_pkce` column (or gate on `auth_url` host) instead — tracked in issue #1542, no code change landed yet.
src/xagent/web/tools/config.py:L3079-3092: delete: unrecognized-`env_mapping`-token_type warning branch guards a typo only this codebase's own developer-authored registry could produce; no test exercises it and no third token_type value is ever registered anywhere. Delete the branch (or assert at registry-load time) instead of carrying it as untested dead code.
net: ~14 lines possible
Blocking status & recommended decision
Blocking: yes — the unguarded destructive delete tool is the sole major finding of this pass; everything else is minor, deferred to a tracked issue, or already fixed.
Recommended event: REQUEST_CHANGES
Blocking issues:
src/xagent/web/tools/mcp/salesforce.py:404-421— major — unguarded destructive delete tool with no allowlist/confirmation and broader blast radius than any peer connector — [new]
…dings Product decision on this round's sole blocking finding: salesforce_delete_ record could delete any standard or custom business record in a shared org with no allowlist, confirmation, or dry-run -- the only connector in this codebase able to delete shared business-of-record data (onedrive/ outlook's DELETE only touch a personal file/calendar event). No generic confirmation/HITL gating layer exists anywhere to lean on, and HubSpot, the other CRM connector here, ships zero delete-capable tools. Removed salesforce_delete_record and its tests, matching HubSpot's approach. Also, from this round's other findings: - _instance_url() now canonicalizes to scheme://host[:port] instead of returning the input string as-is -- it's used as a raw prefix for every outbound request URL, so a value like ".../salesforce.com/evil/path" or a userinfo-embedded value previously passed the scheme+host check and then rode along into every request. Dropped force.com from the allowed host suffixes: it hosts Salesforce Sites/Experience Cloud pages that can serve customer-authored content, and the OAuth token endpoint's instance_url is always a *.salesforce.com host in practice -- force.com only widened the check beyond what Salesforce actually sends. - Added the missing regression test for the env_mapping dispatch's unrecognized-token_type warning branch, and corrected the comment claiming it's developer-only: POST /admin/mcp/apps accepts launch_config as an unvalidated free-form dict, so an admin's hand-typed custom OAuth app can reach it too. - Added the missing PKCE-decrypt regression test for a corrupted/foreign- key ciphertext (EncryptionDecodeError), distinct from the already-tested missing-key case (bare ValueError). - Corrected example.env's sandbox-workaround note: it claimed switching to test.salesforce.com was harmless with respect to salesforce.py's USERINFO_URL, conflating the unused oauth_providers.userinfo_url DB column with the separate hardcoded USERINFO_URL constant salesforce_get_current_user() actually calls unconditionally -- a sandbox-issued token sent there fails outright. Also noted the missing second public_mcp_apps row a working sandbox setup needs. - _extract_error_detail falls back to errorCode before a Python dict-repr when a structured error item's message is empty. - Hedged a comment overclaiming Salesforce keeps every API version working "indefinitely".
Self-review of the last commit's _instance_url() canonicalization found it introduced a real bug: urlparse().port is a lazy property that raises a bare ValueError for a non-numeric port, and that access happened after the scheme/host validation but with no guard of its own, so a malformed port (e.g. "...salesforce.com:abc") crashed with urllib's cryptic "Port could not be cast to integer value" instead of this function's own clear "not a valid Salesforce host" message. Caught explicitly now, with a regression test. Also from the same review: flattened the new errorCode-fallback in _extract_error_detail back into a single list-comprehension expression (a 10-line closure had crept in for a one-clause change), and added the trailing-dot-hostname regression test the canonicalization change needed but didn't get -- returning a reconstructed string instead of the raw input silently changed that case's behavior with zero coverage either way.
Update since 3644bf7Addressed the follow-up review (commits 3644bf7, fbad311). Blocking (product decision)
Minor
Self-review catch, not from the review round
Not fixed, no new issues filed this round (both already covered by open issues, or judged out of scope with no practical impact):
All touched test suites (~320 tests across salesforce/hubspot/oauth/encryption/migrations) pass. |
rogercloud
left a comment
There was a problem hiding this comment.
This PR adds a Salesforce OAuth/MCP connector on top of the existing provider, app, UserOAuth, and stdio-launch abstractions. It introduces a nullable per-grant instance_url, threads it through callback/refresh and legacy or hook-based resolution, and exposes eight Salesforce tools for identity, query/search, schema browsing, and record read/create/update operations. The current head intentionally omits destructive delete, but the PR description and test plan still describe full CRUD and nine tools.
Approach verdict
Acceptable with reservations (independent Round 0 verdict). The per-grant instance_url sidecar is a coherent extension of the existing OAuth/MCP architecture: it follows the grant through persistence, refresh, resolver paths, launch environment mapping, and the final Salesforce origin check without creating a second authorization system. The material reservations are seed credential ownership and rotation, migration provenance, callback/refresh persistence invariants, concurrent NULL-identity grants, and irrecoverable output truncation. This is an independent design judgment, not a conclusion derived from whether earlier review comments were claimed to be fixed.
Findings
Major — new — Seeded Salesforce credentials are plaintext and freeze environment rotation
Location: src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:63-67; the duplicate registry seed is at src/xagent/web/builtin_mcp_registry.py:212-224.
Both seed paths copy SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, and SALESFORCE_REDIRECT_URI directly into oauth_providers. The migration/registry insertion does not use the encryption path used by admin-created providers, so the client secret can be plaintext at rest. More importantly, the OAuth resolvers prefer any non-empty stored value over the environment, so rotating the Connected App values in SALESFORCE_* leaves the old snapshot authoritative and silently makes the new deployment configuration ineffective.
Specific suggestion: Keep built-in credential and redirect fields empty so the existing environment fallback remains live, while retaining encrypted values only for explicitly admin-managed rows; or add an explicit encrypted built-in ownership/reconciliation and rotation policy. Add tests for both seed paths and an A-to-B environment rotation. Encrypting only the current insert is insufficient because DB-first precedence would still block rotation.
Major — prior, not fixed — Salesforce seed downgrade can delete operator-owned rows
Location: src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:108-130,153-186.
Upgrade skips an existing provider_name='salesforce' or app_id='salesforce', but downgrade deletes an app using only app_id/name/transport/provider_name and a provider using only provider_name/name/auth_url/token_url. An operator-owned row with those canonical stable values but custom scopes, description, visibility, launch configuration, credentials, redirect URI, or other fields is therefore skipped on upgrade and deleted on downgrade; deleting the app can then make the provider deletion eligible too.
History and re-check: This is the same G27 root raised at discussion_r3821738710 and recorded as major in review 4982943190. A later follow-up review 4989514308 marked G27 fixed, and the author said in issuecomment-5358522780 that the same-shape guard addressed it while pointing to #1559. I re-checked the current predicates: that previous FIXED claim was incomplete because the guard is only a partial shape check and does not establish migration ownership. #1559 concerns other connector migrations and does not make this Salesforce downgrade safe.
Specific suggestion: Record durable migration ownership (for example, inserted row IDs or an ownership ledger) and delete only rows proven to belong to this revision. Otherwise make downgrade conservative and preserve ambiguous rows. Add a fixture with pre-existing canonical app/provider identifiers and endpoints but custom scopes, launch configuration, description, credentials, and redirect URI, then assert that the complete rows survive upgrade and downgrade.
Major — new — OAuth callback can report success after replacing a grant with no usable instance_url
Location: src/xagent/web/api/auth.py:1821-1859 (anchor :1859); the transaction commits at :1947 and returns the success page at :1968-1988.
The callback validates only that an access token exists. It deletes the previous UserOAuth row before assigning token_data.get("instance_url"), creates the MCP association, commits, and returns Connected Successfully even when the Salesforce response omits instance_url or supplies an empty value. The later launch builder rejects the missing mapped value, but only after the old usable grant has been destroyed and the user has been told the connection succeeded.
Specific suggestion: Before deleting or replacing the prior grant, derive required fields from the target app's launch_config.env_mapping and reject a missing, empty, or malformed instance_url. Preserve the prior grant on that error, keep the launch-time check as defense in depth, and add missing/empty callback tests that assert no destructive mutation and an error response.
Major — new — Empty Salesforce userinfo configuration permits concurrent NULL-identity grants
Location: src/xagent/web/builtin_mcp_registry.py:236; the delete/reinsert sequence is in src/xagent/web/api/auth.py:1820-1829, and the uniqueness definition is in src/xagent/web/models/user_oauth.py:12-35.
The intentional userinfo_url="" leaves provider_user_id NULL for every Salesforce grant. The existing composite unique constraint does not enforce uniqueness for repeated NULL values, and callback uses an unlocked delete-then-insert sequence. Concurrent PostgreSQL callbacks can therefore leave multiple Salesforce rows for one user/provider; .first() and unordered listing consumers can select an arbitrary token, org host, or account state.
Specific suggestion: If the contract is one Salesforce grant per user, enforce a provider-level unique key and use an atomic upsert/update or row/advisory lock with conflict handling. If multiple org accounts are intended, persist a verified Salesforce account identity and make uniqueness and every selector account-aware. Add a concurrent-connect regression test that asserts the chosen invariant and deterministic resolution.
Major — new — Local output capping returns irrecoverable prefixes
Location: src/xagent/web/tools/mcp/salesforce.py:42-65 (anchor :61); call sites are :250-345.
_success_with_capped_list() repeatedly halves the list until the JSON string fits, then returns only the prefix with truncated=true. Query results (including a page where Salesforce reports done=true), SOSL results, sObject listings, and describe fields have no offset, cursor, projection, or continuation contract to recover the omitted tail. This is distinct from the fixed prior invalid-JSON/oversize root and from tracked #1541's upstream nextRecordsUrl/get-record scope: valid JSON is now produced, but valid data can still be silently and permanently lost.
Specific suggestion: Replace the lossy generic halving contract with endpoint-specific recoverability: stable limit plus opaque cursor/offset/filter for search, listing, and describe; a local page cursor in addition to any upstream query continuation; or an explicit bounded projection/rejection when safe continuation is impossible. Return has_more and a usable continuation token, and test that a follow-up call retrieves omitted records, objects, fields, or hits without overlap or loss.
Minor — prior, confirmed — Refresh and initial persistence still accept malformed instance_url metadata
Location: src/xagent/web/tools/config.py:709-714; the same persistence invariant is present on the newly recreated callback row at src/xagent/web/api/auth.py:1859.
On a 200 refresh response containing an instance_url key, the refresh path assigns any value, flushes it, logs success, and returns True. An empty, non-string, or malformed value can replace a valid stored host; the later _instance_url() check only fails when the connector is used, after refresh has committed. The callback occurrence is the fresh-row/state-corruption facet of this same persistence root; the callback's broader old-grant-loss and false-success ordering is reported separately above.
History and re-check: This is the prior persistence finding at discussion_r3827114691, also recorded in review 4989514308. The author explicitly declined persist-time validation in issuecomment-5365510264, arguing that use-time _instance_url() validation is authoritative. I re-checked that claim: it mitigates outbound origin misuse, but it cannot preserve a valid host after refresh mutation/commit or prevent an initial callback from committing unusable state. The hardened use-time boundary is why this remains minor, and it is not the separately tracked #1540 refresh-recovery root.
Specific suggestion: Validate type, non-empty value, and the connector-appropriate origin before either writer mutates persistence. On refresh, preserve the old valid host and return a controlled failure/reconnect outcome when the response is malformed; add initial-callback and refresh tests for empty, null, non-string, malformed, and invalid-origin values.
Minor — new — Output-cap tests allow a real limit violation
Location: tests/web/tools/test_salesforce_mcp.py:468; the same assertion is at :506, :555, and :624.
Each test parses the production string, reserializes the resulting object with different formatting, and allows 2000 + 200 characters. The actual production helper enforces an exact raw limit, and the concrete two-item fixtures produce raw envelopes of 2121, 2103, 2166, and 2199 characters, so these assertions can pass while the output filter still hard-truncates the response into invalid JSON.
Specific suggestion: Capture the raw return value before json.loads, assert len(raw) <= 2000 (or the configured limit) exactly, then parse it. Apply the same pattern to all four cap tests and retain assertions for valid JSON and the expected truncation signal.
Minor — new — Alembic merge annotations describe a tuple as scalar-only
Location: src/xagent/migrations/versions/0b38b8d46e1c_merge_salesforce_and_jira_mcp_app_.py:13-16 and src/xagent/migrations/versions/c97b6332a895_merge_salesforce_and_linear_mcp_app_.py:13-16.
Both new merge revisions assign a two-parent tuple to down_revision: Union[str, None]. Alembic's runtime graph is valid, but the declared static contract is false and differs from neighboring merge revisions that include Sequence[str].
Specific suggestion: Use Union[str, Sequence[str], None] in both new merge files (and update the migration template separately so future generated merge revisions use the same accurate annotation).
Minor — new, documentation-only — PR scope and test-plan text is stale
Location: PR description:2 and PR description:13. This is not a code bug and has no inline anchor because PR metadata is not part of the diff.
Current HEAD exposes eight tools and intentionally removed salesforce_delete_record, but the summary still says “full record CRUD” and the test plan still says “all 9 Salesforce tools.” The removed delete implementation is not being re-reported; the actionable issue is only that the public scope and coverage claim are stale.
Specific suggestion: Update the PR summary and test plan to say that eight non-destructive tools expose query/search/schema plus read/create/update, with delete intentionally omitted. Synchronize the runtime catalog and frozen seed descriptions if they are intended to be user-facing.
Criteria, no-issue checks, and limitations
- Correctness, security, edge cases, and performance are covered by the five major findings and the persistence minor; in particular, the origin allowlist, path encoding, encrypted PKCE state, bounded HTTP timeouts, and token redaction were checked without identifying an additional issue.
- Maintainability is covered by the seed ownership/rotation findings and the false merge annotations. The existing sidecar threading and shared resolver/launch abstractions otherwise match repository conventions.
- Test quality is covered by the raw-limit assertion gap and the missing regression cases called out in the major findings. No additional resource-lifecycle or injection issue was confirmed.
- This consolidation was static: no local tests, build, lint, formatter, typecheck, or Alembic commands were run. Preflight CI was reported as 14 completed SUCCESS checks with no failures or pending checks.
- The Simplification Lens was unavailable because the review-spark worker exhausted its usage limit; no Simplification opportunities section or invented simplification finding is included.
- Tracked #1540 (refresh recovery), #1541 (upstream continuation/get-record field cap), #1542 (sandbox/PKCE capability), and #1543 (429 handling) are not re-reported. Fixed or dropped roots, including the prior invalid-JSON root and the removed destructive-delete security root, are likewise not re-reported. No qinxuye rebuttal exception applies to this author.
Blocking status & recommended decision
Blocking: yes
Recommended event: REQUEST_CHANGES
Blocking issues:
src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:63-67— major — plaintext built-in credentials and stale DB-first rotation behavior [new]src/xagent/migrations/versions/20260818_seed_salesforce_mcp_app.py:153-186— major — downgrade can delete upgrade-skipped operator rows [prior]src/xagent/web/api/auth.py:1859— major — callback commits unusable state and reports success after deleting the prior grant [new]src/xagent/web/builtin_mcp_registry.py:236— major — concurrent NULL-identity callbacks can leave arbitrary token/org selection [new]src/xagent/web/tools/mcp/salesforce.py:61— major — local capping permanently drops valid result data without recovery [new]`
Addresses this round's review findings that are in-scope and caused by this connector's own code (see PR comment for the full list, including the two judged out of scope with reasoning and tracking issues xorbitsai#1586 and the xorbitsai#1541 update): - generic_oauth_callback now rejects a Salesforce token response with a missing, empty, or non-string instance_url *before* the delete-then- recreate persistence step runs. Letting it through previously deleted any prior working grant, committed unusable state (the connector can't launch without instance_url), and still reported "Connected Successfully" -- silently destroying a working connection instead of failing loudly. Same non-string/malformed check added to the refresh path in config.py, which now keeps the prior valid instance_url and logs a warning instead of overwriting it with garbage. - Salesforce's userinfo_url is deliberately left empty (an earlier, already-tested design decision), which means provider_user_id stayed NULL for every grant -- and UserOAuth's (user_id, provider, provider_user_id) unique constraint treats repeated NULLs as non-conflicting, unlike every other provider where a real provider_user_id gives that constraint teeth. The token response's own "id" field (Salesforce's identity URL) now fills provider_user_id for free, no extra network call, closing that gap without touching the already-tested "no account label" contract (driven by email, not provider_user_id). - The seed migration's downgrade() guard only matched a handful of structural columns (name/transport/provider_name for the app row; name/auth_url/token_url for the provider row), so an admin who edited the seeded row's scopes/description/launch_config/visibility without touching those few fields would still match and get silently deleted. Now matches every non-env-dependent column on both rows. - _success_with_capped_list() now says explicitly, when it actually halved a result, that the dropped items are gone for that call and cannot be recovered -- not just a bare truncated:true that could read as "retry for more". Fixed a real bug this introduced: appending that message after the size-check loop had already verified a fit could push the final response back over the limit, re-creating the invalid-JSON risk the whole helper exists to prevent; now the message is included in the same size check, halving further if needed. - Tightened the four output-capping tests to assert the exact raw returned string's length against the limit, not a reparsed-and- reserialized approximation of it that could pass while the real string still violated the limit. - Fixed both new alembic merge revisions' down_revision type annotation (Union[str, None] -> Union[str, Sequence[str], None] to match the tuple actually assigned there, and every neighboring merge revision).
…e comment _success_with_capped_list could still exceed the configured output limit when halving already emptied items and the fixed truncation message text was itself the cause -- now drops the message as a last resort instead of returning an oversized payload. Also corrects the downgrade() comment in the seed migration, which overstated how the seeded row's oauth_scopes/ launch_config can diverge via the admin API (they're PATCH-protected for built-in apps; the real risk is a raw DB edit or the app_id later being dropped from the built-in registry).
|
Ran a self-review pass over the last round's diff (
Also corrected a stale comment in the migration that overstated how the seeded row's Full regression suite (Salesforce tool tests, migration tests, OAuth/token-resolver tests, encryption tests) re-run clean after these fixes. |
…lient-credentials-449d8c
…shadowing Pulls in main (GitHub connector + other recent work) and resolves the resulting alembic head split with a merge migration. The merge also reintroduced a local `import html` inside generic_oauth_callback's Salesforce instance_url check, shadowing the module-level import main now uses everywhere else in this function -- removed it.
rogercloud
left a comment
There was a problem hiding this comment.
PR summary
This PR adds a Salesforce connector built on the repo's existing static-OAuth + custom-FastMCP pattern: seeded oauth_providers / public_mcp_apps rows (duplicated in src/xagent/web/builtin_mcp_registry.py and an alembic seed migration) plus src/xagent/web/tools/mcp/salesforce.py, which exposes 8 non-destructive tools over the Salesforce REST API (SOQL query, SOSL search, sobject list/describe, record get/create/update — no delete). Because Salesforce serves each org from its own API host, it also adds a nullable user_oauth.instance_url column and threads it from the token exchange and refresh through both token-resolution paths into a second launch_config.env_mapping entry. Additionally it introduces PKCE for this flow (gated on the Salesforce provider name, verifier carried encrypted inside the signed JWT state), hoists two id-validation helpers out of hubspot.py into a shared src/xagent/web/tools/mcp/utils.py, and adds three empty alembic merge revisions to reconcile heads with concurrent connector PRs. Roughly 3,374 insertions / 35 deletions across 22 semantic files.
Blocking: yes — recommended event: REQUEST_CHANGES
Update summary (fbad311c -> c1a80315)
Three author commits plus a main merge. 8ccae51d added the pre-mutation instance_url guard in the OAuth callback and widened the seed migration's downgrade guard from a 3-4 column structural check to a full-row shape comparison. 1302764c moved that shape comparison out of SQL into Python (Postgres json has no = operator), fixed the output-capping halving loop so it can no longer exceed the limit when the message alone overflows, and replaced the four output-cap tests' reserialized-plus-slack assertions with exact raw-length assertions. c20986e1 / c1a80315 merged main and added a third merge revision ae0d1cffeca6. This round genuinely closed 8 prior findings (P15, P17, P18, P27, P33, P34, P42, P44).
Approach verdict
Acceptable with reservations. The instance_url column and the Salesforce module are well-judged and follow the established connector pattern rather than inventing a parallel one, and the test shape matches the actual risk surface: migration idempotency, downgrade-preserves-admin-edits, registry/seed parity, PKCE round-trip, the missing-ENCRYPTION_KEY path, malformed-refresh-preserves-prior-value, and an explicit non-Salesforce non-regression test.
The design-level reservation: the provider-identity predicate is not consistent across the flow (B1 below) — PKCE is granted by prefix while both callback protections are granted by exact equality, so a configuration the repo documents as supported gets the capability without the safeguards. Secondarily, PKCE capability is still name-gated rather than modeled as provider data; that is already tracked in #1542 and is not asked for here.
Blocking findings
B1 (MAJOR) — Inconsistent Salesforce provider matching splits capability from safeguards
src/xagent/web/api/auth.py:1491 gates PKCE on provider.lower().startswith("salesforce"), with a comment stating the prefix is deliberate so a documented salesforce-sandbox row also gets PKCE. Both callback-side branches, however, use exact equality: :2031 (the instance_url presence/type guard) and :2095 (the provider_user_id = token_data.get("id") backfill). example.env:690-701 documents the salesforce-sandbox second-provider-row workaround as the supported sandbox path, so this configuration is reachable by design, not hypothetical.
For such a row, three things follow:
- The
:2031guard does not fire, so a token response lackinginstance_urlreaches the unconditional delete-then-recreate at:2191-2201(gated only onif user_id:, always truthy there) — destroying a previously working grant while the user is told "Connected Successfully". This is prior finding P40, which is therefore PARTIAL: fixed and tested for"salesforce", still reachable for the documented sandbox row. - The
:2095backfill does not fire;userinfo_urlis empty for Salesforce-family rows so the genericelif userinfo_url and access_tokenfallback does not fire either, andprovider_user_idstays NULL — forfeiting the(user_id, provider, provider_user_id)unique-constraint protection (src/xagent/web/models/user_oauth.py:12-15) against concurrent duplicate grants. This is prior finding P41, also PARTIAL: the general case is genuinely fixed and tested via thetoken_data["id"]backfill; only the sandbox row remains exposed. - PKCE is granted to precisely the row that receives neither of the other two protections.
To scope this fairly: the unlocked delete-then-insert and the NULL-tolerant composite constraint are pre-existing and generic to all providers (verified identical at base). The ask is not to redesign them — only to make the provider predicate consistent:
def _is_salesforce_provider(provider: str) -> bool:
return provider.lower().startswith("salesforce")used at all three sites, plus a test that exercises a prefixed provider name.
B2 (MAJOR, prior finding P29 — not fixed) — Irrecoverable truncation in list_sobjects / describe_sobject
src/xagent/web/tools/mcp/salesforce.py:42-101 _success_with_capped_list halves the list until the JSON fits and returns only the prefix with truncated: true. 1302764c fixed the loop's own limit-violation edge case and added a candid docstring, but added no recoverability contract.
Scoping this precisely: salesforce_list_sobjects and salesforce_describe_sobject expose no filter or names_only parameter, so a truncated answer to "what objects exist" / "what fields does this object have" is silently incomplete with no way for the model to iterate toward the missing remainder. That is the blocking part. salesforce_query and salesforce_search are different: the model can narrow them with LIMIT / WHERE / more selective SOSL, and their remaining gap is genuine nextRecordsUrl pagination, correctly carved out to #1541 — not re-litigated here.
Fix: give the two schema-listing tools a field/name filter (or a names_only mode) so a bounded-but-complete answer is reachable, or an explicit continuation contract.
Non-blocking findings (minor)
Correctness / robustness
src/xagent/migrations/script.py.mako:16— prior finding P43 PARTIAL. TheUnion[str, Sequence[str], None]annotation was correctly applied to all three merge revisions this PR adds, but the template still emits scalar-onlyUnion[str, None], so future generated merges reproduce the inaccuracy. (Not inline-commentable: the file is not part of this diff.)src/xagent/web/tools/mcp/salesforce.pysalesforce_get_record— the only tool returning an unbounded shape with no cap.hubspot.py:186already has_success_with_capped_dictfor the dict case, and hoisting it intotools/mcp/utils.pyfits the module this PR created. Reachable whenfields=""(documented as "return every field") meets Long Text Area content against the tool output limit; the platform filter treats the whole JSON blob as one opaque string leaf and cuts mid-string. Triaged Minor and deferred in an earlier round — recorded here, still minor.src/xagent/web/api/auth.py:2030/:2230—salesforce_instance_url = token_data.get("instance_url")is assigned unconditionally in the generic path under a provider-prefixed name, and:2230persiststoken_data.get("instance_url")for any provider with no type check (the:2031guard covers Salesforce only). No currently seeded provider returns the key and there is no runtime provider-registration surface, so this is hardening plus naming, not a live bug.src/xagent/web/tools/mcp/salesforce.py:144-152—portcarries two meanings (""= no port,None= malformed). Correct as written; raising directly in theexceptwould keep the variable single-purpose. Separately,urlparse(...).portreturns0for:0(in range, falsy), so:0is silently canonicalized away rather than rejected — practical impact nil given the host is alreadyhttps+*.salesforce.comallowlisted and operator-set.src/xagent/web/tools/config.py:3260—getattr(oauth_account, "instance_url", None)on a column the model declares unconditionally. NoMock/SimpleNamespacereaches this path and a missing physical column would fail at query time, so the fallback is unreachable; use direct attribute access.src/xagent/web/tools/mcp/salesforce.py:177-202_extract_error_detail—return "; ".join(messages) if messages else None: theelse Nonearm is dead, since the earlier non-empty-list guard makesmessagesalways non-empty. Nit.
Duplication / polish
salesforce.py_success_with_capped_listandhubspot.py:119-183_paged_listshare the same halve-while-too-long core, differing only in cursor vs. message bookkeeping. Both files are touched by this PR, which already hoisted two smaller helpers intoutils.py, so the omission is an internal inconsistency._success_with_capped_dictandgoogle_analytics.py's inline loop are legitimately out of scope.src/xagent/web/tools/config.py:3103-3120— thetoken_typeif/elif/else could collapse to avalues.get(token_type)lookup inside the transport layer. Explicitly not recommending anextra: Mapping[str, str]onResolvedToken: that is the public hook contract, where named fields give embedders mypy coverage. Optional polish only.
Comments / documentation
src/xagent/web/api/auth.pycomments (and the PR discussion) describe this as "this codebase's first PKCE implementation".src/xagent/web/models/mcp_oauth.py:237already has acode_verifiercolumn andsrc/xagent/web/api/mcp.pyalready performs fullS256PKCE for the MCP OAuth flow. Different flow, but the claim as written is wrong and will mislead the next reader.example.env:689-700— the sandbox note is now factually correct (P17 fixed), but roughly 12 of its lines narrate implementation internals ("USERINFO_URL is a module-level constant", "no way to make that one tool sandbox-aware without a code change") rather than operator-actionable guidance. Optional trim. For the record, it is not an outlier in size: 30 lines vs GitHub's 32 and Slack's 26 — the upper end of an existing range.- PR description — prior finding P44 is FIXED (the body now says 8 tools / non-destructive; the registry and seed descriptions were never drifted). Still unmentioned: PKCE, and the
hubspot.pyhelper hoist that tightened HubSpot's id validation to reject./... Documentation-only.
Tests
- The guard
not isinstance(x, str) or not xatauth.py:2031has tests for missing and non-string values (both exercising theisinstanceclause) but none for an empty-stringinstance_url— the second clause is untested. tests/web/test_salesforce_oauth.py:242test_connected_salesforce_server_reports_no_account_labelasserts onlyconnected_account is None.MCPServerResponsehas no connected flag, so the docstring's "still show the server as connected" half is unasserted. Weak rather than risky — the siblingtest_callback_persists_instance_url_and_skips_userinfo_lookupalready covers persistence. Optional tightening.
Migration hygiene
- Three chained empty merge revisions (
0b38b8d46e1c->c97b6332a895->ae0d1cffeca6) where a single one at the tip would be equivalent;c97b6332a895's second parent already has20260818_seed_jira_mcp_appas an ancestor. The graph itself is healthy — exactly one head (ae0d1cffeca6), no dangling parents, per alembic's own history/heads. Worth collapsing before merge. Note this is purely tidiness: revision ids are author-chosen slugs, not commit SHAs, so a squash would not silently break the cross-PR references.
Pre-existing / out of scope — context, explicitly not blocking
- P39 (not fixed, pre-existing). Seeded OAuth credentials are written from
os.environ.get(...)in plaintext with noencrypt_value, and_resolve_oauth_secret(src/xagent/web/api/auth.py:141-150) prefers a non-empty stored value over the environment — so rotatingSALESFORCE_*env vars has no effect once seeded. Verified identical for GitHub, Jira, Linear, Slack, Zoom and HubSpot, in both their registry entries and seed migrations, all predating this PR. Worth a separate issue against the shared seeding pattern; not a reason to block here. - Log injection.
require_clean_identifierrejects only surrounding whitespace, so an embedded newline in an id reacheslogger.errorraw (the logged value is the un-encoded parameter, not the percent-encoded one). Same pattern injira.pyandposthog.py;google_analytics.py:377already solves it with!rplus an explanatory comment. Repo-wide follow-up. - Sibling dot-segment gaps.
jira.pyis tracked in #1558, butposthog.py,zoom.py,facebook.pyandinstagram.pyhave the same unguarded_path_segment/_graph_pathhelpers and are not covered by that issue. Suggest widening #1558's scope.
Prior findings checklist
- Fixed (verified against current code): P15 (env_mapping unknown-
token_typenow has a real test hitting theelsebranch, misleading comment corrected), P17 (example.envnote now correctly distinguishes the unusedoauth_providers.userinfo_urlcolumn from the hardcodedUSERINFO_URLconstant, and mentions the secondpublic_mcp_appsrow), P18 (decrypt_value_strictplus a genuine foreign-Fernet-key regression test that would fail if reverted), P27 (downgrade now preserves any admin-edited row via full-shape comparison, with fixtures proving survival — the earlier round's rejection is stale; the guard was rewritten twice afterfbad311c), P33 (_instance_url()canonicalizes toscheme://host[:port],force.comdropped, every host-confusion input correctly rejected), P34 (both paths — refresh preserves the prior value with a warning on malformed input, callback rejects before mutation; the "declined" label in the history is stale), P42 (tests assert exact raw length, slack removed), P44 (PR body corrected). - Partial: P40 and P41 (both roll up into B1 — not separate blocking items), P43 (minor item 1).
- Not fixed: P29 (B2); P39 (out of scope, above).
- Declined by author, still open, minor — WAIVED: P35 (
instance_urloptional-with-default in_build_oauth_mcp_stdio_transport_configthough both call sites always pass it; duplicatedexcept _OAuthInstanceUrlRequiredhandlers). The position that it is pure style with no live bug is reasonable; not re-raised. - Previously fixed or dropped, no action: P1, P3, P4, P6, P7, P9, P10, P11, P12, P19, P20, P22, P23, P26, P28, P30, P31, P36, P37, P38 fixed; P21 (
createablecodespell), P24 (SOSL response shape), P25 (logging.basicConfig), P32 (tautological non-Salesforce guard test) dropped on verified-correct author rebuttals. - Tracked follow-ups, correctly out of scope: #1540 (session-expiry/reconnect design), #1541 (SOQL pagination +
get_recordfield cap), #1542 (sandbox host + PKCE capability modeling), #1543 (429/Retry-After), #1558 (sibling dot-segment), #1559 (sibling downgrade guards).
Simplification opportunities
tests/web/test_salesforce_oauth.py:89: shrink: test_callback_rejects_missing_instance_url_without_touching_prior_grant and test_callback_rejects_non_string_instance_url_without_touching_prior_grant (:127) have ~85% identical bodies, differing only in the token response payload. Merge into one pytest.mark.parametrize with ids=["missing","non-string"] -- matches the parametrize convention this PR already uses for same-mechanism/different-value cases.
net: -30 lines possible
Review limitations
- No linked issue exists (
closingIssuesReferencesempty, no issue references in the body), so intent was taken from the PR description alone and could not be corroborated against a stated requirement. - No tests, builds or linters were run locally for this review. CI is green (14/14 checks) — that is CI's signal, not independent verification here.
- The seed migration's downgrade guard and the
instance_urlcolumn drop are exercised on SQLite only, so the PostgreSQLjsoncomparison path that1302764cspecifically fixed is unverified by CI. _instance_url()accepts the inert bare suffixhttps://.salesforce.com(not registrable, not exploitable, untested).- Every review thread on this PR is currently marked resolved, including several whose underlying concern is still open (P29, P39, P43); statuses above come from reading the current code, not from thread state.
Blocking status and recommended decision
Blocking: yes — recommended event: REQUEST_CHANGES
src/xagent/web/api/auth.py:2031— MAJOR — provider predicate is prefix-based for PKCE (:1491) but exact-equality for theinstance_urlguard (:2031) and theprovider_user_idbackfill (:2095), so the documentedsalesforce-sandboxrow gets PKCE while a missinginstance_urlfalls through to the unconditional delete-then-recreate andprovider_user_idstays NULL. Use one shared predicate at all three sites, plus a prefixed-name test.[new]root cause;[prior]consequences (P40 / P41 PARTIAL).src/xagent/web/tools/mcp/salesforce.py:61— MAJOR —_success_with_capped_listtruncation is irrecoverable forsalesforce_list_sobjectsandsalesforce_describe_sobject, which expose no filter ornames_onlyparameter, so a schema-discovery answer can be silently incomplete with no path to the remainder. Add a name/field filter or an explicit continuation contract.[prior](P29, not fixed).
| ) | ||
|
|
||
| salesforce_instance_url = token_data.get("instance_url") | ||
| if provider.lower() == "salesforce" and ( |
There was a problem hiding this comment.
MAJOR -- this guard uses exact equality on the provider name, but PKCE at src/xagent/web/api/auth.py:1491 is gated on provider.lower().startswith("salesforce"), with a comment saying the prefix is deliberate so a salesforce-sandbox row also gets PKCE. example.env:690-701 documents that second-provider-row workaround as the supported sandbox path, so the mismatch is reachable by design.
For a salesforce-sandbox row:
- This guard does not fire, so a token response lacking
instance_urlreaches the unconditional delete-then-recreate at:2191-2201(gated only onif user_id:, always truthy there) -- destroying a previously working grant while the user sees "Connected Successfully". - The
provider_user_id = token_data.get("id")backfill at:2095also does not fire, and sinceuserinfo_urlis empty for Salesforce-family rows the genericelif userinfo_url and access_tokenfallback does not fire either -- soprovider_user_idstays NULL and the(user_id, provider, provider_user_id)unique constraint (src/xagent/web/models/user_oauth.py:12-15) stops protecting against concurrent duplicate grants. - PKCE is granted to exactly the row that gets neither protection.
This is the same underlying issue as prior findings P40 and P41 rather than a new unrelated one: both are genuinely fixed and tested for the exact name "salesforce", and both remain reachable for the documented prefixed row -- hence PARTIAL, not FIXED.
To be clear about scope: the unlocked delete-then-insert and the NULL-tolerant composite constraint are pre-existing and generic to all providers (identical at base). The ask is only to make the predicate consistent:
def _is_salesforce_provider(provider: str) -> bool:
return provider.lower().startswith("salesforce")Use it at :1491, here, and :2095, and add a test that drives the callback with a prefixed provider name.
|
|
||
| None of the four tools calling this expose a cursor/offset the caller | ||
| could retry with to recover items this halving drops (unlike Salesforce | ||
| query's own separate, still-unimplemented nextRecordsUrl pagination, |
There was a problem hiding this comment.
MAJOR (prior finding P29, not fixed) -- this halving loop returns only a prefix of the list with truncated: true. The last round fixed the loop's own limit-violation edge case and added a candid docstring, but there is still no recoverability contract.
The blocking part is narrow: salesforce_list_sobjects and salesforce_describe_sobject expose no filter or names_only parameter, so a truncated answer to "what objects exist" / "what fields does this object have" is silently incomplete and the model has no way to iterate toward the missing remainder.
salesforce_query and salesforce_search are fine here -- the model can narrow them with LIMIT / WHERE / more selective SOSL, and their remaining nextRecordsUrl pagination gap is correctly carved out to #1541.
Fix: add a name/field filter (or a names_only mode) to the two schema-listing tools so a bounded-but-complete answer is reachable, or give them an explicit continuation contract.
| # other provider is already the correct, final value: no `if | ||
| # "instance_url" in token_data` guard needed to avoid clobbering | ||
| # anything. | ||
| setattr(oauth_account, "instance_url", token_data.get("instance_url")) |
There was a problem hiding this comment.
Minor (hardening + naming). This persists token_data.get("instance_url") for any provider with no type check -- the presence/type guard at :2031 covers Salesforce only. Relatedly, :2030 assigns salesforce_instance_url = token_data.get("instance_url") unconditionally in the generic path, under a provider-specific name.
Not a live bug: no currently seeded provider returns the key and there is no runtime provider-registration surface. But either validate here too, or rename the variable to something provider-neutral so the generic path does not read as Salesforce-specific.
| ) | ||
|
|
||
| access_token = str(oauth_account.access_token) | ||
| instance_url = getattr(oauth_account, "instance_url", None) |
There was a problem hiding this comment.
Minor. getattr(oauth_account, "instance_url", None) defends against a missing attribute on a column the model declares unconditionally. No Mock / SimpleNamespace reaches this path, and a missing physical column would fail at query time rather than at attribute access -- so the fallback is unreachable. Prefer direct attribute access; the getattr only hides typos from mypy.
| # .port is a lazy property that raises ValueError for a | ||
| # non-numeric port (e.g. "...salesforce.com:abc") -- accessed here, | ||
| # before the scheme/host check below, so that case raises this | ||
| # function's own clear message instead of urlparse's cryptic |
There was a problem hiding this comment.
Minor. port carries two meanings here -- "" for "no port", None for "malformed". It is correct as written, but raising directly inside the except would keep the variable single-purpose and drop the downstream is None check.
Separately: urlparse(...).port returns 0 for a :0 suffix (in range, so falsy rather than raising), so :0 is silently canonicalized away rather than rejected. Practical impact is nil -- the host is already https + *.salesforce.com allowlisted and operator-set -- noting it only so the behavior is deliberate rather than incidental.
| messages = [ | ||
| str(item.get("message") or item.get("errorCode") or item) | ||
| if isinstance(item, dict) | ||
| else str(item) |
There was a problem hiding this comment.
Nit: return "; ".join(messages) if messages else None -- the else None arm is dead. The earlier if not isinstance(payload, list) or not payload: return None guard guarantees messages is non-empty by the time this runs, so the conditional can be dropped.
Summary
oauth_providers+public_mcp_appsrow pair, and a newtools/mcp/salesforce.pymodule wrapping the Salesforce REST API (SOQL query, SOSL search, sobject listing/describe, and non-destructive record read/create/update (no delete tool -- this is the only connector here that could otherwise delete shared business records, and HubSpot, the other CRM connector, ships none either) — generic enough to cover any standard or custom object, since Salesforce orgs are highly customizable with potentially thousands of custom object types).login.salesforce.com/test.salesforce.comentry points (which resolve to the user's actual org during login).instance_url) instead of using a fixed domain, and every subsequent API call must go through it. No existing connector needed anything beyond the access token itself, so this required:user_oauth.instance_urlcolumn (migration + model change).generic_oauth_callback(initial connect) andrefresh_oauth_token_if_needed(Salesforce can return a newinstance_urlon refresh, e.g. after an org migration)._LegacyOAuthTokenResolutionand_build_oauth_mcp_stdio_transport_configsolaunch_config.env_mappingcan map a second field ("instance_url", alongside the existing"access_token") into a launch env var.login.salesforce.comhost (notinstance_url), since Salesforce routes it internally based on the token —salesforce_get_current_usercalls that fixed URL directly rather than going throughinstance_urllike every other tool here.Test plan
tests/alembic/test_20260818_add_instance_url_to_user_oauth.py— the new column migration (upgrade/idempotent/downgrade)tests/alembic/test_20260818_seed_salesforce_mcp_app.py— seed migration insert/idempotency/downgrade, and drift check againstbuiltin_mcp_registry.pytests/web/tools/test_salesforce_mcp.py— headers/instance_url validation, array-shaped error handling, and all 8 Salesforce tools (query, search, list_sobjects, describe_sobject, get_current_user, get_record, create_record, update_record)tests/web/test_salesforce_oauth.py—instance_urlpersisted on connect and on refresh; skipped identity lookup (emptyuserinfo_url) doesn't crash; non-Salesforce providers unaffectedtests/web/tools/test_oauth_launch_config_static_env.py— new tests for theinstance_urlenv_mapping case (forwarded when provided, omitted when not)ruff check/ruff format/ mypy / pre-commit hooks all passWebToolConfig/legacy-oauth-resolution test files, all still pass after the sharedauth.py/tools/config.py/UserOAuthchanges