feat: support refreshable bearer stdio auth
This commit is contained in:
parent
3e06e582ef
commit
7f1e9a8ce0
@ -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)
|
||||
|
||||
@ -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 don’t 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 don’t 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 runtime’s 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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -27,6 +27,7 @@ export type {
|
||||
RawConfig,
|
||||
RawEntry,
|
||||
RawLifecycle,
|
||||
RefreshableBearerOptions,
|
||||
ServerDefinition,
|
||||
ServerLifecycle,
|
||||
ServerLoggingOptions,
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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' },
|
||||
|
||||
6
tests/fixtures/imports/.cursor/mcp.json
vendored
6
tests/fixtures/imports/.cursor/mcp.json
vendored
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user