feat: support refreshable bearer stdio auth

This commit is contained in:
Peter Steinberger 2026-05-14 18:29:43 +01:00
parent 3e06e582ef
commit 7f1e9a8ce0
No known key found for this signature in database
22 changed files with 827 additions and 12 deletions

View File

@ -4,6 +4,7 @@
### Config
- Support `auth: "refreshable_bearer"` with explicit `refresh` settings so cached OAuth tokens can be refreshed before HTTP connects or injected into stdio env vars. (Issue #173, thanks @tokyo-s)
- Add `httpFetch: "node-http1"` for HTTP MCP servers whose providers reject Node's built-in `fetch`, and auto-apply it to Sunsama's endpoint. (Issue #158, thanks @mattash)
- Resolve `${VAR}` and `${VAR:-fallback}` placeholders across string-valued server config fields such as `baseUrl`, `command`/`args`, `tokenCacheDir`, and pre-registered OAuth fields while keeping headers/env/bearer-token placeholders lazy until runtime. (PR #161 / issue #157, thanks @zxyasfas)
- Add `mcporter vault set <server>` and `mcporter vault clear <server>` so headless deployments can seed or clear OAuth vault credentials without reproducing mcporter's internal vault-key format. (Issue #156)

View File

@ -225,7 +225,7 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
| `cwd` | Working directory for stdio servers. A leading `~` is expanded to `$HOME`; relative paths resolve against the config file directory. Defaults to the config file directory when omitted. |
| `env` | Key/value pairs applied when launching stdio commands. Supports `${VAR}` interpolation and `${VAR:-fallback}` defaults. Existing process env values win over fallbacks. |
| `headers` | Request headers for HTTP/SSE transports. Values can reference `$env:VAR` or `${VAR}` placeholders, which must be set at runtime or mcporter aborts with a helpful error. |
| `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. `mcporter list` can still reuse an existing OAuth token cache for older HTTP entries missing this marker. |
| `auth` | Recognizes `oauth` and `refreshable_bearer`. `oauth` runs the browser/client OAuth flow for HTTP transports; `refreshable_bearer` refreshes cached bearer tokens non-interactively before connecting. |
| `tokenCacheDir` | Directory for OAuth tokens and schema caches; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` or `$XDG_DATA_HOME/mcporter/credentials.json` when set (legacy per-server caches are auto-migrated). Supports `~` expansion. |
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
| `oauthClientId` | Pre-registered OAuth client id for providers that do not support dynamic client registration. |
@ -234,12 +234,39 @@ Server definition fields (subset of what `RawEntrySchema` accepts):
| `oauthRedirectUrl` | Override the default localhost callback. Required for many pre-registered OAuth apps because the provider must allowlist the exact redirect URI. Also useful when tunneling OAuth through Codespaces or remote dev boxes. |
| `oauthScope` | Optional explicit OAuth scope string. If omitted, mcporter lets the MCP SDK derive scope from server/auth metadata. Use this as an escape hatch for providers that require explicit scopes but dont publish `scopes_supported`. |
| `oauthCommand.args` | For STDIO servers that ship a custom auth subcommand (e.g., Gmail MCP). mcporter will spawn the stdio command with these args when you run `mcporter auth <name>`, so you dont need to call `npx ... auth` manually. |
| `refresh` | Explicit token refresh settings for `auth: "refreshable_bearer"`. Supports `tokenEndpoint`, `clientIdEnv`, `clientSecretEnv`, `clientAuthMethod`, `refreshSkewSeconds`, and `accessTokenEnv` (plus snake_case aliases). |
| `allowedTools` / `allowed_tools` | Optional exact-name allowlist. Only listed tools appear in `mcporter list` and can be called. An empty array blocks all tools. Cannot be combined with `blockedTools`. |
| `blockedTools` / `blocked_tools` | Optional exact-name blocklist. Listed tools are hidden from `mcporter list` and rejected by `mcporter call`. Cannot be combined with `allowedTools`. |
mcporter normalizes headers to include `Accept: application/json, text/event-stream` automatically, matching the runtimes streaming expectations.
String-valued config fields support `${VAR}` and `${VAR:-fallback}` placeholders. Secret-bearing `headers`, `env`, and bearer-token placeholders are preserved in `config get`/`config list` output and resolved only when the transport runs; `*Env` fields name environment variables and are not expanded.
### Refreshable Bearer Tokens
Use `auth: "refreshable_bearer"` when you already seeded OAuth tokens with `mcporter vault set <server>` or `tokenCacheDir`, and the server should receive only a fresh bearer token at runtime. HTTP servers get `Authorization: Bearer <token>` when no authorization header is already configured. STDIO servers require `refresh.accessTokenEnv`; mcporter refreshes before spawning the process and injects that env var with the raw access token.
```json
{
"mcpServers": {
"example": {
"command": "uvx",
"args": ["example-mcp-server"],
"auth": "refreshable_bearer",
"refresh": {
"tokenEndpoint": "https://api.example.com/oauth/token",
"clientIdEnv": "EXAMPLE_CLIENT_ID",
"clientSecretEnv": "EXAMPLE_CLIENT_SECRET",
"clientAuthMethod": "client_secret_basic",
"refreshSkewSeconds": 300,
"accessTokenEnv": "EXAMPLE_ACCESS_TOKEN"
}
}
}
}
```
For keep-alive stdio servers, refresh happens before process start. If that process cannot read updated credentials after startup, use `lifecycle: "ephemeral"` or restart the daemon before the injected token expires.
## Imports & Conflict Resolution
- `pathsForImport(kind, rootDir)` determines every candidate path. mcporter searches the repo first, then user-level directories, and stops at the first file that parses.

View File

@ -197,6 +197,65 @@
"description": "Environment variable name containing the bearer token (snake_case)",
"type": "string"
},
"refresh": {
"type": "object",
"properties": {
"tokenEndpoint": {
"description": "OAuth token endpoint used to refresh access tokens",
"type": "string"
},
"token_endpoint": {
"description": "OAuth token endpoint used to refresh access tokens",
"type": "string"
},
"clientIdEnv": {
"description": "Environment variable containing the OAuth client id",
"type": "string"
},
"client_id_env": {
"description": "Environment variable containing the OAuth client id",
"type": "string"
},
"clientSecretEnv": {
"description": "Environment variable containing the OAuth client secret",
"type": "string"
},
"client_secret_env": {
"description": "Environment variable containing the OAuth client secret",
"type": "string"
},
"clientAuthMethod": {
"description": "OAuth token endpoint client auth method",
"type": "string"
},
"client_auth_method": {
"description": "OAuth token endpoint client auth method",
"type": "string"
},
"refreshSkewSeconds": {
"description": "Refresh before expiry by this many seconds",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"refresh_skew_seconds": {
"description": "Refresh before expiry by this many seconds",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"accessTokenEnv": {
"description": "STDIO env var that receives the refreshed access token",
"type": "string"
},
"access_token_env": {
"description": "STDIO env var that receives the refreshed access token",
"type": "string"
}
},
"additionalProperties": false,
"description": "Refreshable bearer token settings"
},
"httpFetch": {
"description": "HTTP fetch implementation for Streamable HTTP/SSE requests",
"type": "string",

View File

@ -28,6 +28,7 @@ export interface SerializedServerDefinition {
readonly oauthTokenEndpointAuthMethod?: string;
readonly oauthRedirectUrl?: string;
readonly oauthScope?: string;
readonly refresh?: ServerDefinition['refresh'];
readonly httpFetch?: ServerDefinition['httpFetch'];
readonly allowedTools?: readonly string[];
readonly blockedTools?: readonly string[];
@ -152,6 +153,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
@ -175,6 +177,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,

View File

@ -14,6 +14,7 @@ export type SerializedServerDefinition = {
oauthTokenEndpointAuthMethod?: string;
oauthRedirectUrl?: string;
oauthScope?: string;
refresh?: ServerDefinition['refresh'];
httpFetch?: ServerDefinition['httpFetch'];
allowedTools?: readonly string[];
blockedTools?: readonly string[];
@ -41,6 +42,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
@ -62,6 +64,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
oauthTokenEndpointAuthMethod: definition.oauthTokenEndpointAuthMethod,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
refresh: definition.refresh,
httpFetch: definition.httpFetch,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
@ -94,8 +97,8 @@ export function printServerSummary(definition: ServerDefinition): void {
if (definition.description) {
console.log(` ${label('Description')}: ${definition.description}`);
}
if (definition.auth === 'oauth') {
console.log(` ${label('Auth')}: oauth`);
if (definition.auth) {
console.log(` ${label('Auth')}: ${definition.auth}`);
}
if (definition.allowedTools !== undefined) {
const rendered = definition.allowedTools.length > 0 ? definition.allowedTools.join(', ') : '<none>';

View File

@ -5,6 +5,7 @@ import {
type HttpCommand,
loadServerDefinitions,
type RawLifecycle,
type RefreshableBearerOptions,
type ServerDefinition,
type ServerLoggingOptions,
type StdioCommand,
@ -205,6 +206,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
);
const oauthRedirectUrl = typeof def.oauthRedirectUrl === 'string' ? def.oauthRedirectUrl : undefined;
const oauthScope = typeof def.oauthScope === 'string' ? def.oauthScope : undefined;
const refresh = getRefresh(record.refresh);
const httpFetch = normalizeHttpFetch(stringFromAliases(record, 'httpFetch', 'http_fetch'));
const headers = toStringRecord((def as Record<string, unknown>).headers);
const oauthCommand = getOauthCommand(record.oauthCommand ?? record.oauth_command);
@ -229,6 +231,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
oauthRedirectUrl,
oauthScope,
oauthCommand,
refresh,
httpFetch,
lifecycle: resolveLifecycle(name, rawLifecycle, command),
logging,
@ -384,6 +387,28 @@ function getOauthCommand(value: unknown): ServerDefinition['oauthCommand'] | und
return args ? { args } : undefined;
}
function getRefresh(value: unknown): RefreshableBearerOptions | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const tokenEndpoint = stringFromAliases(record, 'tokenEndpoint', 'token_endpoint');
if (!tokenEndpoint) {
return undefined;
}
const refreshSkewSeconds = record.refreshSkewSeconds ?? record.refresh_skew_seconds;
return {
tokenEndpoint,
clientIdEnv: stringFromAliases(record, 'clientIdEnv', 'client_id_env'),
clientSecretEnv: stringFromAliases(record, 'clientSecretEnv', 'client_secret_env'),
clientAuthMethod: stringFromAliases(record, 'clientAuthMethod', 'client_auth_method'),
...(typeof refreshSkewSeconds === 'number' && Number.isInteger(refreshSkewSeconds) && refreshSkewSeconds >= 0
? { refreshSkewSeconds }
: {}),
accessTokenEnv: stringFromAliases(record, 'accessTokenEnv', 'access_token_env'),
};
}
function normalizeHttpFetch(value: string | undefined): ServerDefinition['httpFetch'] | undefined {
return value === 'default' || value === 'node-http1' ? value : undefined;
}

View File

@ -1,6 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
import type {
CommandSpec,
RawEntry,
RawRefresh,
RefreshableBearerOptions,
ServerDefinition,
ServerLoggingOptions,
ServerSource,
} from './config-schema.js';
import { expandHome, resolveEnvPlaceholders } from './env.js';
import { resolveLifecycle } from './lifecycle.js';
@ -25,6 +33,7 @@ export function normalizeServerEntry(
raw.oauthTokenEndpointAuthMethod ?? raw.oauth_token_endpoint_auth_method ?? undefined;
const oauthRedirectUrl = raw.oauthRedirectUrl ?? raw.oauth_redirect_url ?? undefined;
const oauthScope = raw.oauthScope ?? raw.oauth_scope ?? undefined;
const refresh = normalizeRefresh(raw.refresh);
const httpFetch = normalizeHttpFetch(raw.httpFetch ?? raw.http_fetch);
const oauthCommandRaw = raw.oauthCommand ?? raw.oauth_command;
const oauthCommand = oauthCommandRaw ? { args: [...oauthCommandRaw.args] } : undefined;
@ -77,6 +86,7 @@ export function normalizeServerEntry(
oauthRedirectUrl,
oauthScope,
oauthCommand: defaultedOauthCommand,
refresh,
httpFetch,
source,
sources,
@ -145,9 +155,27 @@ function normalizeAuth(auth: string | undefined): string | undefined {
if (auth.toLowerCase() === 'oauth') {
return 'oauth';
}
if (auth.toLowerCase() === 'refreshable_bearer') {
return 'refreshable_bearer';
}
return undefined;
}
function normalizeRefresh(raw: RawRefresh | undefined): RefreshableBearerOptions | undefined {
const tokenEndpoint = raw?.tokenEndpoint ?? raw?.token_endpoint;
if (!tokenEndpoint) {
return undefined;
}
return {
tokenEndpoint,
clientIdEnv: raw?.clientIdEnv ?? raw?.client_id_env,
clientSecretEnv: raw?.clientSecretEnv ?? raw?.client_secret_env,
clientAuthMethod: raw?.clientAuthMethod ?? raw?.client_auth_method,
refreshSkewSeconds: raw?.refreshSkewSeconds ?? raw?.refresh_skew_seconds,
accessTokenEnv: raw?.accessTokenEnv ?? raw?.access_token_env,
};
}
function normalizeHttpFetch(value: 'default' | 'node-http1' | undefined): 'default' | 'node-http1' | undefined {
return value;
}

View File

@ -52,6 +52,33 @@ const RawHttpFetchSchema = z
.enum(['default', 'node-http1'])
.describe('HTTP fetch implementation for Streamable HTTP/SSE requests');
const RawRefreshSchema = z
.object({
tokenEndpoint: z.string().optional().describe('OAuth token endpoint used to refresh access tokens'),
token_endpoint: z.string().optional().describe('OAuth token endpoint used to refresh access tokens'),
clientIdEnv: z.string().optional().describe('Environment variable containing the OAuth client id'),
client_id_env: z.string().optional().describe('Environment variable containing the OAuth client id'),
clientSecretEnv: z.string().optional().describe('Environment variable containing the OAuth client secret'),
client_secret_env: z.string().optional().describe('Environment variable containing the OAuth client secret'),
clientAuthMethod: z.string().optional().describe('OAuth token endpoint client auth method'),
client_auth_method: z.string().optional().describe('OAuth token endpoint client auth method'),
refreshSkewSeconds: z
.number()
.int()
.nonnegative()
.optional()
.describe('Refresh before expiry by this many seconds'),
refresh_skew_seconds: z
.number()
.int()
.nonnegative()
.optional()
.describe('Refresh before expiry by this many seconds'),
accessTokenEnv: z.string().optional().describe('STDIO env var that receives the refreshed access token'),
access_token_env: z.string().optional().describe('STDIO env var that receives the refreshed access token'),
})
.describe('Refreshable bearer token settings');
export const RawEntrySchema = z
.object({
description: z.string().optional().describe('Human-readable description of the server'),
@ -122,6 +149,7 @@ export const RawEntrySchema = z
.string()
.optional()
.describe('Environment variable name containing the bearer token (snake_case)'),
refresh: RawRefreshSchema.optional(),
httpFetch: RawHttpFetchSchema.optional().describe('HTTP fetch implementation for Streamable HTTP/SSE requests'),
http_fetch: RawHttpFetchSchema.optional().describe('HTTP fetch implementation for Streamable HTTP/SSE requests'),
lifecycle: RawLifecycleSchema.optional(),
@ -156,6 +184,7 @@ export const RawConfigSchema = z
export type RawEntry = z.infer<typeof RawEntrySchema>;
export type RawConfig = z.infer<typeof RawConfigSchema>;
export type RawRefresh = z.infer<typeof RawRefreshSchema>;
export interface HttpCommand {
readonly kind: 'http';
@ -193,6 +222,15 @@ export interface ServerLoggingOptions {
};
}
export interface RefreshableBearerOptions {
readonly tokenEndpoint: string;
readonly clientIdEnv?: string;
readonly clientSecretEnv?: string;
readonly clientAuthMethod?: string;
readonly refreshSkewSeconds?: number;
readonly accessTokenEnv?: string;
}
export interface ServerDefinition {
readonly name: string;
readonly description?: string;
@ -210,6 +248,7 @@ export interface ServerDefinition {
readonly oauthCommand?: {
readonly args: string[];
};
readonly refresh?: RefreshableBearerOptions;
readonly httpFetch?: 'default' | 'node-http1';
readonly source?: ServerSource;
readonly sources?: readonly ServerSource[];

View File

@ -27,6 +27,7 @@ export type {
RawConfig,
RawEntry,
RawLifecycle,
RefreshableBearerOptions,
ServerDefinition,
ServerLifecycle,
ServerLoggingOptions,

View File

@ -158,6 +158,11 @@ function convertExternalEntry(value: Record<string, unknown>): RawEntry | null {
result.httpFetch = httpFetch;
}
const refresh = asRefresh(value.refresh);
if (refresh) {
result.refresh = refresh;
}
const url = asString(value.baseUrl ?? value.base_url ?? value.url ?? value.serverUrl ?? value.server_url);
if (url) {
result.baseUrl = url;
@ -206,6 +211,36 @@ function buildExternalHeaders(record: Record<string, unknown>): Record<string, s
return Object.keys(headers).length > 0 ? headers : undefined;
}
function asRefresh(value: unknown): RawEntry['refresh'] | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
copyString(record, result, 'tokenEndpoint', 'token_endpoint');
copyString(record, result, 'clientIdEnv', 'client_id_env');
copyString(record, result, 'clientSecretEnv', 'client_secret_env');
copyString(record, result, 'clientAuthMethod', 'client_auth_method');
copyString(record, result, 'accessTokenEnv', 'access_token_env');
const refreshSkewSeconds = record.refreshSkewSeconds ?? record.refresh_skew_seconds;
if (typeof refreshSkewSeconds === 'number' && Number.isInteger(refreshSkewSeconds) && refreshSkewSeconds >= 0) {
result.refreshSkewSeconds = refreshSkewSeconds;
}
return Object.keys(result).length > 0 ? (result as RawEntry['refresh']) : undefined;
}
function copyString(
source: Record<string, unknown>,
target: Record<string, unknown>,
camel: string,
snake: string
): void {
const value = asString(source[camel] ?? source[snake]);
if (value) {
target[camel] = value;
}
}
function extractClaudeProjectEntries(raw: Record<string, unknown>, projectRoot: string): Map<string, RawEntry> {
const map = new Map<string, RawEntry>();
if (!isRecord(raw.projects)) {

View File

@ -80,6 +80,7 @@ function serializeRawEntry(server: ServerDefinition): RawEntry {
...(server.oauthRedirectUrl ? { oauthRedirectUrl: server.oauthRedirectUrl } : {}),
...(server.oauthScope ? { oauthScope: server.oauthScope } : {}),
...(server.oauthCommand ? { oauthCommand: server.oauthCommand } : {}),
...(server.refresh ? { refresh: server.refresh } : {}),
...(server.httpFetch ? { httpFetch: server.httpFetch } : {}),
...(server.lifecycle ? { lifecycle: serializeLifecycle(server.lifecycle) } : {}),
...(server.logging ? { logging: server.logging } : {}),

View File

@ -1,4 +1,5 @@
import fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import os from 'node:os';
import path from 'node:path';
import { discoverOAuthServerInfo, refreshAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
@ -61,10 +62,10 @@ function tokenExpirySeconds(tokens: OAuthTokens): number | undefined {
return undefined;
}
function shouldRefreshCachedToken(tokens: OAuthTokens): boolean {
function shouldRefreshCachedToken(tokens: OAuthTokens, skewSeconds = TOKEN_EXPIRY_SKEW_SECONDS): boolean {
const expiresAt = tokenExpirySeconds(tokens);
if (expiresAt !== undefined) {
return expiresAt <= Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SKEW_SECONDS;
return expiresAt <= Math.floor(Date.now() / 1000) + skewSeconds;
}
return typeof tokens.expires_in === 'number' && typeof tokens.refresh_token === 'string';
}
@ -375,6 +376,9 @@ export async function readCachedAccessToken(
if (!tokens || typeof tokens.access_token !== 'string' || tokens.access_token.trim().length === 0) {
return undefined;
}
if (definition.auth === 'refreshable_bearer') {
return await readExplicitRefreshableBearerToken(definition, persistence, tokens, logger);
}
if (!shouldRefreshCachedToken(tokens)) {
return tokens.access_token;
}
@ -412,3 +416,161 @@ export async function readCachedAccessToken(
return tokens.access_token;
}
}
async function readExplicitRefreshableBearerToken(
definition: ServerDefinition,
persistence: OAuthPersistence,
tokens: OAuthTokens,
logger?: Logger
): Promise<string> {
const refresh = definition.refresh;
const skewSeconds = refresh?.refreshSkewSeconds ?? TOKEN_EXPIRY_SKEW_SECONDS;
if (!shouldRefreshCachedToken(tokens, skewSeconds)) {
return tokens.access_token;
}
if (!refresh) {
throw new Error(`Cached bearer token for '${definition.name}' is expired, but refresh is not configured.`);
}
if (typeof tokens.refresh_token !== 'string' || tokens.refresh_token.trim().length === 0) {
throw new Error(`Cached bearer token for '${definition.name}' is expired, but no refresh_token is available.`);
}
try {
const refreshed = await refreshBearerToken(definition, tokens.refresh_token);
await persistence.saveTokens(refreshed);
logger?.debug?.(`Refreshed bearer access token for '${definition.name}' (non-interactive).`);
return refreshed.access_token;
} catch (error) {
logger?.debug?.(
`Failed to refresh bearer token for '${definition.name}' non-interactively: ${
error instanceof Error ? error.message : String(error)
}`
);
throw new Error(
`Failed to refresh cached bearer token for '${definition.name}': ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
);
}
}
async function refreshBearerToken(definition: ServerDefinition, refreshToken: string): Promise<OAuthTokens> {
const refresh = definition.refresh;
if (!refresh) {
throw new Error('Missing refresh configuration.');
}
const clientId = readEnvOrConfig(refresh.clientIdEnv, definition.oauthClientId);
const method = refresh.clientAuthMethod ?? definition.oauthTokenEndpointAuthMethod ?? 'client_secret_basic';
const clientSecret = method === 'none' ? undefined : readClientSecret(definition, refresh.clientSecretEnv);
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const headers: Record<string, string> = {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
};
if (method === 'client_secret_post') {
if (clientId) {
body.set('client_id', clientId);
}
if (clientSecret) {
body.set('client_secret', clientSecret);
}
} else if (method === 'none') {
if (clientId) {
body.set('client_id', clientId);
}
} else {
if (!clientId || !clientSecret) {
throw new Error(`Refresh client credentials are required for '${method}'.`);
}
headers.authorization = `Basic ${Buffer.from(
`${formEncodeCredential(clientId)}:${formEncodeCredential(clientSecret)}`
).toString('base64')}`;
}
const response = await fetch(refresh.tokenEndpoint, {
method: 'POST',
headers,
body,
});
if (!response.ok) {
throw new Error(`Token endpoint returned HTTP ${response.status}.`);
}
const payload = normalizeBearerTokenResponse(await response.json());
return {
...payload,
...(payload.refresh_token ? {} : { refresh_token: refreshToken }),
};
}
function normalizeBearerTokenResponse(value: unknown): OAuthTokens {
if (!value || typeof value !== 'object') {
throw new Error('Token endpoint did not return a JSON object.');
}
const payload = value as Record<string, unknown>;
if (typeof payload.access_token !== 'string' || payload.access_token.trim().length === 0) {
throw new Error('Token endpoint did not return an access_token.');
}
return {
access_token: payload.access_token,
token_type: typeof payload.token_type === 'string' && payload.token_type ? payload.token_type : 'Bearer',
...(typeof payload.id_token === 'string' ? { id_token: payload.id_token } : {}),
...(typeof payload.scope === 'string' ? { scope: payload.scope } : {}),
...(typeof payload.refresh_token === 'string' && payload.refresh_token
? { refresh_token: payload.refresh_token }
: {}),
...coerceExpiresIn(payload.expires_in),
};
}
function coerceExpiresIn(value: unknown): Pick<OAuthTokens, 'expires_in'> {
if (typeof value === 'number' && Number.isFinite(value)) {
return { expires_in: value };
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return { expires_in: parsed };
}
}
return {};
}
function readEnvOrConfig(envName: string | undefined, fallback: string | undefined): string | undefined {
if (!envName) {
return fallback;
}
const value = process.env[envName];
if (value === undefined || value.trim().length === 0) {
throw new Error(`Environment variable '${envName}' is required for bearer token refresh.`);
}
return value;
}
function formEncodeCredential(value: string): string {
return new URLSearchParams([['', value]]).toString().slice(1);
}
function readClientSecret(
definition: ServerDefinition,
refreshClientSecretEnv: string | undefined
): string | undefined {
if (refreshClientSecretEnv) {
return readEnvOrConfig(refreshClientSecretEnv, undefined);
}
return resolveOAuthClientSecret(definition);
}
function resolveOAuthClientSecret(definition: ServerDefinition): string | undefined {
if (definition.oauthClientSecretEnv) {
const value = process.env[definition.oauthClientSecretEnv];
if (value === undefined || value.trim().length === 0) {
throw new Error(`Environment variable '${definition.oauthClientSecretEnv}' is required for OAuth client secret.`);
}
return value;
}
return definition.oauthClientSecret;
}

View File

@ -3,7 +3,7 @@ import { analyzeConnectionError } from './error-classifier.js';
import type { Logger } from './logging.js';
export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger): ServerDefinition | undefined {
if (definition.auth === 'oauth') {
if (definition.auth === 'oauth' || definition.auth === 'refreshable_bearer') {
return undefined;
}
if (definition.command.kind !== 'http') {

View File

@ -143,6 +143,10 @@ function shouldAbortSseFallback(error: unknown): boolean {
return isOAuthFlowError(error) || error instanceof OAuthTimeoutError;
}
function hasAuthorizationHeader(headers: Record<string, string> | undefined): boolean {
return Boolean(headers && Object.keys(headers).some((key) => key.toLowerCase() === 'authorization'));
}
function maybePromoteHttpDefinition(
definition: ServerDefinition,
logger: Logger,
@ -169,21 +173,55 @@ async function connectHttpTransport<TTransport extends OAuthCapableTransport>(
}
}
async function applyCachedOAuthHeaderIfAvailable(
async function applyCachedAuthIfAvailable(
definition: ServerDefinition,
logger: Logger,
allowCachedAuth: boolean | undefined
): Promise<ServerDefinition> {
if (!allowCachedAuth || definition.command.kind !== 'http') {
if (!allowCachedAuth && definition.auth !== 'refreshable_bearer') {
return definition;
}
if (
definition.auth === 'refreshable_bearer' &&
definition.command.kind === 'stdio' &&
!definition.refresh?.accessTokenEnv
) {
throw new Error(
`Server '${definition.name}' uses refreshable_bearer stdio auth but is missing refresh.accessTokenEnv.`
);
}
if (definition.command.kind === 'http' && hasAuthorizationHeader(definition.command.headers)) {
return definition;
}
try {
const cached = await readCachedAccessToken(definition, logger);
if (!cached) {
if (definition.auth === 'refreshable_bearer') {
throw new Error(`Server '${definition.name}' uses refreshable_bearer auth but has no cached access token.`);
}
return definition;
}
if (definition.command.kind === 'stdio') {
if (definition.auth !== 'refreshable_bearer') {
return definition;
}
const accessTokenEnv = definition.refresh?.accessTokenEnv;
if (!accessTokenEnv) {
throw new Error(
`Server '${definition.name}' uses refreshable_bearer stdio auth but is missing refresh.accessTokenEnv.`
);
}
logger.debug?.(`Using cached bearer access token for '${definition.name}' stdio env.`);
return {
...definition,
env: {
...definition.env,
[accessTokenEnv]: cached,
},
};
}
const existingHeaders = definition.command.headers ?? {};
if ('Authorization' in existingHeaders) {
if (hasAuthorizationHeader(existingHeaders)) {
return definition;
}
logger.debug?.(`Using cached OAuth access token for '${definition.name}' (non-interactive).`);
@ -198,6 +236,9 @@ async function applyCachedOAuthHeaderIfAvailable(
},
};
} catch (error) {
if (definition.auth === 'refreshable_bearer') {
throw error;
}
logger.debug?.(
`Failed to read cached OAuth token for '${definition.name}': ${
error instanceof Error ? error.message : String(error)
@ -304,6 +345,9 @@ async function attemptHttpClientContext(
if (promoted) {
return { nextDefinition: promoted };
}
if (activeDefinition.auth) {
throw primaryError;
}
oauthSession = undefined;
}
if (primaryError instanceof Error) {
@ -382,6 +426,9 @@ async function connectSseFallbackTransport(
options.onDefinitionPromoted?.(promoted);
return retryHttpTransportWithFallback(client, promoted, logger, options);
}
if (definition.auth) {
throw sseError;
}
}
throw sseError;
}
@ -394,7 +441,7 @@ export async function createClientContext(
options: CreateClientContextOptions = {}
): Promise<ClientContext> {
const client = new Client(clientInfo);
const activeDefinition = await applyCachedOAuthHeaderIfAvailable(definition, logger, options.allowCachedAuth);
const activeDefinition = await applyCachedAuthIfAvailable(definition, logger, options.allowCachedAuth);
return withEnvOverrides(activeDefinition.env, async () => {
if (activeDefinition.command.kind === 'stdio') {

View File

@ -93,6 +93,26 @@ describe('generate-cli runner internals', () => {
expect(serializeDefinition(definition).httpFetch).toBe('node-http1');
});
it('preserves refreshable bearer metadata in generated CLI definitions', () => {
const definition = normalizeDefinition({
name: 'stdio-refresh',
command: 'node',
args: ['server.js'],
auth: 'refreshable_bearer',
refresh: {
token_endpoint: 'https://auth.example.com/token',
access_token_env: 'EXAMPLE_ACCESS_TOKEN',
},
});
expect(definition.auth).toBe('refreshable_bearer');
expect(definition.refresh).toEqual({
tokenEndpoint: 'https://auth.example.com/token',
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
});
expect(serializeDefinition(definition).refresh).toEqual(definition.refresh);
});
it('wraps single-token stdio commands when passed via --command', () => {
const args = ['--command', './scripts/mcp-server.ts'];
const parsed = parseGenerateFlags([...args]);

View File

@ -102,6 +102,10 @@ describe('config imports', () => {
'https://cursor.local/mcp'
);
expect(shared?.httpFetch).toBe('node-http1');
expect(shared?.refresh).toEqual({
tokenEndpoint: 'https://auth.cursor.local/token',
accessTokenEnv: 'CURSOR_ACCESS_TOKEN',
});
expect(shared?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, '.cursor', 'mcp.json'),

View File

@ -159,6 +159,48 @@ describe('config normalization', () => {
expect(servers.find((entry) => entry.name === 'defaulted')?.httpFetch).toBe('default');
});
it('normalizes refreshable bearer config for stdio servers', async () => {
await fs.mkdir(TEMP_DIR, { recursive: true });
const configPath = path.join(TEMP_DIR, 'mcporter-refreshable-stdio.json');
await fs.writeFile(
configPath,
JSON.stringify(
{
mcpServers: {
example: {
command: 'node',
args: ['server.js'],
auth: 'refreshable_bearer',
refresh: {
token_endpoint: 'https://auth.example.com/token',
client_id_env: 'EXAMPLE_CLIENT_ID',
client_secret_env: 'EXAMPLE_CLIENT_SECRET',
client_auth_method: 'client_secret_post',
refresh_skew_seconds: 300,
access_token_env: 'EXAMPLE_ACCESS_TOKEN',
},
},
},
},
null,
2
),
'utf8'
);
const servers = await loadServerDefinitions({ configPath });
const server = servers.find((entry) => entry.name === 'example');
expect(server?.auth).toBe('refreshable_bearer');
expect(server?.refresh).toEqual({
tokenEndpoint: 'https://auth.example.com/token',
clientIdEnv: 'EXAMPLE_CLIENT_ID',
clientSecretEnv: 'EXAMPLE_CLIENT_SECRET',
clientAuthMethod: 'client_secret_post',
refreshSkewSeconds: 300,
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
});
});
it('normalizes pre-registered OAuth client fields', async () => {
await fs.mkdir(TEMP_DIR, { recursive: true });
const configPath = path.join(TEMP_DIR, 'mcporter-oauth-client.json');

View File

@ -22,6 +22,10 @@ describe('config render helpers', () => {
oauthTokenEndpointAuthMethod: 'client_secret_post',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
},
httpFetch: 'node-http1',
allowedTools: ['read'],
env: { FOO: 'bar' },
@ -41,6 +45,10 @@ describe('config render helpers', () => {
oauthTokenEndpointAuthMethod: 'client_secret_post',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
},
httpFetch: 'node-http1',
allowedTools: ['read'],
env: { FOO: 'bar' },

View File

@ -2,7 +2,11 @@
"mcpServers": {
"shared": {
"baseUrl": "https://cursor.local/mcp",
"httpFetch": "node-http1"
"httpFetch": "node-http1",
"refresh": {
"tokenEndpoint": "https://auth.cursor.local/token",
"accessTokenEnv": "CURSOR_ACCESS_TOKEN"
}
},
"cursor-only": {
"command": "cursor-cli",

View File

@ -1,4 +1,5 @@
import fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -34,6 +35,7 @@ describe('oauth persistence', () => {
afterEach(async () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
process.env = { ...originalEnv };
if (hasSpy) {
homedirSpy.mockRestore();
@ -377,6 +379,191 @@ describe('oauth persistence', () => {
);
});
it('refreshes explicit refreshable bearer tokens through the configured token endpoint', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bearer-refresh-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
process.env.CLIENT_ID = 'client-id';
process.env.CLIENT_SECRET = 'client-secret';
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
access_token: 'fresh-bearer',
token_type: 'Bearer',
expires_in: '3600',
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
vi.stubGlobal('fetch', fetchMock);
const definition: ServerDefinition = {
name: 'stdio-refresh',
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: tmp },
auth: 'refreshable_bearer',
tokenCacheDir: cacheDir,
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
clientIdEnv: 'CLIENT_ID',
clientSecretEnv: 'CLIENT_SECRET',
clientAuthMethod: 'client_secret_post',
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
},
};
await expect(readCachedAccessToken(definition)).resolves.toBe('fresh-bearer');
expect(fetchMock).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
}),
})
);
const [, request] = fetchMock.mock.calls[0] ?? [];
expect((request as { body?: URLSearchParams }).body?.toString()).toContain('grant_type=refresh_token');
expect((request as { body?: URLSearchParams }).body?.toString()).toContain('client_id=client-id');
expect((request as { body?: URLSearchParams }).body?.toString()).toContain('client_secret=client-secret');
const persisted = (await readJsonFile(path.join(cacheDir, 'tokens.json'))) as
| { access_token?: string; refresh_token?: string; expires_at?: number }
| undefined;
expect(persisted?.access_token).toBe('fresh-bearer');
expect(persisted?.refresh_token).toBe('refresh-123');
expect(persisted?.expires_at).toBeGreaterThan(Math.floor(Date.now() / 1000));
});
it('form-encodes refresh credentials for client_secret_basic', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bearer-basic-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
process.env.CLIENT_ID = 'client:id';
process.env.CLIENT_SECRET = 'secret + value';
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ access_token: 'fresh-basic', token_type: 'Bearer' }), { status: 200 })
);
vi.stubGlobal('fetch', fetchMock);
const definition: ServerDefinition = {
name: 'basic-refresh',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'refreshable_bearer',
tokenCacheDir: cacheDir,
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
clientIdEnv: 'CLIENT_ID',
clientSecretEnv: 'CLIENT_SECRET',
},
};
await expect(readCachedAccessToken(definition)).resolves.toBe('fresh-basic');
const [, request] = fetchMock.mock.calls[0] ?? [];
const headers = (request as { headers?: Record<string, string> }).headers;
expect(headers?.authorization).toBe(`Basic ${Buffer.from('client%3Aid:secret+%2B+value').toString('base64')}`);
});
it('does not return expired refreshable bearer tokens when refresh fails', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bearer-refresh-fail-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
process.env.CLIENT_ID = 'client-id';
process.env.CLIENT_SECRET = 'client-secret';
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
refresh_token: 'refresh-123',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 500 })));
const definition: ServerDefinition = {
name: 'failed-refresh',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'refreshable_bearer',
tokenCacheDir: cacheDir,
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
clientIdEnv: 'CLIENT_ID',
clientSecretEnv: 'CLIENT_SECRET',
},
};
await expect(readCachedAccessToken(definition)).rejects.toThrow(
"Failed to refresh cached bearer token for 'failed-refresh'"
);
});
it('rejects expired refreshable bearer tokens without refresh metadata', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bearer-no-refresh-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
hasSpy = true;
const cacheDir = path.join(tmp, 'cache');
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(
path.join(cacheDir, 'tokens.json'),
JSON.stringify({
access_token: 'expired-token',
token_type: 'Bearer',
expires_at: Math.floor(Date.now() / 1000) - 30,
})
);
const definition: ServerDefinition = {
name: 'missing-refresh',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
auth: 'refreshable_bearer',
tokenCacheDir: cacheDir,
};
await expect(readCachedAccessToken(definition)).rejects.toThrow(
"Cached bearer token for 'missing-refresh' is expired"
);
});
it('uses unexpired cached OAuth tokens without refresh', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-current-'));
tempRoots.push(tmp);

View File

@ -54,6 +54,26 @@ describe('maybeEnableOAuth', () => {
const updated = maybeEnableOAuth(def, logger as never);
expect(updated).toBeUndefined();
});
it('does not promote refreshable bearer servers to oauth', () => {
const def: ServerDefinition = {
name: 'refreshable-server',
auth: 'refreshable_bearer',
command: { kind: 'http', url: new URL('https://example.com') },
};
const updated = maybeEnableOAuth(def, logger as never);
expect(updated).toBeUndefined();
});
it('promotes unsupported auth markers consistently with config normalization', () => {
const def: ServerDefinition = {
name: 'unsupported-auth-server',
auth: 'bearer',
command: { kind: 'http', url: new URL('https://example.com') },
};
const updated = maybeEnableOAuth(def, logger as never);
expect(updated?.auth).toBe('oauth');
});
});
describe('isUnauthorizedError helper', () => {

View File

@ -1,5 +1,6 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -211,6 +212,104 @@ describe('createClientContext (HTTP)', () => {
expect(mocks.readCachedAccessToken).toHaveBeenCalledWith(definition, logger);
});
it('preserves explicit Authorization headers for refreshable bearer HTTP servers', async () => {
const definition: ServerDefinition = {
...stubHttpDefinition('https://example.com/secure'),
auth: 'refreshable_bearer',
refresh: { tokenEndpoint: 'https://auth.example.com/token' },
command: {
kind: 'http',
url: new URL('https://example.com/secure'),
headers: { Authorization: 'Bearer configured-token' },
},
};
mocks.readCachedAccessToken.mockRejectedValue(new Error('invalid_grant'));
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
const requestInit = (transport as { _requestInit?: RequestInit })._requestInit;
expect(requestInit?.headers).toEqual({ Authorization: 'Bearer configured-token' });
});
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
expect(mocks.readCachedAccessToken).not.toHaveBeenCalled();
});
it('fails refreshable bearer HTTP configs with no cached token', async () => {
const definition: ServerDefinition = {
...stubHttpDefinition('https://example.com/secure'),
auth: 'refreshable_bearer',
refresh: { tokenEndpoint: 'https://auth.example.com/token' },
};
mocks.readCachedAccessToken.mockResolvedValue(undefined);
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 })).rejects.toThrow(
'no cached access token'
);
});
it('injects refreshed bearer tokens into configured stdio env', async () => {
const definition: ServerDefinition = {
name: 'stdio-refresh',
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
auth: 'refreshable_bearer',
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
accessTokenEnv: 'EXAMPLE_ACCESS_TOKEN',
},
env: { STATIC_ENV: '1' },
};
mocks.readCachedAccessToken.mockResolvedValue('cached-token');
vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => {
expect(transport).toBeInstanceOf(StdioClientTransport);
const params = (transport as { _serverParams?: { env?: Record<string, string> } })._serverParams;
expect(params?.env).toEqual(expect.objectContaining({ STATIC_ENV: '1', EXAMPLE_ACCESS_TOKEN: 'cached-token' }));
});
await createClientContext(definition, logger, clientInfo, {
maxOAuthAttempts: 0,
});
});
it('fails refreshable bearer stdio configs that do not name the token env var', async () => {
const definition: ServerDefinition = {
name: 'stdio-refresh',
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
auth: 'refreshable_bearer',
refresh: {
tokenEndpoint: 'https://auth.example.com/token',
},
};
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 })).rejects.toThrow(
'missing refresh.accessTokenEnv'
);
expect(mocks.readCachedAccessToken).not.toHaveBeenCalled();
});
it('does not promote explicit refreshable bearer HTTP servers to OAuth after 401 errors', async () => {
const definition: ServerDefinition = {
...stubHttpDefinition('https://example.com/secure'),
auth: 'refreshable_bearer',
refresh: { tokenEndpoint: 'https://auth.example.com/token' },
};
mocks.readCachedAccessToken.mockResolvedValue('cached-token');
mocks.connectWithAuth.mockImplementationOnce(async (_client, transport) => {
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
throw new Error('SSE error: Non-200 status code (401)');
});
await expect(createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 1 })).rejects.toThrow(
'Non-200 status code (401)'
);
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(1);
});
it('uses the HTTP/1.1 fetch compatibility path when configured', async () => {
const definition: ServerDefinition = {
...stubHttpDefinition('https://example.com/mcp'),