Refresh MCP OAuth auth-profile tokens (#96120)

* Refresh MCP OAuth auth-profile tokens

* Rotate Codex MCP binding on bearer changes

* Preserve agent scope for MCP auth profiles

* Preserve Codex MCP tool filters

* Keep Codex MCP projection helper local-only

* Fix Codex projection package boundary artifacts

* Revert "Fix Codex projection package boundary artifacts"

This reverts commit 13bcaed3dafafa8238e9ab16bf201f56d300fae0.

* Revert "Keep Codex MCP projection helper local-only"

This reverts commit 19751f4922d072296376c3ec68d5660c7dfe4204.

* Trigger CI rerun for OAuth MCP PR

* Fail closed for remote Codex MCP bearer projection

* Fix MCP OAuth bearer token projection

* fix: project MCP-native OAuth credentials

* fix: align MCP SDK surface budget

* fix(mcp): keep agents available before OAuth login

---------

Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
James Armstead
2026-07-10 19:49:43 -05:00
committed by GitHub
parent 5c77724ff8
commit 65cc86f45c
25 changed files with 1636 additions and 40 deletions
+13 -3
View File
@@ -669,7 +669,7 @@ Connects to a remote MCP server over HTTP Server-Sent Events.
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `connectTimeout` | Per-server connection timeout in seconds (optional) |
| `timeout` / `requestTimeoutMs` | Per-server MCP request timeout in seconds or ms |
| `auth: "oauth"` | Use MCP OAuth token storage and `openclaw mcp login` |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
@@ -697,7 +697,11 @@ Sensitive values in `url` (userinfo) and `headers` are redacted in logs and stat
### OAuth workflow
OAuth is for HTTP MCP servers that advertise the MCP OAuth flow. Static `Authorization` headers are ignored for a server while `auth: "oauth"` is enabled.
OAuth is for HTTP MCP servers that advertise the MCP OAuth flow. Static `Authorization` headers are ignored for a server while `auth: "oauth"` is enabled. Credentials saved by `openclaw mcp login` work with embedded MCP, CLI runners, and the local Codex app-server.
Until credentials are available, OpenClaw omits only that MCP server from the agent runtime instead of failing the agent turn. The operator, or an agent with shell access, can then run `openclaw mcp login <name>` and use the server on a later turn.
When a remote MCP service is already backed by a separate OpenClaw refresh-capable auth profile, you can optionally set `oauth.authProfileId`. OpenClaw refreshes either credential source before runtime projection and passes only the current access token to the downstream MCP client.
<Steps>
<Step title="Save the server">
@@ -707,6 +711,12 @@ OAuth is for HTTP MCP servers that advertise the MCP OAuth flow. Static `Authori
openclaw mcp set docs '{"url":"https://mcp.example.com/mcp","transport":"streamable-http","auth":"oauth","oauth":{"scope":"docs.read"}}'
```
For an auth-profile-backed bearer, save the profile binding:
```bash
openclaw mcp set docs '{"url":"https://mcp.example.com/mcp","transport":"streamable-http","auth":"oauth","oauth":{"authProfileId":"docs:mcp"}}'
```
</Step>
<Step title="Start login">
Run login to create the authorization request.
@@ -759,7 +769,7 @@ If the provider rotates tokens or the authorization state gets stuck, run `openc
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `connectTimeout` | Per-server connection timeout in seconds (optional) |
| `timeout` / `requestTimeoutMs` | Per-server MCP request timeout in seconds or ms |
| `auth: "oauth"` | Use MCP OAuth token storage and `openclaw mcp login` |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
@@ -1,4 +1,5 @@
// Codex plugin module implements thread lifecycle behavior.
import crypto from "node:crypto";
import {
buildSkillWorkshopPromptSection,
embeddedAgentLog,
@@ -7,7 +8,7 @@ import {
SKILL_WORKSHOP_TOOL_NAME,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { buildCodexUserMcpServersThreadConfigPatch } from "openclaw/plugin-sdk/codex-mcp-projection";
import { buildCodexUserMcpServersThreadConfigPatchForRuntime } from "openclaw/plugin-sdk/codex-mcp-projection";
import { listRegisteredPluginAgentPromptGuidance } from "openclaw/plugin-sdk/plugin-runtime";
import { CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY } from "../../prompt-overlay.js";
import {
@@ -373,8 +374,15 @@ export async function startOrResumeThread(params: {
const userMcpServersConfigPatch =
params.userMcpServersEnabled === false
? undefined
: buildCodexUserMcpServersThreadConfigPatch(params.params.config, {
: await buildCodexUserMcpServersThreadConfigPatchForRuntime(params.params.config, {
agentId: params.agentId ?? params.params.agentId,
agentDir: params.params.agentDir,
allowLiteralOAuthProjection: params.appServer.connectionClass !== "remote",
onServerUnavailable: (serverName, error) =>
embeddedAgentLog.warn("skipping unavailable MCP OAuth server", {
serverName,
error: formatErrorMessage(error),
}),
});
const userMcpServersFingerprint =
fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
@@ -1765,7 +1773,40 @@ function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
function fingerprintUserMcpServersConfigPatch(
configPatch: JsonObject | undefined,
): string | undefined {
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
return configPatch
? JSON.stringify(stabilizeJsonValue(redactUserMcpServersFingerprintSecrets(configPatch)))
: undefined;
}
function redactUserMcpServersFingerprintSecrets(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(redactUserMcpServersFingerprintSecrets);
}
if (!value || typeof value !== "object") {
return value;
}
const next: JsonObject = {};
for (const [key, entry] of Object.entries(value)) {
if (key === "http_headers" && entry && typeof entry === "object" && !Array.isArray(entry)) {
next[key] = Object.fromEntries(
Object.entries(entry).map(([header, headerValue]) => [
header,
header.toLowerCase() === "authorization"
? fingerprintUserMcpServersAuthorizationHeader(headerValue)
: headerValue,
]),
) as JsonObject;
continue;
}
next[key] = redactUserMcpServersFingerprintSecrets(entry);
}
return next;
}
function fingerprintUserMcpServersAuthorizationHeader(value: unknown): string {
return typeof value === "string" && value.length > 0
? `<redacted:sha256:${crypto.createHash("sha256").update(value).digest("hex")}>`
: "<redacted>";
}
function fingerprintJsonObject(value: JsonObject): string {
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import {
readCodexAppServerBinding,
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
writeCodexAppServerBinding,
@@ -485,6 +486,109 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
expect(startParams?.config?.mcp_servers).toBeUndefined();
});
it("starts a new thread when a user MCP Authorization bearer changes without storing the bearer", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
const workspaceDir = path.join(tempDir, "workspace");
const createConfig = (authorization: string) =>
({
mcp: {
servers: {
ducktape: {
transport: "streamable-http",
url: "https://agents.ducktape.xyz/mcp",
headers: {
Authorization: authorization,
"x-tenant": "keep",
},
},
},
},
}) as unknown as EmbeddedRunAttemptParams["config"];
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-with-current-bearer");
}
if (method === "thread/resume") {
return threadResumeResult("thread-with-stale-bearer");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, createConfig("Bearer access-token-one")),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
const firstBinding = await readCodexAppServerBinding(sessionFile);
expect(firstBinding?.userMcpServersFingerprint).toContain("<redacted:sha256:");
expect(firstBinding?.userMcpServersFingerprint).not.toContain("access-token-one");
request.mockClear();
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, createConfig("Bearer access-token-two")),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
const startParams = request.mock.calls[0]?.[1] as {
config?: { mcp_servers?: Record<string, { http_headers?: Record<string, string> }> };
};
expect(startParams?.config?.mcp_servers?.ducktape?.http_headers?.Authorization).toBe(
"Bearer access-token-two",
);
const secondBinding = await readCodexAppServerBinding(sessionFile);
expect(secondBinding?.userMcpServersFingerprint).toContain("<redacted:sha256:");
expect(secondBinding?.userMcpServersFingerprint).not.toContain("access-token-two");
expect(secondBinding?.userMcpServersFingerprint).not.toBe(
firstBinding?.userMcpServersFingerprint,
);
});
it("omits MCP OAuth servers instead of sending bearers to a remote app-server", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string, _params: unknown) => {
if (method === "thread/start") {
return threadStartResult("thread-without-oauth-mcp");
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir, {
mcp: {
servers: {
ducktape: {
transport: "streamable-http",
url: "https://agents.ducktape.xyz/mcp",
auth: "oauth",
oauth: { authProfileId: "ducktape:mcp" },
},
},
},
} as unknown as EmbeddedRunAttemptParams["config"]),
cwd: workspaceDir,
dynamicTools: [],
appServer: {
...createAppServerOptions(),
connectionClass: "remote",
},
});
const startParams = request.mock.calls[0]?.[1] as {
config?: { mcp_servers?: Record<string, unknown> };
};
expect(startParams?.config?.mcp_servers).toBeUndefined();
});
it("resends user MCP config when resuming a thread with the matching fingerprint", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
+2 -2
View File
@@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10493,
10494,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5236,
5237,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -1628,6 +1628,56 @@ process.on("SIGINT", shutdown);`,
expect(manager.listSessionIds()).not.toContain("session-a");
});
it("preserves agentDir scope when creating and reusing session MCP runtimes", async () => {
const created: Array<{ sessionId: string; agentDir?: string }> = [];
const disposed: Array<{ sessionId: string; agentDir?: string }> = [];
const createRuntime: RuntimeFactory = (params) => {
created.push({ sessionId: params.sessionId, agentDir: params.agentDir });
const runtime = makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]);
return {
...runtime,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
configFingerprint: params.configFingerprint ?? "fingerprint",
dispose: async () => {
disposed.push({ sessionId: params.sessionId, agentDir: params.agentDir });
},
};
};
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
const runtimeA = await manager.getOrCreate({
sessionId: "session-agent-dir",
sessionKey: "agent:test:session-agent-dir",
workspaceDir: "/workspace",
agentDir: "/agents/one",
});
const runtimeB = await manager.getOrCreate({
sessionId: "session-agent-dir",
sessionKey: "agent:test:session-agent-dir",
workspaceDir: "/workspace",
agentDir: "/agents/one",
});
const runtimeC = await manager.getOrCreate({
sessionId: "session-agent-dir",
sessionKey: "agent:test:session-agent-dir",
workspaceDir: "/workspace",
agentDir: "/agents/two",
});
expect(runtimeA).toBe(runtimeB);
expect(runtimeC).not.toBe(runtimeA);
expect(created).toEqual([
{ sessionId: "session-agent-dir", agentDir: "/agents/one" },
{ sessionId: "session-agent-dir", agentDir: "/agents/two" },
]);
expect(disposed).toEqual([{ sessionId: "session-agent-dir", agentDir: "/agents/one" }]);
await manager.disposeAll();
});
it("peeks existing runtimes and populated catalogs without creating new runtimes", async () => {
let catalogReady = false;
const createRuntime: RuntimeFactory = (params) => {
+12 -1
View File
@@ -523,6 +523,7 @@ export function createSessionMcpRuntime(params: {
sessionId: string;
sessionKey?: string;
workspaceDir: string;
agentDir?: string;
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}): SessionMcpRuntime {
@@ -656,7 +657,10 @@ export function createSessionMcpRuntime(params: {
}> = [];
for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) {
failIfDisposed();
const resolved = resolveMcpTransport(serverName, rawServer);
const resolved = resolveMcpTransport(serverName, rawServer, {
cfg: params.cfg,
agentDir: params.agentDir,
});
if (!resolved) {
continue;
}
@@ -925,6 +929,7 @@ export function createSessionMcpRuntime(params: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
configFingerprint,
createdAt,
get lastUsedAt() {
@@ -1041,6 +1046,7 @@ function createSessionMcpRuntimeManager(
{
promise: Promise<SessionMcpRuntime>;
workspaceDir: string;
agentDir?: string;
configFingerprint: string;
}
>();
@@ -1129,6 +1135,7 @@ function createSessionMcpRuntimeManager(
if (existing) {
if (
existing.workspaceDir !== params.workspaceDir ||
existing.agentDir !== params.agentDir ||
existing.configFingerprint !== nextFingerprint
) {
runtimesBySessionId.delete(params.sessionId);
@@ -1143,6 +1150,7 @@ function createSessionMcpRuntimeManager(
if (inFlight) {
if (
inFlight.workspaceDir === params.workspaceDir &&
inFlight.agentDir === params.agentDir &&
inFlight.configFingerprint === nextFingerprint
) {
return inFlight.promise;
@@ -1158,6 +1166,7 @@ function createSessionMcpRuntimeManager(
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
cfg: params.cfg,
configFingerprint: nextFingerprint,
}),
@@ -1170,6 +1179,7 @@ function createSessionMcpRuntimeManager(
createInFlight.set(params.sessionId, {
promise: created,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
configFingerprint: nextFingerprint,
});
try {
@@ -1241,6 +1251,7 @@ export async function getOrCreateSessionMcpRuntime(params: {
sessionId: string;
sessionKey?: string;
workspaceDir: string;
agentDir?: string;
cfg?: OpenClawConfig;
}): Promise<SessionMcpRuntime> {
return await getSessionMcpRuntimeManager().getOrCreate(params);
+2
View File
@@ -67,6 +67,7 @@ export type SessionMcpRuntime = {
sessionId: string;
sessionKey?: string;
workspaceDir: string;
agentDir?: string;
configFingerprint: string;
createdAt: number;
lastUsedAt: number;
@@ -91,6 +92,7 @@ export type SessionMcpRuntimeManager = {
sessionId: string;
sessionKey?: string;
workspaceDir: string;
agentDir?: string;
cfg?: OpenClawConfig;
}) => Promise<SessionMcpRuntime>;
bindSessionKey: (sessionKey: string, sessionId: string) => void;
+59 -4
View File
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js";
import { isValidAgentId, normalizeAgentId } from "../../routing/session-key.js";
import { buildCodexMcpServersConfig, normalizeCodexMcpServerConfig } from "../codex-mcp-config.js";
import { requiresMcpBearerProjection, resolveMcpBearerBundleConfig } from "../mcp-auth-profile.js";
import { isRecord } from "./bundle-mcp-adapter-shared.js";
import { serializeTomlInlineValue } from "./toml-inline.js";
@@ -25,6 +26,9 @@ type CodexThreadConfigObject = { [key: string]: CodexThreadConfigValue };
type CodexUserMcpServersProjectionOptions = {
agentId?: string;
agentDir?: string;
allowLiteralOAuthProjection?: boolean;
onServerUnavailable?: (serverName: string, error: unknown) => void;
};
function normalizeAgentIds(value: unknown): string[] {
@@ -95,13 +99,64 @@ export function buildCodexUserMcpServersThreadConfigPatch(
if (!isCodexMcpServerAllowedForAgent(server as BundleMcpServerConfig, options)) {
continue;
}
mcp_servers[name] = normalizeCodexMcpServerConfig(
name,
server as BundleMcpServerConfig,
) as CodexThreadConfigObject;
mcp_servers[name] = normalizeCodexMcpServerConfig(name, server) as CodexThreadConfigObject;
}
if (Object.keys(mcp_servers).length === 0) {
return undefined;
}
return { mcp_servers };
}
/** Async runtime projection that resolves OpenClaw-managed MCP bearer tokens. */
export async function buildCodexUserMcpServersThreadConfigPatchForRuntime(
cfg: OpenClawConfig | undefined,
options?: CodexUserMcpServersProjectionOptions,
): Promise<{ mcp_servers: CodexThreadConfigObject } | undefined> {
const userServers = normalizeConfiguredMcpServers(cfg?.mcp?.servers);
const entries = Object.entries(userServers);
if (entries.length === 0) {
return undefined;
}
let allowedServers = Object.fromEntries(
entries.filter(
([, server]) =>
server.enabled !== false &&
isCodexMcpServerAllowedForAgent(server as BundleMcpServerConfig, options),
),
) as BundleMcpConfig["mcpServers"];
if (Object.keys(allowedServers).length === 0) {
return undefined;
}
if (options?.allowLiteralOAuthProjection === false) {
const remoteSafeServers: BundleMcpConfig["mcpServers"] = {};
for (const [serverName, server] of Object.entries(allowedServers)) {
if (requiresMcpBearerProjection(server)) {
options.onServerUnavailable?.(
serverName,
new Error(
`MCP OAuth bearer projection is only supported for local app-server connections.`,
),
);
continue;
}
remoteSafeServers[serverName] = server;
}
allowedServers = remoteSafeServers;
}
if (Object.keys(allowedServers).length === 0) {
return undefined;
}
const resolvedConfig = await resolveMcpBearerBundleConfig({
config: { mcpServers: allowedServers },
cfg,
agentDir: options?.agentDir,
tokenProjection: "literal",
omitUnavailableOAuthServers: true,
onServerUnavailable: options?.onServerUnavailable,
});
const mcp_servers: CodexThreadConfigObject = {};
for (const [name, server] of Object.entries(resolvedConfig.config.mcpServers)) {
mcp_servers[name] = normalizeCodexMcpServerConfig(name, server) as CodexThreadConfigObject;
}
return Object.keys(mcp_servers).length === 0 ? undefined : { mcp_servers };
}
@@ -1,9 +1,36 @@
/** Tests projecting OpenClaw user MCP servers into Codex app-server config. */
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { buildCodexUserMcpServersThreadConfigPatch } from "./bundle-mcp-codex.js";
import {
buildCodexUserMcpServersThreadConfigPatch,
buildCodexUserMcpServersThreadConfigPatchForRuntime,
} from "./bundle-mcp-codex.js";
const authMocks = vi.hoisted(() => ({
loadAuthProfileStoreForSecretsRuntime: vi.fn(),
resolveApiKeyForProfile: vi.fn(),
resolveMcpOAuthAccessToken: vi.fn(),
}));
vi.mock("../auth-profiles/store.js", () => ({
loadAuthProfileStoreForSecretsRuntime: authMocks.loadAuthProfileStoreForSecretsRuntime,
}));
vi.mock("../auth-profiles/oauth.js", () => ({
resolveApiKeyForProfile: authMocks.resolveApiKeyForProfile,
}));
vi.mock("../mcp-oauth.js", () => ({
resolveMcpOAuthAccessToken: authMocks.resolveMcpOAuthAccessToken,
}));
describe("buildCodexUserMcpServersThreadConfigPatch", () => {
beforeEach(() => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReset();
authMocks.resolveApiKeyForProfile.mockReset();
authMocks.resolveMcpOAuthAccessToken.mockReset();
});
it("returns undefined when cfg has no mcp.servers (regression: #80814)", () => {
expect(buildCodexUserMcpServersThreadConfigPatch(undefined)).toBeUndefined();
expect(buildCodexUserMcpServersThreadConfigPatch({} as OpenClawConfig)).toBeUndefined();
@@ -89,6 +116,52 @@ describe("buildCodexUserMcpServersThreadConfigPatch", () => {
});
});
it("projects exact OpenClaw MCP tool filters into Codex-native tool filters", () => {
const patch = buildCodexUserMcpServersThreadConfigPatch({
mcp: {
servers: {
docs: {
transport: "streamable-http",
url: "https://docs.example.com/mcp",
toolFilter: {
include: ["search_docs", "read_docs"],
exclude: ["delete_docs"],
},
},
},
},
} as unknown as OpenClawConfig);
expect(patch).toStrictEqual({
mcp_servers: {
docs: {
url: "https://docs.example.com/mcp",
enabled_tools: ["search_docs", "read_docs"],
disabled_tools: ["delete_docs"],
},
},
});
});
it("rejects wildcard OpenClaw MCP tool filters that Codex cannot project exactly", () => {
expect(() =>
buildCodexUserMcpServersThreadConfigPatch({
mcp: {
servers: {
docs: {
transport: "streamable-http",
url: "https://docs.example.com/mcp",
toolFilter: {
include: ["search_*"],
},
},
},
},
} as unknown as OpenClawConfig),
).toThrow(
'Cannot project mcp.servers.docs.toolFilter.include pattern "search_*" into Codex enabled_tools',
);
});
it("uses the Codex-native approval spelling when configured", () => {
const patch = buildCodexUserMcpServersThreadConfigPatch({
mcp: {
@@ -281,4 +354,205 @@ describe("buildCodexUserMcpServersThreadConfigPatch", () => {
expect(patch!.mcp_servers.one).toMatchObject({ command: "one" });
expect(patch!.mcp_servers.two).toMatchObject({ command: "two" });
});
it("projects auth-profile backed user MCP servers with a fresh bearer header at runtime", async () => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({
version: 1,
profiles: {
"ducktape:mcp": {
type: "oauth",
provider: "ducktape",
access: "expired-access",
refresh: "refresh-token-must-not-project",
expires: 1,
},
},
});
authMocks.resolveApiKeyForProfile.mockResolvedValueOnce({
apiKey: "fresh-access-token",
provider: "ducktape",
profileId: "ducktape:mcp",
profileType: "oauth",
credential: {
type: "oauth",
provider: "ducktape",
access: "fresh-access-token",
refresh: "refresh-token-must-not-project",
expires: Date.now() + 60_000,
},
});
const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({
mcp: {
servers: {
ducktape: {
transport: "streamable-http",
url: "https://agents.ducktape.xyz/mcp",
auth: "oauth",
oauth: { authProfileId: "ducktape:mcp" },
headers: {
Authorization: "Bearer stale-access",
"x-tenant": "keep",
},
},
},
},
} as unknown as OpenClawConfig);
expect(patch).toStrictEqual({
mcp_servers: {
ducktape: {
url: "https://agents.ducktape.xyz/mcp",
http_headers: {
Authorization: "Bearer fresh-access-token",
"x-tenant": "keep",
},
},
},
});
expect(JSON.stringify(patch)).not.toContain("refresh-token-must-not-project");
});
it("projects MCP-native OAuth credentials into local Codex runtime config", async () => {
authMocks.resolveMcpOAuthAccessToken.mockResolvedValueOnce("native-access-token");
const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({
mcp: {
servers: {
docs: {
transport: "streamable-http",
url: "https://mcp.example.com/mcp",
auth: "oauth",
oauth: { scope: "docs.read" },
},
},
},
} as unknown as OpenClawConfig);
expect(patch).toStrictEqual({
mcp_servers: {
docs: {
url: "https://mcp.example.com/mcp",
http_headers: { Authorization: "Bearer native-access-token" },
},
},
});
});
it("omits MCP-native OAuth servers that still need authorization", async () => {
authMocks.resolveMcpOAuthAccessToken.mockRejectedValueOnce(
new Error('MCP server "gbrain" requires OAuth authorization.'),
);
const onServerUnavailable = vi.fn();
const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime(
{
mcp: {
servers: {
gbrain: {
transport: "streamable-http",
url: "https://gbrain.example.com/mcp",
auth: "oauth",
},
localTools: {
transport: "stdio",
command: "local-tools",
},
},
},
} as unknown as OpenClawConfig,
{ onServerUnavailable },
);
expect(patch).toStrictEqual({
mcp_servers: {
localTools: { command: "local-tools" },
},
});
expect(onServerUnavailable).toHaveBeenCalledWith("gbrain", expect.any(Error));
});
it("omits MCP-native OAuth projection from a remote Codex app-server", async () => {
const onServerUnavailable = vi.fn();
await expect(
buildCodexUserMcpServersThreadConfigPatchForRuntime(
{
mcp: {
servers: {
docs: {
transport: "streamable-http",
url: "https://mcp.example.com/mcp",
auth: "oauth",
},
},
},
} as unknown as OpenClawConfig,
{ allowLiteralOAuthProjection: false, onServerUnavailable },
),
).resolves.toBeUndefined();
expect(onServerUnavailable).toHaveBeenCalledWith("docs", expect.any(Error));
expect(authMocks.resolveMcpOAuthAccessToken).not.toHaveBeenCalled();
});
it("preserves tool filters while projecting auth-profile backed MCP bearers at runtime", async () => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({
version: 1,
profiles: {
"ducktape:mcp": {
type: "oauth",
provider: "ducktape",
access: "expired-access",
refresh: "refresh-token-must-not-project",
expires: 1,
},
},
});
authMocks.resolveApiKeyForProfile.mockResolvedValueOnce({
apiKey: "fresh-access-token",
provider: "ducktape",
profileId: "ducktape:mcp",
profileType: "oauth",
credential: {
type: "oauth",
provider: "ducktape",
access: "fresh-access-token",
refresh: "refresh-token-must-not-project",
expires: Date.now() + 60_000,
},
});
const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({
mcp: {
servers: {
ducktape: {
transport: "streamable-http",
url: "https://agents.ducktape.xyz/mcp",
auth: "oauth",
oauth: { authProfileId: "ducktape:mcp" },
headers: {
Authorization: "Bearer stale-access",
},
toolFilter: {
include: ["proof_echo", "proof_search"],
exclude: ["admin_delete"],
},
},
},
},
} as unknown as OpenClawConfig);
expect(patch).toStrictEqual({
mcp_servers: {
ducktape: {
url: "https://agents.ducktape.xyz/mcp",
http_headers: {
Authorization: "Bearer fresh-access-token",
},
enabled_tools: ["proof_echo", "proof_search"],
disabled_tools: ["admin_delete"],
},
},
});
expect(JSON.stringify(patch)).not.toContain("refresh-token-must-not-project");
});
});
+16 -2
View File
@@ -8,10 +8,12 @@ import path from "node:path";
import { applyMergePatch } from "../../config/merge-patch.js";
import type { CliBackendConfig } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { tryReadJson } from "../../infra/json-files.js";
import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-mcp.js";
import type { CliBundleMcpMode } from "../../plugins/types.js";
import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js";
import { resolveMcpBearerBundleConfig } from "../mcp-auth-profile.js";
import { isRecord } from "./bundle-mcp-adapter-shared.js";
import {
findClaudeMcpConfigPath,
@@ -181,6 +183,7 @@ export async function prepareCliBundleMcpConfig(params: {
backend: CliBackendConfig;
workspaceDir: string;
config?: OpenClawConfig;
agentDir?: string;
additionalConfig?: BundleMcpConfig;
/**
* Serve exactly these servers, skipping user/plugin/additional merges.
@@ -238,12 +241,23 @@ export async function prepareCliBundleMcpConfig(params: {
if (params.additionalConfig) {
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
}
const resolvedBearerConfig = await resolveMcpBearerBundleConfig({
config: mergedConfig,
cfg: params.config,
agentDir: params.agentDir,
env: params.env,
omitUnavailableOAuthServers: true,
onServerUnavailable: (serverName, error) =>
params.warn?.(
`bundle MCP skipped unavailable OAuth server ${serverName}: ${formatErrorMessage(error)}`,
),
});
return await prepareModeSpecificBundleMcpConfig({
mode,
backend: params.backend,
mergedConfig,
env: params.env,
mergedConfig: resolvedBearerConfig.config,
env: resolvedBearerConfig.env,
});
}
@@ -1,7 +1,7 @@
/** Tests merging user OpenClaw MCP server config into Claude bundle-MCP overlays. */
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { prepareCliBundleMcpConfig } from "./bundle-mcp.js";
@@ -11,9 +11,21 @@ import {
setupCliBundleMcpTestHarness,
} from "./bundle-mcp.test-support.js";
const authMocks = vi.hoisted(() => ({
resolveMcpOAuthAccessToken: vi.fn(),
}));
vi.mock("../mcp-oauth.js", () => ({
resolveMcpOAuthAccessToken: authMocks.resolveMcpOAuthAccessToken,
}));
setupCliBundleMcpTestHarness();
describe("prepareCliBundleMcpConfig user mcp.servers", () => {
beforeEach(() => {
authMocks.resolveMcpOAuthAccessToken.mockReset();
});
it("merges user-configured mcp.servers from OpenClaw config", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-user-servers-",
@@ -135,6 +147,56 @@ describe("prepareCliBundleMcpConfig user mcp.servers", () => {
await prepared.cleanup?.();
});
it("omits unavailable OAuth servers without blocking the CLI agent", async () => {
authMocks.resolveMcpOAuthAccessToken.mockRejectedValueOnce(
new Error('MCP server "gbrain" requires OAuth authorization.'),
);
const warn = vi.fn();
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-user-servers-oauth-",
);
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config: {
plugins: { enabled: false },
mcp: {
servers: {
gbrain: {
transport: "streamable-http",
url: "https://gbrain.example.com/mcp",
auth: "oauth",
},
localTools: {
transport: "stdio",
command: "local-tools",
},
},
},
},
warn,
});
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
expect(raw.mcpServers).toStrictEqual({
localTools: { type: "stdio", command: "local-tools" },
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("skipped unavailable OAuth server gbrain"),
);
await prepared.cleanup?.();
});
it("user mcp.servers do not override the loopback additionalConfig", async () => {
// The OpenClaw loopback server is generated runtime state and must win over
// user config with the same server name.
+1
View File
@@ -786,6 +786,7 @@ export async function prepareCliRunContext(
backend: backendResolved.config,
workspaceDir,
config: params.config,
agentDir,
...(crestodianMcpConfig ? { exclusiveConfig: crestodianMcpConfig } : {}),
additionalConfig: mcpLoopbackRuntime
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
+46
View File
@@ -66,6 +66,51 @@ function resolveCodexDefaultToolsApprovalMode(
);
}
function normalizeToolFilterList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
}
function assertCodexExactToolFilters(
serverName: string,
fieldName: "include" | "exclude",
patterns: string[],
): void {
const wildcard = patterns.find((pattern) => pattern.includes("*"));
if (!wildcard) {
return;
}
const codexFieldName = fieldName === "include" ? "enabled_tools" : "disabled_tools";
throw new Error(
`Cannot project mcp.servers.${serverName}.toolFilter.${fieldName} pattern "${wildcard}" into Codex ${codexFieldName}: Codex MCP projection only supports exact tool names.`,
);
}
function applyCodexToolFilter(
next: Record<string, unknown>,
name: string,
server: BundleMcpServerConfig,
): void {
if (!isRecord(server.toolFilter)) {
return;
}
const include = normalizeToolFilterList(server.toolFilter.include);
const exclude = normalizeToolFilterList(server.toolFilter.exclude);
assertCodexExactToolFilters(name, "include", include);
assertCodexExactToolFilters(name, "exclude", exclude);
if (include.length > 0) {
next.enabled_tools = include;
}
if (exclude.length > 0) {
next.disabled_tools = exclude;
}
}
/** Normalizes one bundle MCP server into Codex's mcp_servers shape. */
export function normalizeCodexMcpServerConfig(
name: string,
@@ -73,6 +118,7 @@ export function normalizeCodexMcpServerConfig(
): Record<string, unknown> {
const next: Record<string, unknown> = {};
applyCommonServerConfig(next, server);
applyCodexToolFilter(next, name, server);
const defaultToolsApprovalMode = resolveCodexDefaultToolsApprovalMode(server);
if (defaultToolsApprovalMode) {
next.default_tools_approval_mode = defaultToolsApprovalMode;
@@ -1648,6 +1648,7 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: effectiveWorkspace,
agentDir,
cfg: params.config,
})
: undefined;
+290
View File
@@ -0,0 +1,290 @@
/** Tests auth-profile backed MCP bearer projection. */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMcpBearerBundleConfig, withMcpAuthProfileBearer } from "./mcp-auth-profile.js";
const authMocks = vi.hoisted(() => ({
loadAuthProfileStoreForSecretsRuntime: vi.fn(),
resolveApiKeyForProfile: vi.fn(),
resolveMcpOAuthAccessToken: vi.fn(),
}));
vi.mock("./auth-profiles/store.js", () => ({
loadAuthProfileStoreForSecretsRuntime: authMocks.loadAuthProfileStoreForSecretsRuntime,
}));
vi.mock("./auth-profiles/oauth.js", () => ({
resolveApiKeyForProfile: authMocks.resolveApiKeyForProfile,
}));
vi.mock("./mcp-oauth.js", () => ({
resolveMcpOAuthAccessToken: authMocks.resolveMcpOAuthAccessToken,
}));
describe("mcp auth profile bearer projection", () => {
beforeEach(() => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReset();
authMocks.resolveApiKeyForProfile.mockReset();
authMocks.resolveMcpOAuthAccessToken.mockReset();
});
it("projects existing MCP-native OAuth credentials without an auth profile", async () => {
authMocks.resolveMcpOAuthAccessToken.mockResolvedValueOnce("native-access-token");
const resolved = await resolveMcpBearerBundleConfig({
config: {
mcpServers: {
docs: {
url: "https://mcp.example.com/mcp",
type: "http",
auth: "oauth",
oauth: { scope: "docs.read" },
},
},
},
});
expect(resolved.config.mcpServers.docs).toMatchObject({
url: "https://mcp.example.com/mcp",
headers: {
Authorization: expect.stringMatching(/^Bearer \$\{OPENCLAW_MCP_AUTH_[A-F0-9]{12}_TOKEN}$/),
},
});
expect(resolved.config.mcpServers.docs?.auth).toBeUndefined();
expect(resolved.config.mcpServers.docs?.oauth).toBeUndefined();
expect(Object.values(resolved.env ?? {})).toEqual(["native-access-token"]);
expect(authMocks.resolveMcpOAuthAccessToken).toHaveBeenCalledWith(
expect.objectContaining({
serverName: "docs",
serverUrl: "https://mcp.example.com/mcp",
config: { scope: "docs.read" },
fetchFn: expect.any(Function),
}),
);
});
it("omits unavailable OAuth servers when graceful degradation is requested", async () => {
authMocks.resolveMcpOAuthAccessToken.mockRejectedValueOnce(
new Error('MCP server "gbrain" requires OAuth authorization.'),
);
const onServerUnavailable = vi.fn();
const resolved = await resolveMcpBearerBundleConfig({
config: {
mcpServers: {
gbrain: {
url: "https://gbrain.example.com/mcp",
type: "http",
auth: "oauth",
},
localTools: {
command: "local-tools",
},
},
},
omitUnavailableOAuthServers: true,
onServerUnavailable,
});
expect(resolved.config.mcpServers).toStrictEqual({
localTools: { command: "local-tools" },
});
expect(onServerUnavailable).toHaveBeenCalledWith("gbrain", expect.any(Error));
});
it("resolves refreshable OAuth profiles into env-backed CLI bearer headers", async () => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({
version: 1,
profiles: {
"ducktape:mcp": {
type: "oauth",
provider: "ducktape",
access: "expired-access",
refresh: "refresh-token-must-not-project",
expires: 1,
},
},
});
authMocks.resolveApiKeyForProfile.mockResolvedValueOnce({
apiKey: "fresh-access-token",
provider: "ducktape",
profileId: "ducktape:mcp",
profileType: "oauth",
credential: {
type: "oauth",
provider: "ducktape",
access: "fresh-access-token",
refresh: "refresh-token-must-not-project",
expires: Date.now() + 60_000,
},
});
const resolved = await resolveMcpBearerBundleConfig({
config: {
mcpServers: {
ducktape: {
url: "https://agents.ducktape.xyz/mcp",
type: "http",
auth: "oauth",
oauth: { authProfileId: "ducktape:mcp" },
headers: {
Authorization: "Bearer stale-access",
"X-Trace": "keep",
},
},
},
},
});
const server = resolved.config.mcpServers.ducktape;
expect(server.auth).toBeUndefined();
expect(server.oauth).toBeUndefined();
expect(server.headers).toEqual({
Authorization: expect.stringMatching(/^Bearer \$\{OPENCLAW_MCP_AUTH_[A-F0-9]{12}_TOKEN}$/),
"X-Trace": "keep",
});
expect(JSON.stringify(resolved.config)).not.toContain("refresh-token-must-not-project");
expect(JSON.stringify(resolved.env)).not.toContain("refresh-token-must-not-project");
expect(Object.values(resolved.env ?? {})).toEqual(["fresh-access-token"]);
expect(authMocks.resolveApiKeyForProfile).toHaveBeenCalledWith(
expect.objectContaining({
profileId: "ducktape:mcp",
}),
);
expect(authMocks.resolveMcpOAuthAccessToken).not.toHaveBeenCalled();
});
it("rejects static token profiles instead of pretending they are refreshable", async () => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({
version: 1,
profiles: {
"ducktape:static": {
type: "token",
provider: "ducktape",
token: "expired-static-token",
expires: 1,
},
},
});
await expect(
resolveMcpBearerBundleConfig({
config: {
mcpServers: {
ducktape: {
url: "https://agents.ducktape.xyz/mcp",
auth: "oauth",
oauth: { authProfileId: "ducktape:static" },
},
},
},
}),
).rejects.toThrow("profiles are not refreshable");
});
it("projects the raw OAuth access token even when provider formatting returns structured auth", async () => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({
version: 1,
profiles: {
"google:mcp": {
type: "oauth",
provider: "google",
access: "expired-access",
refresh: "refresh-token-must-not-project",
expires: 1,
},
},
});
authMocks.resolveApiKeyForProfile.mockResolvedValueOnce({
apiKey: JSON.stringify({
token: "raw-google-access-token",
projectId: "demo-project",
}),
provider: "google",
profileId: "google:mcp",
profileType: "oauth",
credential: {
type: "oauth",
provider: "google",
access: "raw-google-access-token",
refresh: "refresh-token-must-not-project",
expires: Date.now() + 60_000,
},
});
const resolved = await resolveMcpBearerBundleConfig({
config: {
mcpServers: {
google: {
url: "https://mcp.google.test/mcp",
type: "http",
auth: "oauth",
oauth: { authProfileId: "google:mcp" },
},
},
},
tokenProjection: "literal",
});
expect(resolved.config.mcpServers.google?.headers).toEqual({
Authorization: "Bearer raw-google-access-token",
});
expect(resolved.env).toBeUndefined();
expect(JSON.stringify(resolved.config)).not.toContain("demo-project");
expect(JSON.stringify(resolved.config)).not.toContain('{"token"');
});
it("injects fresh bearer headers only for same-origin embedded MCP requests", async () => {
authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValue({
version: 1,
profiles: {
"ducktape:mcp": {
type: "oauth",
provider: "ducktape",
access: "expired-access",
refresh: "refresh-token-must-not-project",
expires: 1,
},
},
});
authMocks.resolveApiKeyForProfile.mockResolvedValue({
apiKey: "fresh-access-token",
provider: "ducktape",
profileId: "ducktape:mcp",
profileType: "oauth",
credential: {
type: "oauth",
provider: "ducktape",
access: "fresh-access-token",
refresh: "refresh-token-must-not-project",
expires: Date.now() + 60_000,
},
});
const calls: Array<[RequestInfo | URL, RequestInit | undefined]> = [];
const wrapped = withMcpAuthProfileBearer({
fetchFn: async (url, init) => {
calls.push([url, init]);
return new Response("ok");
},
serverName: "ducktape",
resourceUrl: "https://agents.ducktape.xyz/mcp",
authProfileId: "ducktape:mcp",
headers: {
Authorization: "Bearer stale-access",
"X-Trace": "keep",
},
});
await wrapped("https://agents.ducktape.xyz/mcp", {
headers: { Accept: "application/json", Authorization: "Bearer sdk-stale" },
});
await wrapped("https://redirect.example/mcp", {
headers: { Authorization: "Bearer sdk-stale" },
});
const sameOriginHeaders = new Headers(calls[0]?.[1]?.headers);
expect(sameOriginHeaders.get("authorization")).toBe("Bearer fresh-access-token");
expect(sameOriginHeaders.get("x-trace")).toBe("keep");
expect(sameOriginHeaders.get("accept")).toBe("application/json");
expect(calls[1]?.[1]?.headers).toEqual({ Authorization: "Bearer sdk-stale" });
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* Auth-profile backed bearer injection for remote MCP servers.
*/
import crypto from "node:crypto";
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { BundleMcpConfig, BundleMcpServerConfig } from "../plugins/bundle-mcp.js";
import { resolveApiKeyForProfile } from "./auth-profiles/oauth.js";
import { loadAuthProfileStoreForSecretsRuntime } from "./auth-profiles/store.js";
import {
buildMcpHttpFetch,
withoutMcpAuthorizationHeader,
withSameOriginMcpHttpHeaders,
} from "./mcp-http-fetch.js";
import { resolveMcpOAuthAccessToken, type McpOAuthConfig } from "./mcp-oauth.js";
import { resolveMcpTransportConfig } from "./mcp-transport-config.js";
type McpAuthProfileOptions = {
cfg?: OpenClawConfig;
agentDir?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function withoutAuthorizationHeader(
headers: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const entries = Object.entries(headers).filter(([key]) => key.toLowerCase() !== "authorization");
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function normalizeStringHeaders(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries = Object.entries(value).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
/** Returns the refresh-capable auth profile selected for one MCP server. */
export function resolveMcpAuthProfileId(rawServer: unknown): string | undefined {
if (!isRecord(rawServer) || rawServer.auth !== "oauth" || !isRecord(rawServer.oauth)) {
return undefined;
}
const authProfileId = rawServer.oauth.authProfileId;
return typeof authProfileId === "string" && authProfileId.trim().length > 0
? authProfileId.trim()
: undefined;
}
/** Returns whether a server needs an OpenClaw-managed bearer projected externally. */
export function requiresMcpBearerProjection(rawServer: unknown): boolean {
if (!isRecord(rawServer) || rawServer.auth !== "oauth") {
return false;
}
return Boolean(resolveMcpAuthProfileId(rawServer) || typeof rawServer.url === "string");
}
async function resolveMcpAuthProfileBearerToken(
params: {
serverName: string;
profileId: string;
} & McpAuthProfileOptions,
): Promise<string> {
const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir, {
config: params.cfg,
externalCliProfileIds: [params.profileId],
});
const credential = store.profiles[params.profileId];
if (!credential) {
throw new Error(
`MCP server "${params.serverName}" references auth profile "${params.profileId}", but that profile was not found.`,
);
}
if (credential.type !== "oauth") {
throw new Error(
`MCP server "${params.serverName}" references auth profile "${params.profileId}", but ${credential.type} profiles are not refreshable. Use a refresh-capable OAuth profile.`,
);
}
const resolved = await resolveApiKeyForProfile({
cfg: params.cfg,
store,
profileId: params.profileId,
agentDir: params.agentDir,
});
if (!resolved || resolved.profileType !== "oauth" || !resolved.apiKey) {
throw new Error(
`MCP server "${params.serverName}" could not resolve refreshable OAuth auth profile "${params.profileId}". Re-authenticate the profile and retry.`,
);
}
if (
!resolved.credential ||
resolved.credential.type !== "oauth" ||
typeof resolved.credential.access !== "string" ||
resolved.credential.access.trim().length === 0
) {
throw new Error(
`MCP server "${params.serverName}" resolved OAuth auth profile "${params.profileId}", but no raw access token was available for bearer projection.`,
);
}
return resolved.credential.access;
}
async function resolveMcpBearerToken(params: {
serverName: string;
server: BundleMcpServerConfig;
cfg?: OpenClawConfig;
agentDir?: string;
}): Promise<string | undefined> {
const authProfileId = resolveMcpAuthProfileId(params.server);
if (authProfileId) {
return await resolveMcpAuthProfileBearerToken({
serverName: params.serverName,
profileId: authProfileId,
cfg: params.cfg,
agentDir: params.agentDir,
});
}
if (params.server.auth !== "oauth") {
return undefined;
}
const resolved = resolveMcpTransportConfig(params.serverName, params.server);
if (!resolved || resolved.kind !== "http") {
return undefined;
}
const fetchFn = withSameOriginMcpHttpHeaders({
fetchFn: buildMcpHttpFetch({
sslVerify: resolved.sslVerify,
clientCert: resolved.clientCert,
clientKey: resolved.clientKey,
resourceUrl: resolved.url,
}),
headers: withoutMcpAuthorizationHeader(resolved.headers),
resourceUrl: resolved.url,
});
return await resolveMcpOAuthAccessToken({
serverName: params.serverName,
serverUrl: resolved.url,
config: resolved.oauth as McpOAuthConfig | undefined,
fetchFn,
});
}
/** Wraps HTTP MCP fetch with same-origin, refreshed bearer injection. */
export function withMcpAuthProfileBearer(
params: {
fetchFn: FetchLike;
serverName: string;
resourceUrl: string;
headers?: Record<string, string>;
authProfileId: string;
} & McpAuthProfileOptions,
): FetchLike {
const resourceOrigin = new URL(params.resourceUrl).origin;
const configuredHeaders = withoutAuthorizationHeader(params.headers);
return async (url, init) => {
if (new URL(url).origin !== resourceOrigin) {
return params.fetchFn(url, init);
}
const headers = new Headers(configuredHeaders);
for (const [key, value] of new Headers(init?.headers)) {
if (key.toLowerCase() !== "authorization") {
headers.set(key, value);
}
}
const token = await resolveMcpAuthProfileBearerToken({
serverName: params.serverName,
profileId: params.authProfileId,
cfg: params.cfg,
agentDir: params.agentDir,
});
headers.set("authorization", `Bearer ${token}`);
return params.fetchFn(url, { ...(init as RequestInit), headers });
};
}
function buildTokenEnvVarName(serverName: string): string {
const hash = crypto.createHash("sha256").update(serverName).digest("hex").slice(0, 12);
return `OPENCLAW_MCP_AUTH_${hash.toUpperCase()}_TOKEN`;
}
function stripOpenClawOnlyOAuthConfig(server: BundleMcpServerConfig): BundleMcpServerConfig {
const next = { ...server };
delete next.auth;
delete next.oauth;
return next;
}
/** Resolves OAuth-backed MCP servers into bearer headers for external runtimes. */
export async function resolveMcpBearerBundleConfig(
params: {
config: BundleMcpConfig;
env?: Record<string, string>;
tokenProjection?: "env" | "literal";
omitUnavailableOAuthServers?: boolean;
onServerUnavailable?: (serverName: string, error: unknown) => void;
} & McpAuthProfileOptions,
): Promise<{ config: BundleMcpConfig; env?: Record<string, string> }> {
let nextServers: Record<string, BundleMcpServerConfig> | undefined;
let nextEnv = params.env;
const tokenProjection = params.tokenProjection ?? "env";
for (const [serverName, server] of Object.entries(params.config.mcpServers)) {
let token: string | undefined;
try {
token = await resolveMcpBearerToken({
serverName,
server,
cfg: params.cfg,
agentDir: params.agentDir,
});
} catch (error) {
if (!params.omitUnavailableOAuthServers || !requiresMcpBearerProjection(server)) {
throw error;
}
nextServers ??= { ...params.config.mcpServers };
delete nextServers[serverName];
params.onServerUnavailable?.(serverName, error);
continue;
}
if (!token) {
continue;
}
let authorization: string;
if (tokenProjection === "literal") {
authorization = `Bearer ${token}`;
} else {
const envVar = buildTokenEnvVarName(serverName);
if (!nextEnv || nextEnv === params.env) {
nextEnv = { ...params.env };
}
nextEnv[envVar] = token;
authorization = `Bearer \${${envVar}}`;
}
const headers = withoutAuthorizationHeader(normalizeStringHeaders(server.headers));
nextServers ??= { ...params.config.mcpServers };
nextServers[serverName] = stripOpenClawOnlyOAuthConfig({
...server,
headers: {
...headers,
Authorization: authorization,
},
});
}
return {
config: nextServers ? { mcpServers: nextServers } : params.config,
env: nextEnv,
};
}
+222 -2
View File
@@ -1,11 +1,12 @@
// Covers MCP OAuth token persistence, isolation, and noninteractive behavior.
import fs from "node:fs/promises";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import {
clearMcpOAuthCredentials,
createMcpOAuthClientProvider,
resolveMcpOAuthAccessToken,
runMcpOAuthLogin,
} from "./mcp-oauth.js";
@@ -16,6 +17,225 @@ vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
}));
describe("MCP OAuth provider", () => {
beforeEach(() => {
authMock.mockReset();
});
it("returns a fresh stored access token without refreshing it", async () => {
await withTempHome(
async () => {
const provider = createMcpOAuthClientProvider({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
});
await provider.saveTokens({
access_token: "fresh-access",
refresh_token: "refresh-token-must-not-project",
token_type: "Bearer",
expires_in: 3600,
});
await expect(
resolveMcpOAuthAccessToken({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
}),
).resolves.toBe("fresh-access");
expect(authMock).not.toHaveBeenCalled();
},
{
prefix: "openclaw-mcp-oauth-fresh-token-",
skipSessionCleanup: true,
env: {
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_STATE_DIR: undefined,
},
},
);
});
it("refreshes an expired stored access token before projecting it", async () => {
await withTempHome(
async () => {
const provider = createMcpOAuthClientProvider({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
});
await provider.saveTokens({
access_token: "expired-access",
refresh_token: "refresh-token-must-not-project",
token_type: "Bearer",
expires_in: -1,
});
authMock.mockImplementationOnce(async (refreshProvider) => {
await refreshProvider.saveTokens({
access_token: "refreshed-access",
refresh_token: "rotated-refresh-token-must-not-project",
token_type: "Bearer",
expires_in: 3600,
});
return "AUTHORIZED";
});
await expect(
resolveMcpOAuthAccessToken({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
config: { scope: "docs.read" },
}),
).resolves.toBe("refreshed-access");
expect(authMock).toHaveBeenCalledOnce();
expect(authMock.mock.calls[0]?.[1]).toMatchObject({
serverUrl: "https://mcp.example.com/mcp",
scope: "docs.read",
});
},
{
prefix: "openclaw-mcp-oauth-expired-token-",
skipSessionCleanup: true,
env: {
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_STATE_DIR: undefined,
},
},
);
});
it("serializes concurrent refreshes for the same OAuth credential store", async () => {
await withTempHome(
async () => {
const provider = createMcpOAuthClientProvider({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
});
await provider.saveTokens({
access_token: "expired-access",
refresh_token: "single-use-refresh-token",
token_type: "Bearer",
expires_in: -1,
});
let signalRefreshStarted: (() => void) | undefined;
const refreshStarted = new Promise<void>((resolve) => {
signalRefreshStarted = resolve;
});
let releaseRefresh: (() => void) | undefined;
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
authMock.mockImplementationOnce(async (refreshProvider) => {
signalRefreshStarted?.();
await refreshGate;
await refreshProvider.saveTokens({
access_token: "shared-refreshed-access",
refresh_token: "rotated-refresh-token",
token_type: "Bearer",
expires_in: 3600,
});
return "AUTHORIZED";
});
const first = resolveMcpOAuthAccessToken({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
});
await refreshStarted;
const second = resolveMcpOAuthAccessToken({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
});
releaseRefresh?.();
await expect(Promise.all([first, second])).resolves.toEqual([
"shared-refreshed-access",
"shared-refreshed-access",
]);
expect(authMock).toHaveBeenCalledOnce();
},
{
prefix: "openclaw-mcp-oauth-concurrent-refresh-",
skipSessionCleanup: true,
env: {
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_STATE_DIR: undefined,
},
},
);
});
it("refreshes pre-upgrade token stores that have no expiry timestamp", async () => {
await withTempHome(
async (home) => {
const provider = createMcpOAuthClientProvider({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
});
await provider.saveTokens({
access_token: "legacy-access",
refresh_token: "legacy-refresh-token-must-not-project",
token_type: "Bearer",
expires_in: 3600,
});
const tokenDir = `${home}/.openclaw/mcp-oauth`;
const [entry] = await fs.readdir(tokenDir);
const tokenPath = `${tokenDir}/${entry}`;
const legacyStore = JSON.parse(await fs.readFile(tokenPath, "utf-8")) as Record<
string,
unknown
>;
delete legacyStore.tokenExpiresAt;
await fs.writeFile(tokenPath, JSON.stringify(legacyStore, null, 2), "utf-8");
authMock.mockImplementationOnce(async (refreshProvider) => {
await refreshProvider.saveTokens({
access_token: "refreshed-legacy-access",
refresh_token: "rotated-refresh-token-must-not-project",
token_type: "Bearer",
expires_in: 3600,
});
return "AUTHORIZED";
});
await expect(
resolveMcpOAuthAccessToken({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
}),
).resolves.toBe("refreshed-legacy-access");
expect(authMock).toHaveBeenCalledOnce();
},
{
prefix: "openclaw-mcp-oauth-legacy-token-",
skipSessionCleanup: true,
env: {
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_STATE_DIR: undefined,
},
},
);
});
it("requires explicit login when no native OAuth credentials exist", async () => {
await withTempHome(
async () => {
await expect(
resolveMcpOAuthAccessToken({
serverName: "Remote Docs",
serverUrl: "https://mcp.example.com/mcp",
}),
).rejects.toThrow("Run openclaw mcp login Remote Docs.");
expect(authMock).not.toHaveBeenCalled();
},
{
prefix: "openclaw-mcp-oauth-missing-token-",
skipSessionCleanup: true,
env: {
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_STATE_DIR: undefined,
},
},
);
});
it("stores token state under the OpenClaw state directory with restricted permissions", async () => {
await withTempHome(
async (home) => {
@@ -136,7 +356,7 @@ describe("MCP OAuth provider", () => {
}),
).rejects.toThrow("localhost redirect also rejected");
await expect(fs.readdir(`${home}/.openclaw/mcp-oauth`)).rejects.toThrow();
await expect(fs.readdir(`${home}/.openclaw/mcp-oauth`)).resolves.toEqual([]);
},
{
prefix: "openclaw-mcp-oauth-localhost-failure-",
+114 -15
View File
@@ -19,11 +19,15 @@ import type {
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveStateDir } from "../config/paths.js";
import { type FileLockOptions, withFileLock } from "../infra/file-lock.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { runQueuedStoreWrite, type StoreWriterQueue } from "../shared/store-writer-queue.js";
import { sanitizeServerName } from "./agent-bundle-mcp-names.js";
type McpOAuthStore = {
clientInformation?: OAuthClientInformationMixed;
tokens?: OAuthTokens;
tokenExpiresAt?: number;
codeVerifier?: string;
discoveryState?: OAuthDiscoveryState;
lastAuthorizationUrl?: string;
@@ -31,7 +35,7 @@ type McpOAuthStore = {
state?: string;
};
type McpOAuthConfig = {
export type McpOAuthConfig = {
scope?: unknown;
redirectUrl?: unknown;
clientMetadataUrl?: unknown;
@@ -48,6 +52,23 @@ export type McpOAuthCredentialsStatus = {
const LEGACY_DEFAULT_REDIRECT_URL = "http://127.0.0.1:8989/oauth/callback";
const LOCALHOST_REDIRECT_URL = "http://localhost:8989/oauth/callback";
const TOKEN_EXPIRY_SKEW_MS = 30_000;
const MCP_OAUTH_LOCK_OPTIONS: FileLockOptions = {
retries: { retries: 20, factor: 1.3, minTimeout: 25, maxTimeout: 500, randomize: true },
stale: 60_000,
staleRecovery: "fail-closed",
};
const MCP_OAUTH_STORE_QUEUES = resolveGlobalSingleton(
Symbol.for("openclaw.mcp-oauth.store-writer-queues"),
() => new Map<string, StoreWriterQueue>(),
);
function resolveTokenExpiresAt(tokens: OAuthTokens): number | undefined {
const expiresIn = tokens.expires_in;
return typeof expiresIn === "number" && Number.isFinite(expiresIn)
? Date.now() + expiresIn * 1000
: undefined;
}
function isMcpOAuthRedirectRegistrationError(error: unknown): boolean {
return /invalid_client_metadata|redirect_uri/i.test(String(error));
@@ -84,6 +105,18 @@ async function writeStore(filePath: string, store: McpOAuthStore): Promise<void>
await fsPromises.chmod(filePath, 0o600).catch(() => {});
}
async function withMcpOAuthStoreLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
return await runQueuedStoreWrite({
queues: MCP_OAUTH_STORE_QUEUES,
storePath: filePath,
label: "withMcpOAuthStoreLock",
fn: async () => {
await fsPromises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
return await withFileLock(filePath, MCP_OAUTH_LOCK_OPTIONS, fn);
},
});
}
function resolveOAuthRedirectUrl(config: McpOAuthConfig, store: McpOAuthStore = {}): string {
return (
normalizeOptionalString(config.redirectUrl) ??
@@ -155,7 +188,14 @@ export function createMcpOAuthClientProvider(params: {
},
async saveTokens(tokens) {
const store = await readStore(filePath);
await writeStore(filePath, { ...store, tokens });
const tokenExpiresAt = resolveTokenExpiresAt(tokens);
const nextStore = { ...store, tokens };
if (tokenExpiresAt === undefined) {
delete nextStore.tokenExpiresAt;
} else {
nextStore.tokenExpiresAt = tokenExpiresAt;
}
await writeStore(filePath, nextStore);
},
async redirectToAuthorization(authorizationUrl) {
assertAuthorizationRedirectAllowed();
@@ -202,6 +242,62 @@ export function createMcpOAuthClientProvider(params: {
};
}
/** Returns a current MCP-native OAuth access token for external runtime projection. */
export async function resolveMcpOAuthAccessToken(params: {
serverName: string;
serverUrl: string;
config?: McpOAuthConfig;
fetchFn?: FetchLike;
}): Promise<string> {
const filePath = oauthStorePath(params.serverName, params.serverUrl);
return await withMcpOAuthStoreLock(filePath, async () => {
return await resolveMcpOAuthAccessTokenLocked(params, filePath);
});
}
async function resolveMcpOAuthAccessTokenLocked(
params: {
serverName: string;
serverUrl: string;
config?: McpOAuthConfig;
fetchFn?: FetchLike;
},
filePath: string,
): Promise<string> {
const store = await readStore(filePath);
const tokens = store.tokens;
if (!tokens?.access_token) {
throw new Error(
`MCP server "${params.serverName}" requires OAuth authorization. Run openclaw mcp login ${params.serverName}.`,
);
}
const tokenIsFresh =
store.tokenExpiresAt !== undefined && store.tokenExpiresAt > Date.now() + TOKEN_EXPIRY_SKEW_MS;
if (tokenIsFresh || (store.tokenExpiresAt === undefined && !tokens.refresh_token)) {
return tokens.access_token;
}
if (!tokens.refresh_token) {
throw new Error(
`MCP server "${params.serverName}" has expired OAuth credentials. Run openclaw mcp login ${params.serverName}.`,
);
}
const provider = createMcpOAuthClientProvider(params);
const result = await auth(provider, {
serverUrl: params.serverUrl,
scope: normalizeOptionalString(params.config?.scope),
fetchFn: params.fetchFn,
});
const refreshedTokens = await provider.tokens();
if (result !== "AUTHORIZED" || !refreshedTokens?.access_token) {
throw new Error(
`MCP server "${params.serverName}" could not refresh OAuth credentials. Run openclaw mcp login ${params.serverName}.`,
);
}
return refreshedTokens.access_token;
}
/** Deletes stored OAuth credentials for one MCP server. */
export async function clearMcpOAuthCredentials(params: {
serverName: string;
@@ -233,19 +329,22 @@ async function runMcpOAuthLoginAttempt(params: {
fetchFn?: FetchLike;
onAuthorizationUrl?: (url: URL) => void | Promise<void>;
}): Promise<"authorized" | "redirect"> {
const result = await auth(
createMcpOAuthClientProvider({
...params,
allowAuthorizationRedirect: true,
}),
{
serverUrl: params.serverUrl,
authorizationCode: normalizeOptionalString(params.authorizationCode),
scope: normalizeOptionalString(params.config?.scope),
fetchFn: params.fetchFn,
},
);
return result === "AUTHORIZED" ? "authorized" : "redirect";
const filePath = oauthStorePath(params.serverName, params.serverUrl);
return await withMcpOAuthStoreLock(filePath, async () => {
const result = await auth(
createMcpOAuthClientProvider({
...params,
allowAuthorizationRedirect: true,
}),
{
serverUrl: params.serverUrl,
authorizationCode: normalizeOptionalString(params.authorizationCode),
scope: normalizeOptionalString(params.config?.scope),
fetchFn: params.fetchFn,
},
);
return result === "AUTHORIZED" ? "authorized" : "redirect";
});
}
/** Runs the MCP OAuth login flow, returning whether it authorized or needs redirect. */
+19 -4
View File
@@ -11,7 +11,9 @@ import {
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logDebug } from "../logger.js";
import { resolveMcpAuthProfileId, withMcpAuthProfileBearer } from "./mcp-auth-profile.js";
import {
buildMcpHttpFetch,
withoutMcpAuthorizationHeader,
@@ -89,6 +91,7 @@ function buildSseEventSourceFetch(
export function resolveMcpTransport(
serverName: string,
rawServer: unknown,
options?: { cfg?: OpenClawConfig; agentDir?: string },
): ResolvedMcpTransport | null {
const resolved = resolveMcpTransportConfig(serverName, rawServer);
if (!resolved) {
@@ -112,8 +115,9 @@ export function resolveMcpTransport(
detachStderr: attachStderrLogging(serverName, transport),
};
}
const authProfileId = resolveMcpAuthProfileId(rawServer);
const authProvider =
resolved.auth === "oauth"
resolved.auth === "oauth" && !authProfileId
? createMcpOAuthClientProvider({
serverName,
serverUrl: resolved.url,
@@ -127,9 +131,20 @@ export function resolveMcpTransport(
resourceUrl: resolved.url,
});
const headers =
resolved.auth === "oauth" ? withoutMcpAuthorizationHeader(resolved.headers) : resolved.headers;
const httpFetch =
resolved.auth === "oauth"
resolved.auth === "oauth" || authProfileId
? withoutMcpAuthorizationHeader(resolved.headers)
: resolved.headers;
const httpFetch = authProfileId
? withMcpAuthProfileBearer({
fetchFn: baseFetch,
serverName,
resourceUrl: resolved.url,
headers,
authProfileId,
cfg: options?.cfg,
agentDir: options?.agentDir,
})
: resolved.auth === "oauth"
? withSameOriginMcpHttpHeaders({
fetchFn: baseFetch,
headers,
+2
View File
@@ -1637,6 +1637,8 @@ export const FIELD_HELP: Record<string, string> = {
"Exact MCP tool names or simple '*' globs to expose from this server. When omitted, all server tools remain eligible unless excluded.",
"mcp.servers.*.toolFilter.exclude":
"Exact MCP tool names or simple '*' globs to hide from this server.",
"mcp.servers.*.oauth.authProfileId":
"Refresh-capable auth profile id used to inject the current bearer token into this remote MCP server. When set, OpenClaw resolves and refreshes the profile at runtime and does not project refresh material downstream.",
"mcp.servers.*.codex.agents":
"Optional non-empty OpenClaw agent ids that should receive this MCP server in Codex app-server thread config. Empty, blank, or invalid lists fail closed; when omitted, the server is projected for all Codex app-server agents.",
"mcp.servers.*.codex.defaultToolsApprovalMode":
+1
View File
@@ -777,6 +777,7 @@ export const FIELD_LABELS: Record<string, string> = {
"mcp.servers.*.enabled": "MCP Server Enabled",
"mcp.servers.*.auth": "MCP Server Auth",
"mcp.servers.*.oauth": "MCP OAuth",
"mcp.servers.*.oauth.authProfileId": "MCP OAuth Auth Profile",
"mcp.servers.*.oauth.scope": "MCP OAuth Scope",
"mcp.servers.*.oauth.redirectUrl": "MCP OAuth Redirect URL",
"mcp.servers.*.oauth.clientMetadataUrl": "MCP OAuth Client Metadata URL",
+35
View File
@@ -267,6 +267,41 @@ describe("config schema", () => {
}
});
it("accepts MCP OAuth auth profile bindings for refreshable bearer projection", () => {
expect(() =>
OpenClawSchema.parse({
mcp: {
servers: {
ducktape: {
url: "https://agents.ducktape.xyz/mcp",
transport: "streamable-http",
auth: "oauth",
oauth: {
authProfileId: "ducktape:mcp",
},
},
},
},
}),
).not.toThrow();
expect(() =>
OpenClawSchema.parse({
mcp: {
servers: {
ducktape: {
url: "https://agents.ducktape.xyz/mcp",
transport: "streamable-http",
auth: "oauth",
oauth: {
authProfileId: " ",
},
},
},
},
}),
).toThrow();
});
it("accepts stdio transport for command-bearing MCP servers", () => {
const result = OpenClawSchema.safeParse({
mcp: {
+2
View File
@@ -54,6 +54,8 @@ export type McpServerConfig = {
auth?: "oauth";
/** Optional OAuth client metadata overrides for HTTP MCP servers. */
oauth?: {
/** Refresh-capable auth profile used to inject the current bearer token. */
authProfileId?: string;
scope?: string;
redirectUrl?: string;
clientMetadataUrl?: string;
+1
View File
@@ -417,6 +417,7 @@ const McpServerSchema = z
auth: z.literal("oauth").optional(),
oauth: z
.object({
authProfileId: z.string().trim().min(1).optional(),
scope: z.string().trim().min(1).optional(),
redirectUrl: HttpUrlSchema.optional(),
clientMetadataUrl: McpOAuthClientMetadataUrlSchema.optional(),
+4 -1
View File
@@ -3,4 +3,7 @@
// can attach the same user `mcp.servers` entries to its thread config without
// deep-importing core helpers.
export { buildCodexUserMcpServersThreadConfigPatch } from "../agents/cli-runner/bundle-mcp-codex.js";
export {
buildCodexUserMcpServersThreadConfigPatch,
buildCodexUserMcpServersThreadConfigPatchForRuntime,
} from "../agents/cli-runner/bundle-mcp-codex.js";