Merge PR #8983: fix(mcp): refresh broker-backed MCP OAuth credentials (@roboomp)

This commit is contained in:
can1357
2026-08-19 11:52:54 +02:00
6 changed files with 270 additions and 21 deletions
+1
View File
@@ -15,6 +15,7 @@
- Fixed the `/btw` panel re-committing its frame to native scrollback on every update while the primary turn is still streaming: a live region that pins itself (an anchored HUD/panel such as `/btw`) no longer leaks its scrolled-off rows just because an unpinned transcript seam sits above it in the same frame ([#8793](https://github.com/can1357/oh-my-pi/issues/8793)).
- Fixed a submitted `/skill:<name>` command staying invisible in the transcript until its awaited preflight (memory recall, `before_agent_start` hooks, auto-thinking classification, pre-prompt compaction) finished, so a slow step such as a Hindsight auto-recall timeout made the command look unaccepted. Idle skill submissions now paint an optimistic row immediately — like a normal prompt — and reconcile it in place when the canonical `message_start` lands ([#8895](https://github.com/can1357/oh-my-pi/issues/8895)).
- Fixed broker-backed MCP OAuth credentials never refreshing, so remote OAuth MCP servers dropped out of `/mcp` once their access token expired under `omp auth-broker serve`. The client threw on the broker-redacted refresh sentinel instead of asking the broker to refresh, and the broker had no `mcp_oauth:*` refresh path (`POST /v1/credential/:id/refresh` answered `Unknown OAuth provider`). The client now routes redacted MCP refreshes through the broker, and the broker refreshes MCP credentials with a generic `refresh_token` grant from the credential's embedded token endpoint and client id — so the background refresher also keeps MCP tokens live ([#8933](https://github.com/can1357/oh-my-pi/issues/8933)).
- Fixed Claude Code marketplace plugins ignoring the `enabledPlugins` switch in `~/.claude/settings.json` and `.claude/settings(.local).json`: a plugin turned off for a project no longer loads there, and a local-scope install enabled for a project loads even when its recorded `projectPath` is a different directory
- Fixed revived subagents (warm lifecycle reviver and cold persisted reviver) rebuilding the session without initializing the extension runtime, leaving every runtime action throwing `ExtensionRuntimeNotInitializedError`. An extension with a `tool_call` handler that touched a runtime action (e.g. `appendEntry`) then tripped the fail-closed gate in `emitToolCall` and blocked every tool — including the hidden `yield` — so the revived agent could neither finish nor exit and looped until killed. Both revivers now call the shared `initializeExtensions` helper, restoring runtime actions, `onError`, and the `session_start` event ([#8824](https://github.com/can1357/oh-my-pi/issues/8824)).
- Fixed `omp commit` split-commit crashing with a misleading `No diff found for <path>` when a staged binary (or any payload) pushed `git diff --cached --binary` past the 8 MiB subprocess output cap. The capture is truncated silently, so files sorting after the binary vanished from the parsed diff; the split flow now requests a complete diff and fails fast naming the real cause instead ([#8897](https://github.com/can1357/oh-my-pi/issues/8897)).
@@ -33,10 +33,14 @@ import {
SqliteAuthCredentialStore,
} from "@oh-my-pi/pi-ai";
import { AuthBrokerClient, DEFAULT_AUTH_BROKER_BIND, startAuthBroker } from "@oh-my-pi/pi-ai/auth-broker";
import { refreshOAuthToken } from "@oh-my-pi/pi-ai/oauth";
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
import chalk from "@oh-my-pi/pi-utils/chalk";
import { setTransports as setLoggerTransports } from "@oh-my-pi/pi-utils/logger";
import { $ } from "bun";
import { refreshManagedMcpOAuthCredential } from "../mcp/oauth-credentials";
import { isManagedMCPOAuthCredentialId, mcpOAuthServerUrlFromCredentialId } from "../mcp/oauth-flow";
import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import" | "migrate" | "list";
@@ -119,6 +123,34 @@ async function ensureToken(): Promise<string> {
return token;
}
/**
* OAuth refresh handler for `omp auth-broker serve`'s {@link AuthStorage}.
*
* The vault holds provider OAuth rows AND OMP-managed `mcp_oauth:*` rows.
* Provider rows refresh through the per-provider registry. MCP rows are
* self-describing — the embedded token endpoint and client credentials are the
* only refresh material — so they refresh with a generic `refresh_token` grant.
* The serve process never loads the MCP manager, so this is the only place that
* teaches the broker to refresh MCP tokens; without it
* `POST /v1/credential/:id/refresh` fails with "Unknown OAuth provider" and the
* background refresher lets MCP access tokens expire (issue #8933).
*/
export function refreshBrokerOAuthCredential(
provider: string,
credential: OAuthCredential,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
if (isManagedMCPOAuthCredentialId(provider)) {
return refreshManagedMcpOAuthCredential(credential, {
serverUrl: mcpOAuthServerUrlFromCredentialId(provider),
signal,
});
}
// Non-MCP rows: same per-provider path AuthStorage would take by default
// (the serve process registers no custom OAuth providers).
return refreshOAuthToken(provider as OAuthProvider, credential);
}
async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
// The broker is a long-running headless service: route structured logs to
// stdout so a process supervisor (pm2, journald, k8s) captures them, and
@@ -129,7 +161,10 @@ async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
const token = await ensureToken();
const dbPath = getAgentDbPath();
const store = await SqliteAuthCredentialStore.open(dbPath);
const storage = new AuthStorage(store);
const storage = new AuthStorage(store, {
refreshOAuthCredential: (provider, _credentialId, credential, signal) =>
refreshBrokerOAuthCredential(provider, credential, signal),
});
await storage.reload();
const handle = startAuthBroker({
storage,
+41 -20
View File
@@ -7,6 +7,7 @@
import * as path from "node:path";
import * as url from "node:url";
import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
import { logger } from "@oh-my-pi/pi-utils";
import type { SourceMeta } from "../capability/types";
import { resolveConfigValue } from "../config/resolve-config-value";
@@ -30,9 +31,10 @@ import { type LoadMCPConfigsResult, loadAllMCPConfigs, validateServerConfig } fr
import {
lookupMcpOAuthCredential,
type MCPOAuthCredentialLookup,
refreshManagedMcpOAuthCredential,
selectMcpOAuthRefreshMaterial,
} from "./oauth-credentials";
import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
import type { MCPStoredOAuthCredential } from "./oauth-flow";
import type { McpConnectionStatusEvent } from "./startup-events";
import type { MCPToolDetails } from "./tool-bridge";
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
@@ -1404,6 +1406,36 @@ export class MCPManager {
};
}
/**
* Refresh a broker-redacted MCP OAuth credential through the auth-broker.
*
* When running in broker mode the client only ever holds the redacted
* refresh sentinel; the real refresh token lives on the broker. Delegating
* to {@link AuthStorage.forceRefreshCredentialById} makes the broker run the
* `refresh_token` grant and return a fresh access token, which the client
* uses while keeping {@link REMOTE_REFRESH_SENTINEL} in the refresh slot.
*/
async #refreshBrokeredMcpCredential(credentialId: string, signal?: AbortSignal): Promise<OAuthCredentials> {
const storage = this.#authStorage;
if (!storage) throw new Error("MCP OAuth broker refresh requires an auth storage");
const row = storage.listStoredCredentials(credentialId).find(entry => entry.credential.type === "oauth");
if (!row) throw new Error(`No broker credential row for ${credentialId}`);
const entry = await storage.forceRefreshCredentialById(row.id, signal);
if (entry.credential.type !== "oauth") {
throw new Error(`Broker returned non-OAuth credential for ${credentialId}`);
}
const refreshed = entry.credential;
return {
access: refreshed.access,
refresh: REMOTE_REFRESH_SENTINEL,
expires: refreshed.expires,
accountId: refreshed.accountId,
email: refreshed.email,
projectId: refreshed.projectId,
enterpriseUrl: refreshed.enterpriseUrl,
};
}
/**
* Resolve OAuth credentials and shell commands in config.
* `oauth: false` skips credential injection (reauth's unauthenticated probe);
@@ -1435,24 +1467,15 @@ export class MCPManager {
return Boolean(current.refresh && material?.tokenUrl);
},
refresh: (current, signal) => {
// Broker-backed credentials redact the refresh token
// (REMOTE_REFRESH_SENTINEL); the broker holds the real one, so
// route the refresh through it instead of failing locally.
if (current.refresh === REMOTE_REFRESH_SENTINEL) {
throw new Error("MCP OAuth refresh token is broker-redacted; local refresh is unavailable");
return this.#refreshBrokeredMcpCredential(credentialId, signal);
}
const material = selectMcpOAuthRefreshMaterial(current, auth);
const tokenUrl = material?.tokenUrl;
if (!current.refresh || !tokenUrl) {
throw new Error("MCP OAuth credential is missing refresh material");
}
const clientId = material?.clientId;
const clientSecret = material?.clientSecret;
const authorizationUrl =
material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
const resourceIsFallback =
!material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
return refreshMCPOAuthToken(tokenUrl, current.refresh, clientId, clientSecret, resource, {
authorizationUrl,
stripSameOriginResource: resourceIsFallback,
return refreshManagedMcpOAuthCredential(current, {
serverUrl: config.type === "http" || config.type === "sse" ? config.url : undefined,
auth,
signal,
});
},
@@ -1480,10 +1503,8 @@ export class MCPManager {
isDefinitiveOAuthFailure(error instanceof Error ? error.message : String(error)),
disabledCause: error =>
`oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`,
keepCredentialOnRefreshFailure: error =>
!(error instanceof Error && error.message.includes("broker-redacted")),
keepCredentialOnRefreshFailure: true,
onRefreshFailure: refreshError => {
if (refreshError instanceof Error && refreshError.message.includes("broker-redacted")) return;
logger.warn("MCP OAuth refresh failed, using existing token", {
credentialId,
error: refreshError,
@@ -1,3 +1,4 @@
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
import { expandEnvVarsDeep } from "../discovery/helpers";
import type { AuthStorage } from "../session/auth-storage";
@@ -6,6 +7,7 @@ import {
type MCPStoredOAuthCredential,
mcpOAuthCredentialId,
mcpOAuthCredentialProfile,
refreshMCPOAuthToken,
} from "./oauth-flow";
import type { MCPAuthConfig, MCPServerConfig } from "./types";
@@ -80,6 +82,42 @@ export function selectMcpOAuthRefreshMaterial(
return credential.tokenUrl ? credential : auth;
}
/**
* Refresh a stored MCP OAuth credential via the standard `refresh_token` grant.
*
* Refresh material is taken from the credential itself (self-contained modern
* credentials embed `tokenUrl`/`clientId`/`clientSecret`/`resource`) or, for
* legacy credentials that carry none, the server's `auth` block. Shared by the
* local MCP manager and the `omp auth-broker serve` refresh path so a broker
* with no access to the MCP config can still refresh `mcp_oauth:*` credentials
* from the vault.
*
* `serverUrl` supplies the RFC 8707 fallback resource indicator when neither
* the credential nor the auth block advertised one; the manager passes the
* configured server URL, the broker recovers it from the credential id via
* {@link mcpOAuthServerUrlFromCredentialId}.
*
* @throws when no usable refresh token or token endpoint is available.
*/
export function refreshManagedMcpOAuthCredential(
credential: MCPStoredOAuthCredential,
opts: { serverUrl?: string; auth?: MCPAuthConfig; signal?: AbortSignal } = {},
): Promise<OAuthCredentials> {
const material = selectMcpOAuthRefreshMaterial(credential, opts.auth);
const tokenUrl = material?.tokenUrl;
if (!credential.refresh || !tokenUrl) {
throw new Error("MCP OAuth credential is missing refresh material");
}
const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
const resourceIsFallback = !material?.resource && Boolean(opts.serverUrl);
const resource = material?.resource ?? (resourceIsFallback ? opts.serverUrl : undefined);
return refreshMCPOAuthToken(tokenUrl, credential.refresh, material?.clientId, material?.clientSecret, resource, {
authorizationUrl,
stripSameOriginResource: resourceIsFallback,
signal: opts.signal,
});
}
export async function removeManagedMcpOAuthCredential(
authStorage: AuthStorage,
credentialId: string | undefined,
@@ -53,6 +53,27 @@ export function mcpOAuthCredentialProfile(credentialId: string): string | undefi
return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
}
/**
* Server URL embedded in a managed MCP OAuth credential id, or `undefined`
* for legacy random ids (`mcp_oauth_<rand>`) minted before URL-keyed ids.
*
* Inverse of {@link mcpOAuthCredentialId}. Mirrors {@link mcpOAuthCredentialProfile}:
* the URL contains `:` and `/`, so for profile-scoped ids the URL is everything
* after the profile segment; for legacy url-keyed ids (`mcp_oauth:<url>`) it is
* everything after the prefix. Lets the auth-broker — which never sees the MCP
* config — recover the server URL for the RFC 8707 fallback resource on refresh.
*/
export function mcpOAuthServerUrlFromCredentialId(credentialId: string): string | undefined {
if (credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) {
const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
return separator === -1 ? undefined : credentialId.slice(separator + 1) || undefined;
}
if (credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX)) {
return credentialId.slice(MCP_OAUTH_URL_CREDENTIAL_PREFIX.length) || undefined;
}
return undefined;
}
/**
* Stored MCP OAuth credential. Refresh material is embedded so token refresh
* works without any `auth` block persisted in (possibly shared) config files.
@@ -0,0 +1,133 @@
/**
* End-to-end regression for broker-backed MCP OAuth refresh (issue #8933).
*
* Topology mirrors `omp auth-broker serve` fronting a sandboxed client:
* client (RemoteAuthCredentialStore) → broker (SqliteAuthCredentialStore
* + refreshBrokerOAuthCredential override) → MCP token endpoint.
*
* Two gaps used to break this once the ~6h access token expired:
* A. the client threw on the `__remote__` refresh sentinel instead of asking
* the broker to refresh (mcp/manager.ts);
* B. the broker had no `mcp_oauth:*` refresh path, so it answered
* `Unknown OAuth provider` (auth-broker-cli.ts / auth-storage.ts).
*
* The test proves the fixed contract: a remote OAuth MCP server whose access
* token has expired refreshes through the broker (which holds the only real
* refresh token) and the client injects the freshly minted Bearer.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AuthStorage, type OAuthCredential, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
import {
AuthBrokerClient,
type AuthBrokerServerHandle,
RemoteAuthCredentialStore,
startAuthBroker,
} from "@oh-my-pi/pi-ai/auth-broker";
import { refreshBrokerOAuthCredential } from "@oh-my-pi/pi-coding-agent/cli/auth-broker-cli";
import { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
import { mcpOAuthCredentialId } from "@oh-my-pi/pi-coding-agent/mcp/oauth-flow";
import type { MCPServerConfig } from "@oh-my-pi/pi-coding-agent/mcp/types";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import type { Server } from "bun";
const SERVER_URL = "https://mcp.granola.ai/mcp";
const MCP_PROVIDER = mcpOAuthCredentialId(SERVER_URL, "default");
const BEARER = "e2e-broker-mcp-token";
function getAuthorizationHeader(config: MCPServerConfig): string | undefined {
if (config.type !== "http" && config.type !== "sse") return undefined;
return config.headers?.Authorization;
}
describe("broker-backed MCP OAuth refresh", () => {
let tempDir = "";
let tokenServer: Server<undefined> | undefined;
let tokenRequests: URLSearchParams[] = [];
let serverStore: SqliteAuthCredentialStore | undefined;
let serverStorage: AuthStorage | undefined;
let handle: AuthBrokerServerHandle | undefined;
let remote: RemoteAuthCredentialStore | undefined;
let clientStorage: AuthStorage | undefined;
let manager: MCPManager | undefined;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-broker-mcp-refresh-"));
tokenRequests = [];
const server = Bun.serve({
port: 0,
async fetch(req) {
tokenRequests.push(new URLSearchParams(await req.text()));
return Response.json({ access_token: "fresh-access", refresh_token: "rotated-refresh", expires_in: 3600 });
},
});
tokenServer = server;
const tokenUrl = `http://127.0.0.1:${server.port}/token`;
serverStore = await SqliteAuthCredentialStore.open(path.join(tempDir, "broker.db"));
// The serve process constructs AuthStorage with this exact override.
serverStorage = new AuthStorage(serverStore, {
refreshOAuthCredential: (provider, _credentialId, credential, signal) =>
refreshBrokerOAuthCredential(provider, credential, signal),
});
await serverStorage.reload();
// Expired MCP OAuth credential with embedded refresh material, as the
// vault holds it. Spread bypasses the excess-property check for the
// MCP-only extension fields the base OAuthCredential type omits.
const credential: OAuthCredential = {
...{ type: "oauth", access: "stale-access", refresh: "real-refresh-token", expires: Date.now() - 60_000 },
...{ tokenUrl, clientId: "client-xyz" },
};
await serverStorage.set(MCP_PROVIDER, credential);
handle = startAuthBroker({
storage: serverStorage,
bind: "127.0.0.1:0",
bearerTokens: [BEARER],
disableRefresher: true,
});
remote = new RemoteAuthCredentialStore({
client: new AuthBrokerClient({ url: handle.url, token: BEARER }),
streamSnapshots: false,
});
clientStorage = new AuthStorage(remote);
await clientStorage.revalidateCredentials();
manager = new MCPManager(process.cwd());
manager.setAuthStorage(clientStorage);
});
afterEach(async () => {
clientStorage?.close();
await handle?.close();
serverStorage?.close();
serverStore?.close();
tokenServer?.stop(true);
await removeWithRetries(tempDir);
});
test("expired remote MCP token refreshes through the broker and injects the fresh Bearer", async () => {
// Sanity: the client only ever sees the redacted refresh token.
const stored = clientStorage!.get(MCP_PROVIDER);
expect(stored?.type === "oauth" ? stored.refresh : undefined).toBe(REMOTE_REFRESH_SENTINEL);
const prepared = await manager!.prepareConfig({
type: "http",
url: SERVER_URL,
auth: { type: "oauth", credentialId: MCP_PROVIDER },
});
// Gap A + B fixed: fresh access token minted and injected.
expect(getAuthorizationHeader(prepared)).toBe("Bearer fresh-access");
// The grant ran on the BROKER with the real refresh token — the client
// never held it, and the broker no longer answers "Unknown OAuth provider".
expect(tokenRequests).toHaveLength(1);
expect(tokenRequests[0].get("grant_type")).toBe("refresh_token");
expect(tokenRequests[0].get("refresh_token")).toBe("real-refresh-token");
});
});