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:
-
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)
-
Thread oauth through resolveHttpTransportConfig return shape
so the transport instantiation site sees it (~2 LOC).
-
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
- Schema parsing:
oauth block extracted and validated; malformed
shapes warn-and-skip rather than crash; unsupported grant_type
returns clear error.
- Mutual exclusion: when both
oauth and headers.Authorization
are set, warn fires and oauth wins.
- Transport wiring:
StreamableHTTPClientTransport is constructed
with authProvider (not requestInit.headers) when oauth is
set. Mock SDK to assert.
- 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.
Problem
mcpServersentries for HTTP/streamable-HTTP servers only accept staticbearer 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, perdocs/help/environment.md:and
docs/gateway/configuration-reference.md:Confirmed in source: in
pi-bundle-mcp-runtime-*.js,resolveHttpMcpServerLaunchConfigextracts headers once and passes themas static
requestInit.headersintoStreamableHTTPClientTransportatconstruction. The SDK's
requestInitis a plainRequestInit, not acallable — 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
oauthblock alongside the existingheadersfield onHTTP/streamable-HTTP
mcpServersentries:oauthandheaders.Authorizationare mutually exclusive. If both arepresent, log a warn and prefer
oauth(more explicit auth method wins).oauth.clientSecretcan use the existing${VAR}interpolation —that's load-time, but the access token rotates internally inside
the SDK's
ClientCredentialsProviderwith no operator involvement, soload-time
clientSecretresolution is fine. The token-rotation problemthe 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). OpenClawalready 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.tsper the bundlecomments.
Three changes:
resolveHttpMcpServerLaunchConfig— extract optionaloauthfield from
raw, validate its shape, propagate viaconfig.oauth:(~20 LOC)
Thread
oauththroughresolveHttpTransportConfigreturn shapeso the transport instantiation site sees it (~2 LOC).
Transport-instantiation site — when
resolved.oauthis set,construct
ClientCredentialsProviderand pass asauthProvider:(~10 LOC; replaces the existing 7-LOC block at the same site)
Total runtime delta: ~30 LOC, contained in one file.
ClientCredentialsProviderauto-discovers the token endpoint by hitting/.well-known/oauth-authorization-serverat the MCP URL's authority(per MCP spec §authorization). An OAuth-capable MCP server advertises
client_credentials in its discovery response, for example:
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 wantsto drop them into OpenClaw's
mcpServersconfig — but the currentschema 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
oauthblock extracted and validated; malformedshapes warn-and-skip rather than crash; unsupported grant_type
returns clear error.
oauthandheaders.Authorizationare set, warn fires and
oauthwins.StreamableHTTPClientTransportis constructedwith
authProvider(notrequestInit.headers) whenoauthisset. Mock SDK to assert.
at an MCP server that requires CC, confirm token acquisition + auto
refresh works.
Backward compatibility
oauthis purely additive. All existing entries without it continueto 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 + PKCEwould require redirect handlingand 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.
it's the operator's job to register an OAuth client on the MCP
server's side and paste the resulting
client_id/client_secretinto the
oauthblock. 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 fromsrc/agents/mcp-transport.tsandsrc/agents/mcp-http.tsper theinline bundle comments.
@modelcontextprotocol/sdkv1.29.0 is already bundled at<openclaw-install>/node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth-extensions.js.ClientCredentialsProvideris exported from that module; verified byinspecting 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.