Skip to content

bundle-mcp: support OAuth 2.1 client_credentials for remote MCP servers (currently limited to static bearer headers) #84294

Description

@CaiusPrime

Problem

mcpServers entries for HTTP/streamable-HTTP servers only accept static
bearer headers:

{
  "type": "url",
  "url": "https://mcp.example.com/mcp",
  "transport": "streamable-http",
  "headers": {
    "Authorization": "Bearer ${MY_TOKEN}"
  }
}

For MCP servers that require OAuth 2.1 client_credentials (the documented
"machine-to-machine" grant per the MCP spec, RFC 6749 §4.4), there is no
config surface. Operators have to fall back to whatever long-lived legacy
bearer the server still grandfathers — losing per-client scope, audit
attribution, and token rotation.

Concrete impact: in a multi-agent setup where several agents share
one MCP server and the server issues per-client OAuth credentials so
each agent's writes are scoped/attributed to its own bucket, agents
running on OpenClaw cannot consume those credentials. They fall back to
the server's legacy bearer (typically pinned to a default bucket), so
their writes contaminate the shared default while peer agents on other
runtimes route correctly. Per-agent isolation is impossible for the
OpenClaw-hosted agent in an otherwise OAuth-capable fleet.

Why static-bearer + ${VAR} + refresh daemon doesn't work

${VAR} interpolation in headers is load-time only, per
docs/help/environment.md:

Both methods resolve from the process environment at activation time.

and docs/gateway/configuration-reference.md:

If a referenced variable is missing or empty, an error will be thrown
during config load.

Confirmed in source: in pi-bundle-mcp-runtime-*.js,
resolveHttpMcpServerLaunchConfig extracts headers once and passes them
as static requestInit.headers into StreamableHTTPClientTransport at
construction. The SDK's requestInit is a plain RequestInit, not a
callable — no per-request header hook exists.

So an external token-refresh daemon writing to an env var would require
restarting the OpenClaw gateway every refresh cycle (typically ~50 min
for OAuth access tokens with 3600s TTL). Untenable for a 24/7 agent
runtime: every refresh interrupts in-flight tool calls and resets
session-bound state.

Proposed schema addition

Add an optional oauth block alongside the existing headers field on
HTTP/streamable-HTTP mcpServers entries:

{
  "mcp": {
    "servers": {
      "example-remote": {
        "transport": "streamable-http",
        "url": "https://mcp.example.com/mcp",
        "oauth": {
          "clientId":     "${MCP_CLIENT_ID}",
          "clientSecret": "${MCP_CLIENT_SECRET}",
          "grantType":    "client_credentials",
          "scope":        "read write"
        }
      }
    }
  }
}

oauth and headers.Authorization are mutually exclusive. If both are
present, log a warn and prefer oauth (more explicit auth method wins).

oauth.clientSecret can use the existing ${VAR} interpolation —
that's load-time, but the access token rotates internally inside
the SDK's ClientCredentialsProvider with no operator involvement, so
load-time clientSecret resolution is fine. The token-rotation problem
the static-header path has goes away because the SDK provider handles
refresh per request.

Reference implementation

The MCP TypeScript SDK already ships ClientCredentialsProvider (v1.29.0+,
in @modelcontextprotocol/sdk/client/auth-extensions.js). OpenClaw
already depends on this exact version. No new dependencies.

The patch site is the emitted pi-bundle-mcp-runtime-*.js; in source,
src/agents/mcp-transport.ts + src/agents/mcp-http.ts per the bundle
comments.

Three changes:

  1. resolveHttpMcpServerLaunchConfig — extract optional oauth
    field from raw, validate its shape, propagate via config.oauth:

    let oauth: { clientId: string; clientSecret: string; grantType?: string; scope?: string } | undefined;
    if (raw.oauth !== void 0 && raw.oauth !== null) {
      if (!isMcpConfigRecord(raw.oauth)) {
        options?.onMalformedOAuth?.(raw.oauth);
      } else if (typeof raw.oauth.clientId === 'string' && typeof raw.oauth.clientSecret === 'string') {
        const grantType = typeof raw.oauth.grantType === 'string'
          ? raw.oauth.grantType : 'client_credentials';
        if (grantType !== 'client_credentials') {
          // authorization_code+PKCE requires redirect handling — out of scope for v1
          return { ok: false, reason: `unsupported grant_type "${grantType}"; only client_credentials is supported` };
        }
        oauth = {
          clientId: raw.oauth.clientId,
          clientSecret: raw.oauth.clientSecret,
          grantType,
          scope: typeof raw.oauth.scope === 'string' ? raw.oauth.scope : undefined,
        };
      }
    }
    if (oauth && headers && headers.Authorization) {
      logWarn(`bundle-mcp: server "${serverName}": both "oauth" and "headers.Authorization" are set; "oauth" wins.`);
      delete headers.Authorization;
    }

    (~20 LOC)

  2. Thread oauth through resolveHttpTransportConfig return shape
    so the transport instantiation site sees it (~2 LOC).

  3. Transport-instantiation site — when resolved.oauth is set,
    construct ClientCredentialsProvider and pass as authProvider:

    import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js';
    // ...
    if (resolved.transportType === 'streamable-http') {
      const transportOpts: StreamableHTTPClientTransportOptions = {
        fetch: fetchStreamableHttpWithRedirectScrub,
      };
      if (resolved.oauth) {
        transportOpts.authProvider = new ClientCredentialsProvider({
          clientId: resolved.oauth.clientId,
          clientSecret: resolved.oauth.clientSecret,
          scope: resolved.oauth.scope,
        });
      } else if (resolved.headers) {
        transportOpts.requestInit = { headers: resolved.headers };
      }
      return {
        transport: new StreamableHTTPClientTransport(new URL(resolved.url), transportOpts),
        description: resolved.description,
        transportType: 'streamable-http',
        connectionTimeoutMs: resolved.connectionTimeoutMs,
      };
    }

    (~10 LOC; replaces the existing 7-LOC block at the same site)

Total runtime delta: ~30 LOC, contained in one file.

ClientCredentialsProvider auto-discovers the token endpoint by hitting
/.well-known/oauth-authorization-server at the MCP URL's authority
(per MCP spec §authorization). An OAuth-capable MCP server advertises
client_credentials in its discovery response, for example:

$ curl -s https://mcp.example.com/.well-known/oauth-authorization-server | jq
{
  "issuer": "https://mcp.example.com/",
  "token_endpoint": "https://mcp.example.com/token",
  "grant_types_supported": ["authorization_code","refresh_token","client_credentials"],
  "scopes_supported": ["read","write","admin"],
  "token_endpoint_auth_methods_supported": ["client_secret_post","none"]
}

So no additional config is needed on the OpenClaw side to discover the
token endpoint; the SDK handles it.

Concrete use case

Three agents on different runtimes share one MCP server. The server
issues per-client OAuth credentials so each agent's writes are
attributable and scoped to its own bucket. Two of the three agents
can consume OAuth credentials via their MCP client config; the
OpenClaw-hosted agent falls through to legacy bearer and loses
per-client scope as a result. The operator registers an OAuth client
on the MCP server, obtains { client_id, client_secret }, and wants
to drop them into OpenClaw's mcpServers config — but the current
schema offers no place to put them.

With the proposed schema, the OpenClaw side becomes:

{
  "mcp": {
    "servers": {
      "example-remote": {
        "transport": "streamable-http",
        "url": "https://mcp.example.com/mcp",
        "oauth": {
          "clientId": "${MCP_CLIENT_ID}",
          "clientSecret": "${MCP_CLIENT_SECRET}"
        }
      }
    }
  }
}

Writes route to the agent's own bucket, attributable per request,
isolated, with no daemon dance and no gateway restarts.

Test surface

  1. Schema parsing: oauth block extracted and validated; malformed
    shapes warn-and-skip rather than crash; unsupported grant_type
    returns clear error.
  2. Mutual exclusion: when both oauth and headers.Authorization
    are set, warn fires and oauth wins.
  3. Transport wiring: StreamableHTTPClientTransport is constructed
    with authProvider (not requestInit.headers) when oauth is
    set. Mock SDK to assert.
  4. End-to-end (optional, can be follow-up): point an integration test
    at an MCP server that requires CC, confirm token acquisition + auto
    refresh works.

Backward compatibility

oauth is purely additive. All existing entries without it continue
to work exactly as before (static bearer via headers.Authorization,
SSE/streamable-HTTP transport choice unchanged).

The mutual-exclusion warn-log is the only behavior change for existing
configs, and it only fires when an operator has intentionally written
both forms — which is contradictory and worth surfacing.

Scope notes / non-goals

  • authorization_code + PKCE would require redirect handling
    and is out of scope for v1; bundle-mcp is a service-runtime
    context, not a user-agent context. The patch validates and rejects
    non-CC grant types with a clear error.
  • Dynamic Client Registration (RFC 7591) is also out of scope —
    it's the operator's job to register an OAuth client on the MCP
    server's side and paste the resulting client_id/client_secret
    into the oauth block. DCR could come later as a separate flag.

Repro

OpenClaw v2026.5.18. Source paths reference the emitted bundle file
<openclaw-install>/dist/pi-bundle-mcp-runtime-*.js, generated from
src/agents/mcp-transport.ts and src/agents/mcp-http.ts per the
inline bundle comments.

@modelcontextprotocol/sdk v1.29.0 is already bundled at
<openclaw-install>/node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth-extensions.js.
ClientCredentialsProvider is exported from that module; verified by
inspecting the installed .d.ts.


Happy to open a PR with the patch + tests if this is a direction you'd take — just say the word.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions