Skip to content

MCP OAuth tool refresh hides reauthorization and re-registers DCR client #1816

Closed

Description

@ramarivera

Bug description

A saved OAuth-backed remote MCP connection can be rejected by the upstream MCP endpoint before its locally stored expiry. During remote tool catalog refresh, Executor turns the resulting McpOAuthReauthorizationRequired into { tools: [], incomplete: true }.

The deployed 1.6.0 core then preserves the previous catalog and logs only plugin returned an incomplete tool catalog. Current main improved the detail in #1563 by carrying incompleteReason, but it still classifies the connection as degraded, not expired. When the connection has never produced tools, users still see zero tools without the reconnect state added by #1316.

The MCP SDK also performs Dynamic Client Registration before requesting an authorization redirect because the plugin's OAuthClientProvider returns no saved client information. A noninteractive catalog refresh cannot complete that redirect, so the refresh can register a fresh OAuth client and abandon it.

PR #1316 fixed the adjacent human reconnect handoff and expired health rendering. Current main still catches this typed failure in packages/plugins/mcp/src/sdk/plugin.ts inside resolveTools and returns it as an incomplete catalog with degraded health.

Reproduction

Observed on self-host 1.6.0 with a saved PostHog MCP OAuth connection:

  1. Complete the PostHog OAuth connection successfully.
  2. Have the upstream endpoint reject the saved bearer while Executor still considers it unexpired.
  3. Refresh the connection's tools.
  4. Executor returns an empty catalog with degraded health and logs plugin returned an incomplete tool catalog.

Temporary instrumentation around the real Executor 1.6.0 MCP transport produced this sanitized sequence:

POST https://mcp.posthog.com/mcp -> 401
GET  https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp -> 200
GET  https://oauth.posthog.com/.well-known/oauth-authorization-server -> 200
POST https://oauth.posthog.com/oauth/register/ -> 201
McpToolDiscoveryError: Failed connecting to MCP server: MCP OAuth re-authorization required

The initial 401 had no WWW-Authenticate header and no insufficient_scope indication. Executor never sent tools/list.

Current code path

discoverTools preserves the connector failure as McpToolDiscoveryError. On current main, mcpPlugin().resolveTools catches every discovery error and returns an incomplete result with a human-readable reason:

{
  tools: [],
  incomplete: true,
  incompleteReason: discovered.failure.message,
}

Core's tool-sync health path maps every incomplete result to status: "degraded", so the typed reconnect condition is still lost even though its message now survives.

The plugin's makeOAuthProvider also implements:

clientInformation: () => undefined
saveClientInformation: () => undefined

That makes the SDK dynamically register again after the saved bearer is rejected, even though the saved connection already references an OAuth client.

Expected behavior

  • Preserve a typed reauthorization-required outcome from MCP discovery instead of collapsing it into an anonymous incomplete catalog.
  • Preserve any previously persisted tool catalog.
  • Persist an expired or reconnect-required health result on the saved connection so the existing reconnect UI from Return expired health verdicts for rejected OAuth refreshes and fix DCR reconnect #1316 can act on it.
  • Do not dynamically register a new OAuth client during background refresh of an existing saved OAuth connection.
  • Do not expose bearer tokens, OAuth client secrets, or raw upstream response bodies in logs or API errors.
  • Keep ordinary network/schema discovery failures on the existing catalog-preservation path.

Suggested regression coverage

Use the repository OAuth/MCP emulators with a server that:

  1. completes an initial DCR OAuth connection,
  2. rejects the saved bearer during a later MCP initialize,
  3. records all OAuth and MCP requests.

Assert that refresh:

  • preserves the existing tools,
  • marks the connection expired or reconnect-required,
  • performs no tools/list,
  • performs no second /register,
  • returns an actionable typed result rather than an internal error or anonymous incomplete-catalog reason.

Environment

  • Executor self-host image: 1.6.0
  • Host: Linux amd64, Docker self-host
  • Remote transport: streamable HTTP
  • Upstream MCP: PostHog

🤖 This content was generated with AI assistance using GPT-5.6 Sol.

Activity

  1. ramarivera commented on Aug 28, 2026

    @ramarivera
    ContributorAuthor

    Live self-hosted reproduction after completing PostHog OAuth reconnection:

    • Executor stored a newly issued access token and moved the connection from expired to healthy before any upstream probe.
    • The existing auto-registered client remained dcr-oauth-posthog-com.
    • The new grant contained 100 scopes, while PostHog's current protected-resource metadata advertises 144.
    • Exact delta: 46 missing scopes. These include billing:read, canvas:read, canvas:write, comment:write, dataset:read, dataset:write, all error_tracking:* scopes, all feature_flag:* scopes, insight:write, integration:write, llm_analytics:write, organization:write, project:write, query:write, and session_recording:write.
    • The first authenticated MCP tools/list request returned HTTP 401. Executor then classified the connection as expired and kept toolCount: 0.

    This isolates a second bug adjacent to the 401 classification fixed in #1818: Reconnect reuses the existing DCR client and reruns authorization, but it does not re-register the client when the protected resource's supported scopes have expanded. The authorization server can therefore issue a fresh token constrained to the old client registration, and repeated reconnects cannot repair it.

    Current manual recovery sequence is:

    1. Remove connections using the stale auto-registered client.
    2. Remove that client from the integration's Auto-registered clients section.
    3. Add the connection again so DCR registers a fresh client against current metadata.

    The durable product fix should detect DCR scope drift and replace or update the auto-registered client before starting authorization, rather than asking the user to reconnect indefinitely.

    🤖 This content was generated with AI assistance using GPT-5.6 Sol.

  2. ramarivera commented on Aug 28, 2026

    @ramarivera
    ContributorAuthor

    Correction after tracing the live UI and SDK paths: the manual recovery sequence is not fully reachable from the current UI for an integration whose automatic DCR path remains active.

    • AddAccountModal hides the OAuth app picker and its Auto-registered clients management section while dcrActive is true (packages/react/src/components/add-account-modal.tsx:2507, management section at :2743).
    • Starting Add connection does not replace the stale registration. registerDynamicClient calls decideDcrClientReuse, then immediately returns the existing slug when issuer/resource/redirect match (packages/core/sdk/src/oauth-service.ts:1170-1172). It never compares the current discovered scopes with those used to register that client.
    • Therefore both Reconnect and Add connection reuse the stale DCR client. The visible UI offers no route to remove it in this state.

    The current recovery requires the approval-gated API/CLI to remove the affected connection, then remove dcr-oauth-posthog-com, followed by a fresh Add connection/consent flow. The product fix also needs either scope-aware DCR reuse or a reachable “replace auto-registered client” UI action.

    🤖 This content was generated with AI assistance using GPT-5.6 Sol.

  3. ramarivera commented on Aug 28, 2026

    @ramarivera
    ContributorAuthor

    Follow-up after deleting the stale DCR client and completing a fresh PostHog OAuth connection: PostHog now selects CIMD (posthog-mcp-cimd), but the zero-tools failure persists.

    Fresh live evidence:

    • Executor discovers and requests all 144 scopes from https://mcp.posthog.com/.well-known/oauth-protected-resource/mcp.
    • The newly issued token's oauthScope contains exactly 100 scopes.
    • The granted set equals the first 100 entries of the protected-resource metadata exactly.
    • Entry 99 is property_definition:write; entry 100, query:read, is the first omitted scope.
    • 44 scopes are absent, including query:read, all session-recording scopes, survey scopes, user scopes, and warehouse scopes.
    • The first authenticated connection attempt returns HTTP 401. Executor classifies it internally as MCP OAuth re-authorization required, but the integration view suppresses that actionable state and simply exposes zero tools.

    This corrects the prior DCR-only hypothesis: deleting the stale DCR registration changes the registration protocol but does not resolve the live PostHog failure. The current token truncation happens upstream after Executor requests the full scope set. PostHog's current source resolves 144 MCP scopes, but its production authorization flow grants a deterministic 100-scope prefix.

    PostHog upstream evidence: PostHog/posthog#55307 (comment)

    Executor still has a product bug independently of PostHog: an upstream 401 requiring reauthorization must not silently render as a healthy-looking integration with zero tools. #1818 addresses that classification and UI handoff.

    🤖 This content was generated with AI assistance using GPT-5.6 Sol.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions