Repository navigation
feat: sign in to custom MCP servers through the browser - #720
Conversation
Browser sign-in only worked for presets: discovery and client registration ran when the connection came from a preset, and the add-connection form offered None, Bearer, API key, or Basic. A custom MCP server can now choose OAuth. Mogplex reads the server's protected-resource metadata, registers itself as a public client, and signs in with PKCE and the server as the token's resource, as the MCP authorization spec describes. Client settings stay optional for MCP and are validated when supplied; REST OAuth is unchanged.
There was a problem hiding this comment.
Mogplex PR Review
Status: Attention needed
Solid, well-scoped feature: the oauth-profile extraction, optional validation for custom MCP OAuth rows, and the misconfiguration rule all match the description, and no security or data-integrity hole turned up. Two behavior problems in the new path should be fixed before merge: a stale credential field is submitted as the OAuth client_secret, and the new resource indicator is also sent on refresh for hand-configured custom MCP connections that predate this change.
Warnings
-
Stale credential is submitted as the OAuth client secret for MCP browser sign-in (
components/connections/add-connection-form.tsx)
The credential input is hidden oncenewConn.auth_type === "oauth", but the value stays in form state andaddConnection()postsJSON.stringify(newConn)wholesale, so a credential typed under Bearer/API key/Basic is still submitted.normalizeOauthCredentialsthen wraps it as{"client_secret": "<token>"}(lib/connections/validation.ts), it is stored as the connection's OAuth credentials, andexchangeCodeForTokenssends it asclient_secretto the authorization server discovered from the MCP server.Two consequences: (1) the sign-in fails with
invalid_clientfor the client we registered withtoken_endpoint_auth_method: "none", and because the credential persists the row stays broken until it is recreated; (2) a token the user pasted for a different auth mode is transmitted to a third-party authorization server they did not intend to send it to.Suggested fix: clear the field when switching modes, e.g. in the MCP auth
onChangeusecredentials: e.target.value === "oauth" ? "" : p.credentials, or dropcredentialsfrom the payload whentype === "mcp_server" && auth_type === "oauth". -
The
resourceindicator is now sent on refresh for hand-configured custom MCP OAuth rows (lib/connections/oauth.ts)
getOAuthResourceIndicatorreturnsmcp_urlfor everymcp_serverrow withauth_type: "oauth"and the custom profile setsuse_resource_indicator: true, so the value is appended to the authorize URL, the code exchange, andrefreshOAuthToken. The connections pane already offered OAuth for MCP before this PR, so rows authorized against manually registered clients exist today; their authorization servers may reject parameters they do not know (invalid_request/unsupported_parameter). That failure would surface only when the stored access token expires, long after this deploy, and it is not covered by the behavior-change note in the description (which mentions sign-in only).Was refresh verified against such a server? If not, consider sending
resourceonly for rows whose client we registered (for example whenoauth_client_idwas written during discovery), or retrying the refresh once withoutresourcewhen the token endpoint answers 400invalid_request. -
New unit test depends on live DNS resolution (
tests/unit/connection-oauth-custom.test.ts)
The spec only stubsglobal.fetch, butprepareOAuthConnectioncallsdiscoverOAuthMetadata("https://mcp.sentry.dev/mcp"), which validates the protected-resource and metadata URLs withassertSafeOutboundHttpUrlWithDns; that helper performs a realdns.lookup(lib/security/outbound-url.ts) before any stubbed fetch runs. On a runner without DNS egress, or when the name resolves to a private address, the test fails with "must resolve to a public host" rather than exercising discovery. Stubbingdns.promises.lookup(already injectable aslookupHostnameon the validator) or mocking@/lib/security/outbound-urlin this spec would keep it hermetic.
Suggestions
-
Registration replaces endpoints a custom connection supplied explicitly (
lib/connections/oauth.ts)
When a custom MCP row hasoauth_authorize_urlandoauth_token_urlfilled in but nooauth_client_id, the registration update writes the discoveredmetadata.authorization_endpoint/metadata.token_endpointover both the row and the in-memory connection. For presets that is clearly intended; for a hand-configured custom row it silently discards what the user entered. Is that deliberate (the discovered authorization server is authoritative)? If so a one-line comment near the update would save the next reader the same question; otherwiseoauth_authorize_url: nextConnection.oauth_authorize_url ?? metadata.authorization_endpointwould preserve explicit configuration. -
Untested branch: supplied client id skips discovery and registration (
tests/unit/connection-oauth-custom.test.ts)
The new tests cover the fully-discovered path only. Two branches ofprepareOAuthConnectionhave no coverage: a custom MCP row that already hasoauth_client_id(no fetch at all, PKCE still returned) and one that supplies both endpoints but no client id (discovery plus the endpoint rewrite above). The e2e addition covers the settings form; the connections-pane inline form has its own OAuth branch and its own redirect and is not exercised.
…CP tokens without a resource A credential typed under Bearer, API key, or Basic stayed in form state after switching to OAuth and was posted as the client secret. The settings form now omits credentials for OAuth, and the workspace pane keeps the OAuth client secret in its own field so a token typed for another auth type never becomes it. Custom MCP connections send the resource indicator during authorize and code exchange only. Refresh sends it only for presets that declare it, so rows hand-configured before browser sign-in keep refreshing against servers that reject the parameter. The custom OAuth unit tests now use an IP-literal host so they no longer depend on DNS resolution.
There was a problem hiding this comment.
Mogplex PR Review
Status: Attention needed
Well-scoped feature with good coverage of discovery, registration, PKCE and the resource indicator, but two correctness gaps should be settled first: custom MCP refreshes drop the resource indicator that authorize/token requests set, and a client secret saved without a client id survives dynamic registration and is later sent with a public client.
Warnings
-
Custom MCP refresh drops the
resourceindicator (lib/connections/oauth.ts)
getOAuthResourceIndicator(connection, "refresh")returns nothing for a row with no preset, so a custom MCP server that was authorized withresource=<mcp_url>refreshes without it. Everything else about these rows assumes resource indicators work: the authorize request andexchangeCodeForTokensboth send it, and the MCP authorization spec requires the parameter on token requests. A preset row with the sameregistration: "dynamic"/use_resource_indicator: trueprofile (Sentry) does send it on refresh, so two rows registered through the exact same code path now behave differently. On authorization servers that bind the access token to the requested resource (AuthKit/WorkOS-style servers, Keycloak resource indicators, Auth0 resource servers) the refreshed token can come back without the MCP audience or the request can fail withinvalid_target, which surfaces as a 401 from the MCP server and a re-auth roughly every token lifetime. The comment's rationale is real for hand-configured legacy rows on servers that rejectresource(Azure AD v2), but it does not describe rows whose client was just registered by this new flow.Suggestion: persist a marker when
prepareOAuthConnectionfills in endpoints/client id from discovery + registration, includeresourceon refresh for those rows, and keep omitting it only for hand-configured rows. Note thatshould refresh a custom MCP token without the resource indicatorcurrently locks the omission in. -
A client secret saved without a client id survives dynamic registration (
lib/connections/validation.ts)
The MCP OAuth branch acceptscredentialswhileoauth_client_idis optional, and the registration block inprepareOAuthConnectiononly rewritescredentialswhen the registration response includes aclient_secret:const credentials = registration.client_secret ? JSON.stringify({ client_secret: registration.client_secret }) : undefined; ... ...(credentials ? { credentials } : {}),
So a row created with a client secret but no client id (both fields are optional in the inline form) registers a fresh public client, keeps the user's secret, and then sends it as
client_secretinexchangeCodeForTokensandrefreshOAuthTokenagainst a client that was registered withtoken_endpoint_auth_method: "none". Depending on the authorization server that isinvalid_client, or a token for a client the user did not configure.Suggestion: reject a client secret without a client id in
normalizeConnectionCreateInput, or clearcredentialswhenever registration issues a newclient_id.
Suggestions
-
Prefer the discovered
resourceover the rawmcp_url(lib/connections/oauth-discovery.ts)
discoverProtectedResourceMetadataalready has the server'sresourcevalue, butdiscoverOAuthMetadatareturns only the endpoints, sogetOAuthResourceIndicatorfalls back toconnection.mcp_urlverbatim with no RFC 8707 canonicalization (scheme/host case, trailing slash, fragment). An authorization server that compares the requestedresourceagainst its own protected-resource identifier can reject that withinvalid_targetwhen a server publishesresource: https://hostwhile its MCP endpoint ishttps://host/mcp.Worth returning
resource(andscopes_supported) from discovery, persisting the resource on the row, and using it for both the authorize and token requests. I am not certain this bites the servers you tested, but it is the canonical value per RFC 9728. -
Sign-in silently skipped when the create response has no id (
components/connections/add-connection-form.tsx)
InaddConnection,await res.json().catch(() => ({}))swallows a parse failure, and whenconnection?.idis missing the code falls through tosetNewConn(INITIAL_CONNECTION_FORM)andmutateConnections(). The user gets a createdoauthrow with no sign-in started and no message explaining it. The table's "Connect OAuth" action makes that recoverable, but only if they notice the new row.Suggestion: call
setConnectionErrorwhen the id is missing instead of falling through. -
Settings-form sign-in cannot use a pre-registered client (
components/connections/connection-form.ts)
MCP_CONNECTION_AUTH_OPTIONSadds OAuth to the settings add form, but that form has no client id, client secret or endpoint inputs (the connections-pane form does), andnormalizeConnectionSettingsPatchonly acceptsis_enabled/approval_mode. A custom MCP server that requires a pre-registered client therefore fails atoauth=setup_errorfor anyone who added it from Settings, and the only fix is deleting the row and re-adding it from the other form.Either expose the optional OAuth fields in the settings form, or point users at the connections pane: the current setup-error copy only talks about preset configuration.
|
f56170c addresses the three warnings from the review. The stale credential is no longer sent. The settings form leaves Refresh no longer sends The custom OAuth unit tests point at a public IP literal now. That skips the outbound DNS lookup, so they no longer depend on name resolution. Stubbing On the two suggestions: Registration replacing hand-entered endpoints is deliberate. When a row has no client id, Mogplex registers a client with the authorization server it discovers from the MCP server. That client id is only valid at that server. If we kept user-entered endpoints that point somewhere else, the next sign-in would fail with an unknown client. If a row already has a client id, discovery and registration are skipped and its endpoints are kept as entered. On coverage: the workspace pane's OAuth branch and its redirect are now covered by the e2e spec above. The remaining unit gaps are the custom row that already has a client id and the row with endpoints but no client id. Both run the same code paths as the preset tests in |
…nd drop stale secrets When Mogplex registers a custom MCP server's client, the registration write now replaces the row's credentials with the issued secret, if any, plus a registered_client marker. A secret saved without a client id is no longer sent for the new public client. Refresh reads that marker. Registered custom clients keep sending the resource indicator, and hand-configured custom clients still refresh without it. Preset rows are unchanged and keep their stored credentials until the token write completes migration.
There was a problem hiding this comment.
Mogplex PR Review
Status: Attention needed
Solid, well-tested implementation of custom-MCP browser sign-in, but I'd want one design question answered before merge: the resource parameter is sent on the sign-in exchange for every custom row yet deliberately omitted on refresh for hand-configured rows, which can leave a refreshed token bound to the wrong resource. Secondary: discovered authorization endpoints are persisted and used as a browser redirect target without the outbound-URL validation that user-supplied endpoints must pass.
Warnings
-
resourceis sent at sign-in but dropped on refresh for hand-configured custom clients (lib/connections/oauth.ts)
getOAuthResourceIndicator(connection)(no refresh argument) makes the authorize URL and the code exchange carryresource=<mcp_url>for every custom MCP row, including rows where the user suppliedoauth_client_id/endpoints by hand. On refresh,refreshOAuthTokenpasses{ registeredClient }, so those same rows call it withoutresourcebecause they never went through dynamic registration and therefore never get theregistered_clientmarker in the encrypted credentials.Why it matters: per RFC 8707 the authorization server binds the issued token to the requested resource. Refreshing without a resource can mint a token for the authorization server's default resource instead of the MCP server, which typically surfaces as 401s from the MCP server roughly an hour after sign-in — the kind of failure that is hard to trace back.
It also makes the in-code rationale self-defeating: the comment says hand-configured clients may talk to servers that reject
resource, but the sign-in exchange for exactly those rows already sends it, so such a server would fail at sign-in rather than at refresh.Suggestion: derive the refresh decision from the same evidence as the exchange, e.g. persist whether the exchange was resource-bound (a marker next to
registered_client, or the resource itself) whenexchangeCodeForTokensruns, and reuse it inrefreshOAuthToken. If the current split is deliberate, a test plus a comment explaining why sign-in and refresh differ would help. Explicitly: is the asymmetry intended? -
Discovered OAuth endpoints are persisted and redirect the browser without the outbound-URL checks applied to supplied endpoints (
lib/connections/oauth-discovery.ts)
discoverOAuthMetadatareturnsauthorization_endpoint/token_endpointstraight from the fetched authorization-server document; only the well-known URL andregistration_endpointgo throughassertSafeOutboundHttpUrlWithDns.prepareOAuthConnectionthen writes those values intooauth_authorize_url/oauth_token_urlunchanged, while the same fields supplied by a user go throughoptionalHttpUrl->assertSafeOutboundHttpUrlinlib/connections/validation.ts. The two paths now disagree.Why it matters here and not before: discovery used to run only against pinned preset
mcp_urlvalues; this PR makes it run against any user-typed MCP URL, so the metadata is effectively third-party input.oauth_token_urlis re-validated with a DNS check at use, butoauth_authorize_urlis only ever used as a redirect target (buildAuthorizeUrl->NextResponse.redirect), so a server's metadata can send the user's browser to an arbitrary absolute URL — including a non-http(s) scheme — carryingstate,client_id,redirect_uri, andcode_challenge.Suggestion: validate both discovered endpoints with
assertSafeOutboundHttpUrl(and requirehttps:) insidediscoverOAuthMetadataor before theupdateConnectioninprepareOAuthConnection, so discovered and supplied configuration follow the same rules. I'm flagging this as hardening rather than a confirmed exploit: the MCP spec does have the client trust the server's advertised authorization server.
Suggestions
-
Partially supplied OAuth endpoints are silently replaced by discovery (
lib/connections/validation.ts)
normalizeConnectionCreateInputnow treatsoauth_client_id,oauth_authorize_url, andoauth_token_urlas independently optional for MCP OAuth, butprepareOAuthConnectionruns discovery when either endpoint is missing and then overwrites both from the metadata. A user who supplies a correctoauth_authorize_urland leaves the token URL empty gets their value replaced with no feedback.Suggestion: make the endpoint pair all-or-nothing in validation, or fill only the missing endpoint in
prepareOAuthConnection. Related note: servers without dynamic client registration can only be connected from the pane form, sinceAddConnectionFormhas no fields for client id/endpoints — from settings they fail through a generic?oauth=setup_errorredirect. -
Pane form keeps OAuth selected when switching to REST API (
components/panes/connections-pane/inline-add-form.tsx)
The REST API / MCP Server buttons only callsetType, so an OAuth selection survives the switch torest_api. Submitting then sendstype: "rest_api"withauth_type: "oauth"and empty client settings, and validation rejects it withoauth_client_id is required.The settings form gained exactly this guard in this PR (
nextType === "rest_api" && p.auth_type === "oauth" ? "none"); mirroring it in the pane form would keep the two add surfaces consistent. -
Custom MCP sign-in never requests a scope (
lib/connections/oauth-profile.ts)
CUSTOM_MCP_OAUTH_PROFILEcarries no scopes,discoverOAuthMetadatadropsscopes_supportedfrom the protected-resource and authorization-server metadata, andAddConnectionFormhas no scopes input — so for a custom server reached from settings the authorize request has noscopeparameter at all.Suggestion: feed the server's advertised
scopes_supportedintooauth_scopesduring discovery (the MCP authorization spec expects clients to use it), or at minimum surface theoauth_scopesfield that the pane form already has. -
Authorize params are still read from the preset only (
app/api/connections/oauth/route.ts)
The route builds the authorize URL withauthorizeParams: preset?.oauth_config?.authorize_params, while everything else about the flow now goes throughgetOAuthProfile.OAuthProfileincludesauthorize_params, so adding any toCUSTOM_MCP_OAUTH_PROFILEwould silently do nothing.Suggestion: use
getOAuthProfile(resolvedConnection)?.authorize_paramsso both profile sources are honored.
|
eb5e52d settles the two points from the previous review. When Mogplex registers a custom MCP server's client, the registration write now replaces the row's credentials with the issued secret, if there is one, plus a Replies to the latest review: The sign-in/refresh asymmetry for hand-configured rows is intended. Those rows existed before this PR, and some were authorized against clients that were set up by hand. Their stored refresh tokens predate the indicator, so refresh is where adding a new parameter could break a working row long after deploy. You're right that a fresh sign-in on such a row now sends Validating discovered endpoints is fair hardening. Partially supplied endpoints, the pane form keeping OAuth selected after a switch to REST, scopes from |
…scovered OAuth endpoints The code exchange now records in the encrypted credentials whether it named the MCP resource, and refresh sends resource exactly when that marker is present or a preset declares it. Registered and hand-configured custom clients signed in through the new flow both refresh with it. Tokens from exchanges that predate the marker keep refreshing without it, and a later exchange without a resource clears it. This replaces the registered_client marker. Registration still replaces a stale client secret on custom rows. Authorization and token endpoints returned by discovery now pass the same outbound URL check as user-entered OAuth URLs and must use https. A server advertising a private, loopback, or non-https endpoint fails sign-in with an error that names the field but not the URL. Credential parsing moves to oauth-credentials.ts to keep oauth.ts under the line cap.
There was a problem hiding this comment.
Mogplex PR Review
Status: Attention needed
Approve-ready: the custom MCP OAuth path reuses the existing authorize/callback/refresh machinery correctly, and CUSTOM_MCP_OAUTH_PROFILE, PKCE, the resource indicator, isConnectionMisconfigured and the CLI sync path (listConnectionsForCli → buildCliMcpRecord) all line up with the unchanged callers. One non-blocking correctness concern: validation now accepts each OAuth endpoint independently, but prepareOAuthConnection re-discovers and overwrites both whenever either is missing, so a supplied endpoint can be silently discarded.
Warnings
-
Supplied OAuth endpoint is silently overwritten when only one of the pair is configured (
lib/connections/validation.ts)
The new MCP-OAuth branch ofnormalizeConnectionCreateInputacceptsoauth_client_id,oauth_authorize_urlandoauth_token_urlindependently ("validated only when supplied"), butprepareOAuthConnectioninlib/connections/oauth.tstreats the endpoint pair as all-or-nothing:if (!nextConnection.oauth_authorize_url || !nextConnection.oauth_token_url)runsdiscoverOAuthMetadata(mcp_url)and writes both endpoints back, replacing whatever the user supplied. Two consequences:- A row created with only
oauth_authorize_url(or onlyoauth_token_url) has the supplied value replaced on first sign-in, which contradicts the validation contract that supplied endpoints are kept and only missing ones are discovered. - When
oauth_client_idis also supplied, the registration block is skipped (if (!nextConnection.oauth_client_id)), so the kept client id is paired with endpoints discovered from the server's metadata. If the user's client id belongs to a different authorization server, sign-in fails at the provider (typicallyinvalid_client) with no hint that the pair was mismatched.
Suggestion: either require the two endpoints together (mirroring the REST branch, which requires all four fields) or only fill in the missing endpoint instead of overwriting the supplied one, and add a unit test for a custom row that carries just one of the two URLs.
- A row created with only
Suggestions
-
Discovered
registration_endpointbypasses the https check applied to the other endpoints (lib/connections/oauth-discovery.ts)
assertDiscoveredEndpointis applied toauthorization_endpointandtoken_endpoint, butdiscoverOAuthMetadatacopiesregistration_endpointstraight through, so the dynamic client registration POST is only checked byassertSafeOutboundHttpUrlWithDnsand may go out over plain http. Impact is limited (the DCR body carries only client name, redirect URIs and scopes), but the response supplies theclient_idthat is then persisted on the row, so an unauthenticated registration response can influence stored config. Running the same helper onregistration_endpoint(which would also reject non-string values from the parsed metadata) keeps the invariant the helper's doc comment states. -
Inline add form duplicates the auth option list and disagrees with the settings form (
components/panes/connections-pane/inline-add-form.tsx)
AUTH_OPTIONSis a local copy of the option list thatcomponents/connections/connection-form.tsnow owns (CONNECTION_AUTH_OPTIONS/MCP_CONNECTION_AUTH_OPTIONS). Besides the duplicate label for the same value ("OAuth" here versus "OAuth (browser sign-in)" in the settings form), the two add surfaces now diverge: the settings form only offers OAuth formcp_serverand resets the selection when switching back torest_api, while this pane still offers OAuth for REST connections, where it fails with the "OAuth client … is required" validation error unless all four fields are filled. Importing the shared constants and filtering by connection type would remove both the drift and the copy.
|
a3d38f4 makes the last round of changes. Refresh now follows what the code exchange actually sent. The exchange records in the encrypted credentials whether it named the MCP resource, and refresh sends The authorization and token endpoints returned by discovery now go through the same The remaining review notes are tracked in #724:
None of these changes what this PR ships, so I've kept them out of it. |
Browser sign-in only worked for connection presets.
prepareOAuthConnectionran discovery and dynamic client registration only when the connection came from a preset, and the add-connection form offered None, Bearer, API key, or Basic. A custom MCP server that expects OAuth, which is most hosted MCP servers, had to be connected with a pasted access token. The public docs already said custom connections support OAuth.A custom MCP connection can now choose "OAuth (browser sign-in)". It follows the MCP authorization spec through a generic profile in
lib/connections/oauth-profile.ts: RFC 9728 protected-resource discovery, dynamic client registration as a public client, PKCE on every request, and the MCP server as the token's resource. The existing authorize route, callback, token refresh, and CLI sync are reused unchanged. Once authorized, the connection reaches the CLI through the same sync as preset OAuth connections.isConnectionMisconfiguredtreats a custom MCP OAuth row like a dynamic-registration preset before its first sign-in.Behavior change: a custom MCP connection that was already configured by hand with OAuth client settings now also sends PKCE and the
resourceparameter, both required by the MCP authorization spec.A CLI-native sign-in (
mogplex mcp login) is a separate follow-up in the CLI repository.Tests: unit tests cover discovery and registration for a custom server, PKCE, the resource indicator, and exclusion of REST and non-OAuth rows. Validation tests cover URL-only OAuth, rejected supplied endpoints, and unchanged REST requirements. A new e2e spec covers the form starting sign-in, and the existing manual-add spec now expects OAuth to be offered only for MCP servers. The custom-profile tests fail with the profile removed.
Open in [code]smith · Need help on this PR? Tag
@codesmith-botwith what you need.