can1357/oh-my-pi · error · Error

MCP OAuth credential is missing refresh material

Error message

MCP OAuth credential is missing refresh material

What it means

refreshManagedMcpOAuthCredential refreshes a stored MCP OAuth credential via the standard refresh_token grant. It requires two things: a refresh token on the credential itself, and a token endpoint URL resolved from either the credential (modern self-contained credentials) or the server's `auth` config block (legacy credentials). If either is missing, the credential cannot be refreshed through the standard grant and this error is thrown instead of issuing a doomed network request.

Source

Thrown at packages/coding-agent/src/mcp/oauth-credentials.ts:111

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

async function refreshBrokeredMcpOAuthCredential(
	authStorage: AuthStorage,
	credentialId: number,
	provider: string,
	signal?: AbortSignal,
): Promise<OAuthCredentials> {
	const entry = await authStorage.forceRefreshCredentialById(credentialId, signal);

View on GitHub (pinned to 9690622007)

Solutions

  1. Re-run the full OAuth authorization flow for the MCP server so a fresh access token and refresh token are stored
  2. Add a tokenUrl to the server's `auth` config block (or to the stored credential) so legacy credentials can locate the token endpoint
  3. Check whether the OAuth provider issues refresh tokens at all; if it does not, plan for periodic re-authorization instead of refresh
  4. Verify the stored credential in the vault was not partially written (missing refresh field) by an interrupted flow

Example fix

// before: legacy config with no token endpoint
{"mcpServers": {"acme": {"url": "https://acme.example/mcp", "oauth": {"clientId": "x"}}}}
// after: supply the token endpoint so the refresh grant can run
{"mcpServers": {"acme": {"url": "https://acme.example/mcp", "auth": {"tokenUrl": "https://acme.example/oauth/token", "clientId": "x"}}}}
Defensive patterns

Strategy: validation

Validate before calling

function canRefresh(c) {
  const material = c.tokenUrl ? c : authConfig;
  return Boolean(c.refresh && material?.tokenUrl);
}
if (!canRefresh(credential)) await runFullAuthorizationFlow(serverUrl);

Type guard

function hasRefreshMaterial(c, auth) {
  const material = c.tokenUrl ? c : auth;
  return typeof c.refresh === 'string' && c.refresh.length > 0 &&
         typeof material?.tokenUrl === 'string' && material.tokenUrl.length > 0;
}

Try / catch

try {
  creds = await refreshManagedMcpOAuthCredential(credential, { serverUrl, auth });
} catch (e) {
  if (e.message.includes('missing refresh material')) {
    creds = await runFullAuthorizationFlow(serverUrl); // re-auth instead of refresh
  } else throw e;
}

Prevention

When it happens

Trigger: Calling refreshManagedMcpOAuthCredential (directly, or via refreshBrokerOAuthCredential / refreshStoredManagedMcpOAuthCredential) when credential.refresh is falsy (the token was never stored or was consumed as a one-time token), or when neither the stored credential nor the passed opts.auth block carries a tokenUrl.

Common situations: Legacy MCP server configs that only stored an access token without a refresh token; auth blocks that specify only an authorization server but omit the token endpoint; OAuth providers that never issue refresh tokens; credentials whose refresh token was rotated and dropped by the server.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/c0f967013226dcc9. Report an issue: GitHub.