Skip to content

Adding an OAuth MCP source: silent credential drop, refresh masks discovery failure, and addSource is the only path that actually (re)discovers tools #859

@Mark-Life

Description

@Mark-Life

Adding an OAuth MCP source: silent credential drop, refresh masks discovery failure, and addSource is the only path that actually (re)discovers tools

Summary

Connecting Notion (an OAuth-protected remote MCP source) through the agent-facing tools left the source in a broken "0 tools" state that was hard to diagnose. Three distinct problems compounded:

  1. mcp.addSource / mcp.configureSource silently accept an invalid auth shape and return ok: true while creating no credential binding. No validation error.
  2. coreTools.sources.refresh returns { refreshed: true } even when discovery fails or finds 0 tools — it hides the discovery status/error that addSource does return.
  3. refresh does not re-run discovery against a binding that was added after the source was created. After the binding existed, every refresh still reported toolCount: 0. Only re-running mcp.addSource with the credential passed inline actually discovered the tools (14).

Net effect from the user's side: the connector first appeared as a source showing nothing, then showed up with 0 tools, and only after an addSource re-run did all tools appear. There was no surfaced error explaining any of this.

Environment

  • Executor: local desktop runtime, single host, two credential scopes (personal + org).
  • Source: Notion preset → official remote MCP server https://mcp.notion.com/mcp.
  • Auth: OAuth 2.0 with dynamic client registration (DCR); supportsDynamicRegistration: true.
  • Transport: streamable-http (server does not support SSE).

Reproduction (actual tool-call sequence)

  1. executor.sources.list → only built-in executor.
  2. executor.coreTools.sources.presets({ query: "notion" }) → Notion preset, pluginId: "mcp", transport: "remote".
  3. executor.mcp.probeEndpoint({ endpoint }){ connected: false, requiresOAuth: true, supportsDynamicRegistration: true }.
  4. executor.coreTools.scopes.list + executor.coreTools.oauth.probe({ endpoint }) → DCR + auth-server metadata.
  5. executor.coreTools.oauth.start({ endpoint, credentialScope })invalid_tool_arguments (missing connectionId, pluginId, strategy). The tool description doesn't make these required fields obvious; needed describe.tool to find the schema. (Minor DX nit, see below.)
  6. executor.coreTools.oauth.start({ endpoint, credentialScope, connectionId, pluginId: "mcp", strategy: { kind: "dynamic-dcr" } })authorizationUrl. User completed browser sign-in.
  7. executor.coreTools.connections.list → connection present, valid token.
  8. BUG Add organization billing model and members/billing console flows #1executor.mcp.addSource({ ..., credentials: { scope, auth: { kind: "connection", connectionId } } })
    ok: true, toolCount: 0, discovery.status: "failed" (Failed connecting via sse).
    The auth field only accepts { kind: "none" } or { oauth2?: HttpOAuthConfigureInput }. { kind: "connection", ... } is the value shape used for headers/queryParams, not for auth. It was silently dropped — no validation error — so no binding was created.
  9. Re-ran step 8 with remoteTransport: "streamable-http" (same wrong auth shape) → discovery.status: "failed" (Failed connecting via streamable-http), still ok: true.
  10. executor.coreTools.sources.bindings.list[] (confirms no binding was ever created in steps 8–9, despite ok: true).
  11. executor.mcp.configureSource({ source, scope, auth: { oauth2: { connection: { kind: "connection", connectionId } } } }){ configured: true }. bindings.list now shows the binding.
  12. BUG Add organization billing model and members/billing console flows #2 + Add Cloudflare Workers sandbox runtime for isolated code execution #3executor.coreTools.sources.refresh({ id, targetScope }){ refreshed: true }.
    But executor.sources.list still shows notion with toolCount: 0. Repeated refresh calls all return refreshed: true; tool count never changes. No discovery status/error is returned by refresh at all.
  13. Workaround / fixexecutor.mcp.addSource({ ..., remoteTransport: "streamable-http", credentials: { scope, auth: { oauth2: { connection: { kind: "connection", connectionId } } } } })
    { toolCount: 14, discovery: { status: "ok" } }. Tools finally registered.
  14. Verified: notion.notion_search({ query: "a" }) returned real workspace pages.

Expected vs. actual

# Expected Actual
1 addSource/configureSource reject an invalid auth shape with a validation error Returns ok: true, silently drops the credential, creates no binding
2 refresh reports discovery status/error (like addSource's discovery field) Always returns { refreshed: true }, even on failure / 0 tools
3 refresh re-runs discovery using the current bindings Does not pick up a binding added after the source was created; addSource re-run is the only path that discovers

Impact

  • A source can sit in a permanently broken "0 tools" state with no surfaced error, and the documented recovery (refresh) silently does nothing.
  • The only working recovery (re-addSource with inline creds) is non-obvious and undocumented for the OAuth-after-create flow.
  • Hard to debug without dropping to bindings.list / getSource / repeated describe.tool.

Suggested fixes

  • Validate the auth field in mcp.addSource / mcp.configureSource (and the GraphQL/OpenAPI equivalents). Reject { kind: "connection" } in auth with a clear message pointing to auth.oauth2.connection.
  • Make refresh return a discovery field identical to addSource (status, message, stage, toolCount). Never return refreshed: true when discovery failed or yielded 0 tools without flagging it.
  • Make refresh actually re-run discovery against current bindings, so it's a true equivalent of add for the post-OAuth flow. If that's intentionally not the case, document it and have the connect flow tell the user to re-add.
  • Surface discovery errors in the UI next to the source (the connector showed "0 tools" with no error/badge).

Observability gap (the broader ask)

There's no audit trail of what the agent/runtime did during connect. We should register every tool call the runtime makes during a connect/discovery flow — tool path, redacted args, scope, outcome, and discovery status — and expose it (per-source activity log / debug panel / structured logs). With that, this whole sequence would have been one glance instead of a dozen probing calls. At minimum: log discovery attempts and their failure reasons against the source, and show them in the source detail view.

DX nits (low priority)

  • oauth.start requires connectionId, pluginId, strategy but the tool description reads as if endpoint + credentialScope suffice. Consider documenting required fields or providing a higher-level "connect preset" helper that wires DCR end-to-end.
  • sources.refresh takes { id, targetScope } while sibling tools take { source: { id, scope } }. Inconsistent argument shapes across the source tools.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions