openclaw - 💡(How to fix) Fix bundle-mcp: support OAuth 2.1 client_credentials for remote MCP servers (currently limited to static bearer headers) [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#84294Fetched 2026-05-20 03:41:43
View on GitHub
Comments
1
Participants
2
Timeline
11
Reactions
1
Timeline (top)
labeled ×10commented ×1

Error Message

If a referenced variable is missing or empty, an error will be thrown present, log a warn and prefer oauth (more explicit auth method wins). shapes warn-and-skip rather than crash; unsupported grant_type returns clear error. are set, warn fires and oauth wins. The mutual-exclusion warn-log is the only behavior change for existing non-CC grant types with a clear error.

Root Cause

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.

Fix Action

Fix / Workaround

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.

  • 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.

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

Code Example

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

---

{
  "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"
        }
      }
    }
  }
}

---

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;
   }

---

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,
     };
   }

---

$ 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"]
}

---

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

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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix bundle-mcp: support OAuth 2.1 client_credentials for remote MCP servers (currently limited to static bearer headers) [1 comments, 2 participants]