From c5a661d797f78371ddb16e46eb3f0147d2e88258 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 22:53:01 +0000 Subject: [PATCH 1/2] Add support for OAuth client_credentials grant (machine-to-machine) Implements https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials for non-interactive authentication in CI pipelines, service accounts, and other automation contexts. No browser required. Usage: mcpc login --grant client-credentials \ --client-id --client-secret \ [--scope "..."] [--token-endpoint ] mcpc persists client_id/client_secret in the OS keychain and re-issues access tokens automatically when they expire. --- CHANGELOG.md | 1 + README.md | 26 ++++ src/bridge/index.ts | 55 ++++++++- src/cli/commands/auth.ts | 66 +++++++++++ src/cli/commands/sessions.ts | 3 + src/cli/index.ts | 25 ++++ src/lib/auth/client-credentials-flow.ts | 151 ++++++++++++++++++++++++ src/lib/auth/oauth-token-manager.ts | 100 +++++++++++++--- src/lib/auth/oauth-utils.ts | 58 +++++++++ src/lib/auth/token-refresh.ts | 38 +++++- src/lib/bridge-client.ts | 16 +-- src/lib/bridge-manager.ts | 21 +++- src/lib/types.ts | 21 +++- test/unit/lib/auth/oauth-utils.test.ts | 100 +++++++++++++++- 14 files changed, 648 insertions(+), 33 deletions(-) create mode 100644 src/lib/auth/client-credentials-flow.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c18e14..c569bfde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `tasks-result ` command that fetches the final `CallToolResult` payload of an async task via the MCP `tasks/result` method. Blocks until the task reaches a terminal state, then prints the payload using the same renderer as `tools-call` (`--json` returns the raw result). - Public [Client ID Metadata Document](https://apify.github.io/mcpc/client-metadata.json) hosted via GitHub Pages, giving every `mcpc` installation a consistent client identity on CIMD-capable authorization servers. `mcpc login` now uses this hosted document by default; override with `--client-metadata-url ` or disable with `--no-client-metadata-url` to force Dynamic Client Registration. The OAuth callback uses a fixed loopback port range (13316–13325) to match the registered redirect URIs. +- `mcpc login --grant client-credentials` flag adds support for the [OAuth 2.1 client_credentials grant](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials) for non-interactive machine-to-machine authentication. Usage: `mcpc login --grant client-credentials --client-id --client-secret [--scope "..."] [--token-endpoint ]`. mcpc persists the credentials in the OS keychain and automatically re-issues access tokens on expiry. ### Changed diff --git a/README.md b/README.md index 32f24a0b..4207386c 100644 --- a/README.md +++ b/README.md @@ -517,6 +517,32 @@ mcpc login mcp.example.com --no-client-metadata-url See the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-registration-approaches) for details on each approach and the format of Client ID Metadata Documents. +### Machine-to-machine authentication (client_credentials grant) + +For non-interactive environments (CI pipelines, service accounts, automation), +`mcpc` supports the [OAuth 2.1 client_credentials grant](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials). +No browser is involved — `mcpc` exchanges a pre-issued client ID and secret for +an access token, and re-issues the token automatically when it expires. + +```bash +# Login using the client_credentials grant (no browser) +mcpc login mcp.example.com --grant client-credentials \ + --client-id my-service --client-secret "$SERVICE_SECRET" + +# Optionally request specific scopes and pin the token endpoint +mcpc login mcp.example.com --grant client-credentials \ + --client-id my-service --client-secret "$SERVICE_SECRET" \ + --scope "tools:read" \ + --token-endpoint https://auth.example.com/oauth/token + +# Use the resulting profile like any other +mcpc connect mcp.example.com @svc +mcpc @svc tools-list +``` + +Client ID and secret are stored in the OS keychain. When the access token +expires, `mcpc` re-runs the `client_credentials` request transparently. + ### Authentication precedence When multiple authentication methods are available, `mcpc` uses this precedence order: diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 0e731575..c1dcf538 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -148,13 +148,66 @@ class BridgeProcess { setAuthCredentials(credentials: AuthCredentials): void { logger.info(`Received auth credentials for profile: ${credentials.profileName}`); logger.debug(` serverUrl: ${credentials.serverUrl}`); + logger.debug(` grantType: ${credentials.grantType ?? 'refresh_token'}`); logger.debug(` refreshToken: ${credentials.refreshToken ? 'present' : 'MISSING'}`); logger.debug(` accessToken: ${credentials.accessToken ? 'present' : 'MISSING'}`); logger.debug(` clientId: ${credentials.clientId ? 'present' : 'MISSING'}`); + logger.debug(` clientSecret: ${credentials.clientSecret ? 'present' : 'MISSING'}`); logger.debug(` headers: ${credentials.headers ? Object.keys(credentials.headers).length : 0}`); + // Set up OAuth token manager for client_credentials grant + if ( + credentials.grantType === 'client_credentials' && + credentials.clientId && + credentials.clientSecret + ) { + this.tokenManager = new OAuthTokenManager({ + serverUrl: credentials.serverUrl, + profileName: credentials.profileName, + grantType: 'client_credentials', + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + ...(credentials.accessToken && { accessToken: credentials.accessToken }), + ...(credentials.accessTokenExpiresAt !== undefined && { + accessTokenExpiresAt: credentials.accessTokenExpiresAt, + }), + ...(credentials.scope && { scope: credentials.scope }), + ...(credentials.tokenEndpoint && { tokenEndpoint: credentials.tokenEndpoint }), + // Persist re-issued tokens to keychain so other sessions can reuse them + onTokenRefresh: async (tokens) => { + logger.debug('client_credentials re-issue detected, persisting tokens to keychain'); + const tokenInfo: Parameters[2] = { + accessToken: tokens.access_token, + tokenType: tokens.token_type || 'Bearer', + }; + if (tokens.expires_in !== undefined) { + tokenInfo.expiresIn = tokens.expires_in; + tokenInfo.expiresAt = Math.floor(Date.now() / 1000) + tokens.expires_in; + } + if (tokens.scope !== undefined) { + tokenInfo.scope = tokens.scope; + } + await storeKeychainOAuthTokenInfo( + credentials.serverUrl, + credentials.profileName, + tokenInfo + ); + await updateAuthProfileRefreshedAt(credentials.serverUrl, credentials.profileName); + logger.debug('client_credentials tokens persisted to keychain'); + }, + }); + logger.debug('OAuth token manager initialized (client_credentials grant)'); + + this.authProvider = new OAuthProvider({ + serverUrl: credentials.serverUrl, + profileName: credentials.profileName, + tokenManager: this.tokenManager, + clientId: credentials.clientId, + }); + logger.debug('OAuthProvider created for SDK transport (runtime mode, client_credentials)'); + } // Set up OAuth token manager if refresh token and client ID are provided - if (credentials.refreshToken && credentials.clientId) { + else if (credentials.refreshToken && credentials.clientId) { this.tokenManager = new OAuthTokenManager({ serverUrl: credentials.serverUrl, profileName: credentials.profileName, diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 911dd86d..e5a9558e 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -6,6 +6,7 @@ import { formatSuccess, formatError, formatOutput, formatInfo, formatWarning } f import type { CommandOptions } from '../../lib/types.js'; import { deleteAuthProfiles } from '../../lib/auth/profiles.js'; import { performOAuthFlow } from '../../lib/auth/oauth-flow.js'; +import { performClientCredentialsFlow } from '../../lib/auth/client-credentials-flow.js'; import { normalizeServerUrl, validateProfileName } from '../../lib/utils.js'; import chalk from 'chalk'; import { DEFAULT_AUTH_PROFILE, DEFAULT_CLIENT_METADATA_URL } from '../../lib/auth/oauth-utils.js'; @@ -22,6 +23,8 @@ export async function login( clientSecret?: string; clientMetadataUrl?: string | false; callbackPort?: number; + grant?: string; + tokenEndpoint?: string; } ): Promise { try { @@ -30,6 +33,65 @@ export async function login( validateProfileName(profileName); + // Normalize grant type — accept both hyphen and underscore variants. + const grantRaw = (options.grant ?? 'authorization-code').toLowerCase().replace(/_/g, '-'); + if (grantRaw !== 'authorization-code' && grantRaw !== 'client-credentials') { + throw new Error( + `Invalid --grant "${options.grant}". Expected "authorization-code" or "client-credentials".` + ); + } + const useClientCredentials = grantRaw === 'client-credentials'; + + if (useClientCredentials) { + if (!options.clientId || !options.clientSecret) { + throw new Error('--grant client-credentials requires both --client-id and --client-secret'); + } + if (options.clientMetadataUrl) { + throw new Error( + '--client-metadata-url is not supported with --grant client-credentials ' + + '(CIMD applies to interactive authorization-code flow only)' + ); + } + + if (options.outputMode === 'human') { + console.log( + formatInfo(`Starting OAuth client_credentials authentication for ${normalizedUrl}`) + ); + console.log(formatInfo(`Profile: ${chalk.magenta(profileName)}`)); + } + + const result = await performClientCredentialsFlow({ + serverUrl: normalizedUrl, + profileName, + clientId: options.clientId, + clientSecret: options.clientSecret, + ...(options.scope !== undefined && { scope: options.scope }), + ...(options.tokenEndpoint !== undefined && { tokenEndpoint: options.tokenEndpoint }), + }); + + if (options.outputMode === 'human') { + console.log(formatSuccess('Authentication successful!')); + console.log(formatInfo(`Profile ${chalk.magenta(profileName)} saved`)); + + if (result.profile.scopes && result.profile.scopes.length > 0) { + console.log(formatInfo(`Scopes: ${result.profile.scopes.join(', ')}`)); + } + } else { + console.log( + formatOutput( + { + profile: profileName, + serverUrl: normalizedUrl, + scopes: result.profile.scopes, + grant: 'client-credentials', + }, + 'json' + ) + ); + } + return; + } + if (options.clientSecret && !options.clientId) { throw new Error('--client-secret requires --client-id'); } @@ -41,6 +103,10 @@ export async function login( ); } + if (options.tokenEndpoint) { + throw new Error('--token-endpoint is only supported with --grant client-credentials'); + } + // Resolve the effective CIMD URL: // - --client-id → no CIMD (pre-registered client) // - --no-client-metadata-url → explicitly disabled (force DCR) diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index e3e52ad4..27c73947 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -650,6 +650,9 @@ export async function listSessionsAndAuthProfiles(options: { const timeLabel = profile.refreshedAt ? 'refreshed' : 'created'; let line = ` ${hostStr} / ${nameStr}`; + if (profile.authType === 'oauth-client-credentials') { + line += chalk.dim(' [client-credentials]'); + } if (userStr) { line += chalk.dim(` (${userStr})`); } diff --git a/src/cli/index.ts b/src/cli/index.ts index aeb40d11..4b369fa5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -590,9 +590,25 @@ ${jsonHelp('`InitializeResult` object extended with `toolNames` and `_mcpc` meta ) .option('--no-client-metadata-url', 'Disable CIMD; force DCR on CIMD-capable servers') .option('--callback-port ', 'Loopback port for OAuth callback (default: 13316–13325)') + .option( + '--grant ', + 'OAuth grant type: "authorization-code" (default, interactive browser) or ' + + '"client-credentials" (non-interactive, machine-to-machine)' + ) + .option( + '--token-endpoint ', + 'OAuth token endpoint URL (client-credentials grant only; auto-discovered if omitted)' + ) .addHelpText( 'after', ` +${chalk.bold('OAuth grant types:')} + + --grant authorization-code (default) Interactive browser login with PKCE. + --grant client-credentials Non-interactive machine-to-machine login. + Requires --client-id and --client-secret. + See https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials + ${chalk.bold('OAuth client registration approaches:')} 1. Pre-registration: --client-id (and optionally --client-secret). @@ -607,6 +623,13 @@ ${chalk.bold('OAuth client registration approaches:')} See https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization +${chalk.bold('Examples:')} + + mcpc login mcp.example.com + mcpc login mcp.example.com --scope "tools:read tools:write" + mcpc login mcp.example.com --grant client-credentials \\ + --client-id my-service --client-secret $SERVICE_SECRET + ${jsonHelp('Interactive prompts are written to stderr, stdout contains a clean JSON object', '`{ profile, serverUrl, scopes }`')} ` ) @@ -623,6 +646,8 @@ ${jsonHelp('Interactive prompts are written to stderr, stdout contains a clean J clientSecret: opts.clientSecret, clientMetadataUrl: opts.clientMetadataUrl, ...(opts.callbackPort ? { callbackPort: parseInt(opts.callbackPort as string, 10) } : {}), + grant: opts.grant, + tokenEndpoint: opts.tokenEndpoint, ...getOptionsFromCommand(command), }); }); diff --git a/src/lib/auth/client-credentials-flow.ts b/src/lib/auth/client-credentials-flow.ts new file mode 100644 index 00000000..e9325e0d --- /dev/null +++ b/src/lib/auth/client-credentials-flow.ts @@ -0,0 +1,151 @@ +/** + * OAuth 2.1 client_credentials grant flow for MCP. + * Implements https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials + * + * This is a non-interactive flow for machine-to-machine authentication: + * the client authenticates directly with client_id/client_secret and receives + * an access token in a single POST to the token endpoint. No browser, no user. + */ + +import { URL } from 'url'; +import { normalizeServerUrl } from '../utils.js'; +import { ClientError } from '../errors.js'; +import { createLogger } from '../logger.js'; +import { + storeKeychainOAuthClientInfo, + storeKeychainOAuthTokenInfo, + removeKeychainOAuthTokenInfo, + type OAuthTokenInfo, +} from './keychain.js'; +import { getAuthProfile, saveAuthProfile } from './profiles.js'; +import { discoverTokenEndpoint, requestClientCredentialsToken } from './oauth-utils.js'; +import type { AuthProfile } from '../types.js'; + +const logger = createLogger('client-credentials-flow'); + +export interface ClientCredentialsFlowResult { + profile: AuthProfile; + success: boolean; +} + +export interface ClientCredentialsFlowOptions { + serverUrl: string; + profileName: string; + clientId: string; + clientSecret: string; + scope?: string; + /** + * Optional pre-supplied token endpoint. When omitted, mcpc discovers it via + * OAuth/OIDC metadata at /.well-known/oauth-authorization-server (or openid-configuration). + */ + tokenEndpoint?: string; +} + +/** + * Perform the OAuth client_credentials flow: + * 1. Discover (or use supplied) token endpoint + * 2. POST grant_type=client_credentials with the supplied client_id / client_secret + * 3. Persist the client credentials and resulting access token to the OS keychain + * 4. Write (or update) the auth profile metadata + */ +export async function performClientCredentialsFlow( + options: ClientCredentialsFlowOptions +): Promise { + const normalizedServerUrl = normalizeServerUrl(options.serverUrl); + const { profileName, clientId, clientSecret, scope } = options; + + logger.debug( + `Starting client_credentials flow for ${normalizedServerUrl} (profile: ${profileName})` + ); + + // Warn about OAuth over plain HTTP (except localhost). Client credentials + // travel in the request body, so HTTPS is strongly recommended in production. + const parsedUrl = new URL(normalizedServerUrl); + if ( + parsedUrl.protocol === 'http:' && + parsedUrl.hostname !== 'localhost' && + parsedUrl.hostname !== '127.0.0.1' + ) { + console.warn( + '\nWarning: OAuth client_credentials over plain HTTP is insecure. ' + + 'Only use for local development.\n' + ); + } + + // Resolve token endpoint (pre-supplied or discovered) + let tokenEndpoint = options.tokenEndpoint; + if (!tokenEndpoint) { + logger.debug(`Discovering token endpoint for ${normalizedServerUrl}...`); + tokenEndpoint = await discoverTokenEndpoint(normalizedServerUrl); + if (!tokenEndpoint) { + throw new ClientError( + `Could not discover OAuth token endpoint for ${normalizedServerUrl}. ` + + `Pass --token-endpoint to specify it explicitly.` + ); + } + logger.debug(`Discovered token endpoint: ${tokenEndpoint}`); + } + + // Request the access token + const tokenResponse = await requestClientCredentialsToken( + tokenEndpoint, + clientId, + clientSecret, + scope + ); + + // Persist client credentials (used for re-issuing tokens on expiry). + // Replace any existing tokens from a previous (different-grant) login. + await storeKeychainOAuthClientInfo(normalizedServerUrl, profileName, { + clientId, + clientSecret, + }); + await removeKeychainOAuthTokenInfo(normalizedServerUrl, profileName); + + const tokenInfo: OAuthTokenInfo = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type || 'Bearer', + }; + if (tokenResponse.expires_in !== undefined) { + tokenInfo.expiresIn = tokenResponse.expires_in; + tokenInfo.expiresAt = Math.floor(Date.now() / 1000) + tokenResponse.expires_in; + } + if (tokenResponse.scope !== undefined) { + tokenInfo.scope = tokenResponse.scope; + } + // Most client_credentials responses omit refresh_token; honour it if present. + if (tokenResponse.refresh_token !== undefined) { + tokenInfo.refreshToken = tokenResponse.refresh_token; + } + await storeKeychainOAuthTokenInfo(normalizedServerUrl, profileName, tokenInfo); + + // Create/update profile metadata + const now = new Date().toISOString(); + const existing = await getAuthProfile(normalizedServerUrl, profileName); + + const profile: AuthProfile = existing + ? { ...existing, authType: 'oauth-client-credentials', authenticatedAt: now } + : { + name: profileName, + serverUrl: normalizedServerUrl, + authType: 'oauth-client-credentials', + oauthIssuer: '', + createdAt: now, + authenticatedAt: now, + }; + + // Record the token endpoint so runtime token re-issuance can skip discovery. + profile.tokenEndpoint = tokenEndpoint; + + // Prefer scopes granted by the server; fall back to the scopes requested by the caller. + if (tokenResponse.scope) { + profile.scopes = tokenResponse.scope.split(' ').filter(Boolean); + } else if (scope) { + profile.scopes = scope.split(' ').filter(Boolean); + } + + await saveAuthProfile(profile); + logger.debug('client_credentials flow completed successfully'); + + return { profile, success: true }; +} diff --git a/src/lib/auth/oauth-token-manager.ts b/src/lib/auth/oauth-token-manager.ts index da6aca5a..022d72de 100644 --- a/src/lib/auth/oauth-token-manager.ts +++ b/src/lib/auth/oauth-token-manager.ts @@ -8,6 +8,8 @@ import { createLogger } from '../logger.js'; import { AuthError } from '../errors.js'; import { discoverAndRefreshToken, + discoverTokenEndpoint, + requestClientCredentialsToken, createReauthError, type OAuthTokenResponse, } from './oauth-utils.js'; @@ -41,10 +43,22 @@ export type OnBeforeRefreshCallback = () => Promise< export interface OAuthTokenManagerOptions { serverUrl: string; profileName: string; + /** + * Grant used to obtain new access tokens when the current one expires. + * Defaults to 'refresh_token'. 'client_credentials' re-issues tokens using + * clientId/clientSecret instead of a refresh token. + */ + grantType?: 'refresh_token' | 'client_credentials'; /** OAuth client ID (required for public clients) */ clientId: string; - /** Initial refresh token */ - refreshToken: string; + /** OAuth client secret (required for client_credentials grant) */ + clientSecret?: string; + /** Refresh token (required for refresh_token grant) */ + refreshToken?: string; + /** OAuth scope to request on re-issuance (client_credentials grant only) */ + scope?: string; + /** Pre-known token endpoint; when absent, it is discovered from server metadata */ + tokenEndpoint?: string; /** Initial access token (optional - will be refreshed if not provided or expired) */ accessToken?: string; /** Unix timestamp when access token expires */ @@ -61,8 +75,12 @@ export interface OAuthTokenManagerOptions { export class OAuthTokenManager { private serverUrl: string; private profileName: string; + private grantType: 'refresh_token' | 'client_credentials'; private clientId: string; + private clientSecret?: string; private refreshToken: string; + private scope?: string; + private tokenEndpoint?: string; private accessToken: string | null = null; private accessTokenExpiresAt: number | null = null; // unix timestamp private onTokenRefresh?: OnTokenRefreshCallback; @@ -71,8 +89,18 @@ export class OAuthTokenManager { constructor(options: OAuthTokenManagerOptions) { this.serverUrl = options.serverUrl; this.profileName = options.profileName; + this.grantType = options.grantType ?? 'refresh_token'; this.clientId = options.clientId; - this.refreshToken = options.refreshToken; + if (options.clientSecret) { + this.clientSecret = options.clientSecret; + } + this.refreshToken = options.refreshToken ?? ''; + if (options.scope) { + this.scope = options.scope; + } + if (options.tokenEndpoint) { + this.tokenEndpoint = options.tokenEndpoint; + } this.accessToken = options.accessToken ?? null; this.accessTokenExpiresAt = options.accessTokenExpiresAt ?? null; if (options.onTokenRefresh) { @@ -81,6 +109,14 @@ export class OAuthTokenManager { if (options.onBeforeRefresh) { this.onBeforeRefresh = options.onBeforeRefresh; } + + // Validate required fields per grant type + if (this.grantType === 'refresh_token' && !this.refreshToken) { + throw new Error('OAuthTokenManager: refresh_token grant requires refreshToken'); + } + if (this.grantType === 'client_credentials' && !this.clientSecret) { + throw new Error('OAuthTokenManager: client_credentials grant requires clientSecret'); + } } /** @@ -138,22 +174,48 @@ export class OAuthTokenManager { } } - if (!this.refreshToken) { - throw createReauthError( - this.serverUrl, - this.profileName, - `No refresh token available for profile ${this.profileName}` - ); - } - - logger.debug(`Refreshing access token for profile: ${this.profileName}`); + logger.debug( + `Refreshing access token for profile: ${this.profileName} (grant: ${this.grantType})` + ); try { - const tokenResponse = await discoverAndRefreshToken( - this.serverUrl, - this.refreshToken, - this.clientId - ); + let tokenResponse: OAuthTokenResponse; + if (this.grantType === 'client_credentials') { + if (!this.clientSecret) { + throw createReauthError( + this.serverUrl, + this.profileName, + `No client secret available for profile ${this.profileName}` + ); + } + // Resolve token endpoint (cache after first discovery) + if (!this.tokenEndpoint) { + const discovered = await discoverTokenEndpoint(this.serverUrl); + if (!discovered) { + throw new AuthError(`Could not discover OAuth token endpoint for ${this.serverUrl}`); + } + this.tokenEndpoint = discovered; + } + tokenResponse = await requestClientCredentialsToken( + this.tokenEndpoint, + this.clientId, + this.clientSecret, + this.scope + ); + } else { + if (!this.refreshToken) { + throw createReauthError( + this.serverUrl, + this.profileName, + `No refresh token available for profile ${this.profileName}` + ); + } + tokenResponse = await discoverAndRefreshToken( + this.serverUrl, + this.refreshToken, + this.clientId + ); + } // Store new access token this.accessToken = tokenResponse.access_token; @@ -162,8 +224,8 @@ export class OAuthTokenManager { const expiresIn = tokenResponse.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS; this.accessTokenExpiresAt = Math.floor(Date.now() / 1000) + expiresIn; - // Update refresh token if a new one was provided (token rotation) - if (tokenResponse.refresh_token) { + // Update refresh token if a new one was provided (token rotation; refresh_token grant only) + if (tokenResponse.refresh_token && this.grantType === 'refresh_token') { this.refreshToken = tokenResponse.refresh_token; logger.debug('Received new refresh token (token rotation)'); } diff --git a/src/lib/auth/oauth-utils.ts b/src/lib/auth/oauth-utils.ts index b760c5ab..f0fd7f92 100644 --- a/src/lib/auth/oauth-utils.ts +++ b/src/lib/auth/oauth-utils.ts @@ -147,6 +147,64 @@ export async function discoverAndRefreshToken( return refreshAccessToken(tokenEndpoint, refreshToken, clientId); } +/** + * Request an access token using the OAuth 2.1 client_credentials grant. + * Per https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials, + * this flow is intended for machine-to-machine auth without user interaction. + * + * Client credentials are sent in the form body (client_secret_post) rather than + * HTTP Basic, because both are permitted by RFC 6749 and the form-body variant + * is simpler to reason about for MCP servers. + * + * @param tokenEndpoint - The OAuth token endpoint URL + * @param clientId - The OAuth client ID + * @param clientSecret - The OAuth client secret (required - confidential client) + * @param scope - Optional space-separated OAuth scopes + * @returns The token response from the server + * @throws AuthError if the request fails + */ +export async function requestClientCredentialsToken( + tokenEndpoint: string, + clientId: string, + clientSecret: string, + scope?: string +): Promise { + logger.debug(`Requesting client_credentials token at: ${tokenEndpoint}`); + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }); + if (scope) { + params.set('scope', scope); + } + + const response = await proxyFetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: params.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`client_credentials token request failed: ${response.status} ${errorText}`); + + if (response.status === 400 || response.status === 401) { + throw new AuthError('Client credentials are invalid or the server rejected the grant'); + } + + throw new AuthError( + `Failed to obtain client_credentials token: ${response.status} ${response.statusText}` + ); + } + + return (await response.json()) as OAuthTokenResponse; +} + /** * Create an AuthError with a re-authentication hint * Use this for errors that require the user to re-authenticate diff --git a/src/lib/auth/token-refresh.ts b/src/lib/auth/token-refresh.ts index 40c90a16..1440f4f1 100644 --- a/src/lib/auth/token-refresh.ts +++ b/src/lib/auth/token-refresh.ts @@ -88,6 +88,42 @@ export async function getValidAccessTokenFromKeychain( return undefined; } + // Load client info from keychain (needed for token refresh / re-issuance) + const clientInfo = await readKeychainOAuthClientInfo(serverUrl, profileName); + + // client_credentials grant: re-issue access tokens using stored client credentials + if (profile.authType === 'oauth-client-credentials') { + if (!clientInfo?.clientId || !clientInfo.clientSecret) { + throw createReauthError( + serverUrl, + profileName, + 'Client credentials not found in keychain (required for client_credentials grant)' + ); + } + + // If the current access token is still valid, return it as-is without re-issuing. + if (tokens.expiresAt && Date.now() / 1000 <= tokens.expiresAt - 60) { + logger.debug(`Using auth profile: ${profileName}`); + return tokens.accessToken; + } + + const tokenManager = new OAuthTokenManager({ + serverUrl, + profileName, + grantType: 'client_credentials', + clientId: clientInfo.clientId, + clientSecret: clientInfo.clientSecret, + accessToken: tokens.accessToken, + ...(tokens.expiresAt !== undefined && { accessTokenExpiresAt: tokens.expiresAt }), + ...(profile.tokenEndpoint && { tokenEndpoint: profile.tokenEndpoint }), + ...(profile.scopes && profile.scopes.length > 0 && { scope: profile.scopes.join(' ') }), + onTokenRefresh: createPersistenceCallback(serverUrl, profileName, profile, tokens), + }); + + logger.debug(`Using auth profile: ${profileName} (client_credentials)`); + return await tokenManager.getValidAccessToken(); + } + // If no refresh token, check if current token is still valid if (!tokens.refreshToken) { if (tokens.expiresAt && Date.now() / 1000 > tokens.expiresAt - 60) { @@ -102,8 +138,6 @@ export async function getValidAccessTokenFromKeychain( return tokens.accessToken; } - // Load client info from keychain (needed for token refresh) - const clientInfo = await readKeychainOAuthClientInfo(serverUrl, profileName); if (!clientInfo?.clientId) { throw createReauthError( serverUrl, diff --git a/src/lib/bridge-client.ts b/src/lib/bridge-client.ts index d0d4b3da..f8c12b91 100644 --- a/src/lib/bridge-client.ts +++ b/src/lib/bridge-client.ts @@ -15,7 +15,13 @@ import { connect, type Socket } from 'net'; import { EventEmitter } from 'events'; -import type { IpcMessage, NotificationData, TaskUpdate, X402WalletCredentials } from './types.js'; +import type { + IpcMessage, + NotificationData, + TaskUpdate, + X402WalletCredentials, + AuthCredentials, +} from './types.js'; import { createLogger } from './logger.js'; import { NetworkError, ClientError, ServerError, AuthError } from './errors.js'; import { generateRequestId } from './utils.js'; @@ -260,13 +266,7 @@ export class BridgeClient extends EventEmitter { /** * Send auth credentials to bridge (one-way, no response expected) */ - sendAuthCredentials(credentials: { - serverUrl: string; - profileName: string; - refreshToken?: string; - accessToken?: string; - headers?: Record; - }): void { + sendAuthCredentials(credentials: AuthCredentials): void { this.send({ type: 'set-auth-credentials', authCredentials: credentials, diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index af794438..6d7333a9 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -472,14 +472,33 @@ async function sendAuthCredentialsToBridge( credentials.accessToken = tokens.accessToken; logger.debug(`Found OAuth access token for profile ${profileName}`); } + if (tokens.expiresAt !== undefined) { + credentials.accessTokenExpiresAt = tokens.expiresAt; + } } - // Load client info from keychain (needed for token refresh) + // Load client info from keychain (needed for token refresh / re-issuance) const clientInfo = await readKeychainOAuthClientInfo(profile.serverUrl, profileName); if (clientInfo?.clientId) { credentials.clientId = clientInfo.clientId; logger.debug(`Found OAuth client ID for profile ${profileName}`); } + + // client_credentials profiles need the client secret + token endpoint + // to re-issue access tokens on expiry. + if (profile.authType === 'oauth-client-credentials') { + credentials.grantType = 'client_credentials'; + if (clientInfo?.clientSecret) { + credentials.clientSecret = clientInfo.clientSecret; + logger.debug(`Found OAuth client secret for profile ${profileName}`); + } + if (profile.tokenEndpoint) { + credentials.tokenEndpoint = profile.tokenEndpoint; + } + if (profile.scopes && profile.scopes.length > 0) { + credentials.scope = profile.scopes.join(' '); + } + } } } diff --git a/src/lib/types.ts b/src/lib/types.ts index a37627e8..70d02a93 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -185,10 +185,20 @@ export interface SessionsStorage { export interface AuthProfile { name: string; serverUrl: string; - authType: 'oauth'; + /** + * 'oauth' - Authorization code grant with PKCE (interactive browser login) + * 'oauth-client-credentials' - OAuth 2.1 client_credentials grant (machine-to-machine, + * per https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials) + */ + authType: 'oauth' | 'oauth-client-credentials'; // OAuth metadata oauthIssuer: string; scopes?: string[]; + /** + * OAuth token endpoint captured at login time. Only set for client_credentials profiles + * so that re-issuing an expired access token does not require re-running discovery. + */ + tokenEndpoint?: string; // User info (from OIDC id_token, if available) userEmail?: string; userName?: string; @@ -225,11 +235,20 @@ export type IpcMessageType = export interface AuthCredentials { serverUrl: string; profileName: string; + /** + * Grant type for OAuth re-issuance. Defaults to 'refresh_token' when omitted. + * 'client_credentials' triggers machine-to-machine re-issuance using clientId/clientSecret. + */ + grantType?: 'refresh_token' | 'client_credentials'; // OAuth credentials (for refresh flow) clientId?: string; + clientSecret?: string; // Required for client_credentials grant (confidential client) refreshToken?: string; // OAuth access token (used as static Bearer token when no refresh token available) accessToken?: string; + accessTokenExpiresAt?: number; // Unix timestamp (for client_credentials re-issuance) + scope?: string; // Scope to request on re-issuance (client_credentials only) + tokenEndpoint?: string; // Cached token endpoint (skips discovery) // HTTP headers (from --header flags, stored in keychain) headers?: Record; } diff --git a/test/unit/lib/auth/oauth-utils.test.ts b/test/unit/lib/auth/oauth-utils.test.ts index c4250e2f..e443e26e 100644 --- a/test/unit/lib/auth/oauth-utils.test.ts +++ b/test/unit/lib/auth/oauth-utils.test.ts @@ -2,7 +2,11 @@ * Unit tests for OAuth utility functions */ -import { discoverTokenEndpoint } from '../../../../src/lib/auth/oauth-utils.js'; +import { + discoverTokenEndpoint, + requestClientCredentialsToken, +} from '../../../../src/lib/auth/oauth-utils.js'; +import { AuthError } from '../../../../src/lib/errors.js'; import * as proxyModule from '../../../../src/lib/proxy.js'; // Helper to create a mock fetch Response @@ -10,6 +14,17 @@ function mockResponse(body: object | null, ok = true): Response { return { ok, json: () => Promise.resolve(body), + text: () => Promise.resolve(body ? JSON.stringify(body) : ''), + } as unknown as Response; +} + +function mockResponseWithStatus(body: object | null, status: number): Response { + return { + ok: false, + status, + statusText: 'Error', + json: () => Promise.resolve(body), + text: () => Promise.resolve(body ? JSON.stringify(body) : ''), } as unknown as Response; } @@ -147,3 +162,86 @@ describe('discoverTokenEndpoint', () => { ]); }); }); + +describe('requestClientCredentialsToken', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(proxyModule, 'proxyFetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('POSTs grant_type=client_credentials with client_id and client_secret', async () => { + let capturedBody = ''; + let capturedHeaders: Record | undefined; + fetchSpy.mockImplementation((_url: string, init: RequestInit) => { + capturedBody = init.body as string; + capturedHeaders = init.headers as Record; + return Promise.resolve( + mockResponse({ + access_token: 'abc123', + token_type: 'Bearer', + expires_in: 3600, + }) + ); + }); + + const result = await requestClientCredentialsToken( + 'https://example.com/token', + 'my-client', + 'my-secret' + ); + + expect(result.access_token).toBe('abc123'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(3600); + + const params = new URLSearchParams(capturedBody); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('client_id')).toBe('my-client'); + expect(params.get('client_secret')).toBe('my-secret'); + expect(params.get('scope')).toBeNull(); + + expect(capturedHeaders?.['Content-Type']).toBe('application/x-www-form-urlencoded'); + }); + + it('includes scope in the request when provided', async () => { + let capturedBody = ''; + fetchSpy.mockImplementation((_url: string, init: RequestInit) => { + capturedBody = init.body as string; + return Promise.resolve(mockResponse({ access_token: 'x', token_type: 'Bearer' })); + }); + + await requestClientCredentialsToken( + 'https://example.com/token', + 'cid', + 'csecret', + 'tools:read tools:write' + ); + + const params = new URLSearchParams(capturedBody); + expect(params.get('scope')).toBe('tools:read tools:write'); + }); + + it('throws AuthError with a clear message on 401', async () => { + fetchSpy.mockResolvedValue(mockResponseWithStatus({ error: 'invalid_client' }, 401)); + + await expect( + requestClientCredentialsToken('https://example.com/token', 'cid', 'bad') + ).rejects.toThrow(AuthError); + await expect( + requestClientCredentialsToken('https://example.com/token', 'cid', 'bad') + ).rejects.toThrow(/Client credentials are invalid|rejected/); + }); + + it('throws AuthError on unexpected 5xx', async () => { + fetchSpy.mockResolvedValue(mockResponseWithStatus({ error: 'server_error' }, 500)); + + await expect( + requestClientCredentialsToken('https://example.com/token', 'cid', 'sec') + ).rejects.toThrow(AuthError); + }); +}); From 5272a19d570808fea041281382d75b320b945ed6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 09:43:09 +0000 Subject: [PATCH 2/2] Add e2e test for OAuth client_credentials grant - Add mock OAuth endpoints to test server: /.well-known/oauth-authorization-server for discovery and /token for the client_credentials grant. Configurable via OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN env vars. - Auth check now validates the actual token value (not just presence of Bearer prefix). - New test suite (14 tests): login flow, profile metadata, session usage (tools-list, tools-call), --scope, --json output, validation errors, wrong credentials, credential leak check in --verbose, --help output. https://claude.ai/code/session_01E96muBqt36zJHubmGFUWfB --- test/e2e/server/index.ts | 66 +++++- .../suites/basic/client-credentials.test.sh | 191 ++++++++++++++++++ 2 files changed, 256 insertions(+), 1 deletion(-) create mode 100755 test/e2e/suites/basic/client-credentials.test.sh diff --git a/test/e2e/server/index.ts b/test/e2e/server/index.ts index 8e6f2ef8..6865681f 100644 --- a/test/e2e/server/index.ts +++ b/test/e2e/server/index.ts @@ -7,6 +7,9 @@ * PAGINATION_SIZE - items per page, 0 = no pagination (default: 0) * LATENCY_MS - artificial latency in ms (default: 0) * REQUIRE_AUTH - require Authorization header (default: false) + * OAUTH_CLIENT_ID - expected client_id for client_credentials grant (default: "test-client") + * OAUTH_CLIENT_SECRET - expected client_secret for client_credentials grant (default: "test-secret") + * OAUTH_TOKEN - token returned by /token and accepted by auth check (default: "test-token") * NO_TOOLS - disable tools capability (default: false) * NO_RESOURCES - disable resources capability (default: false) * NO_PROMPTS - disable prompts capability (default: false) @@ -45,6 +48,9 @@ const PORT = parseInt(process.env.PORT || '13456', 10); const PAGINATION_SIZE = parseInt(process.env.PAGINATION_SIZE || '0', 10); const LATENCY_MS = parseInt(process.env.LATENCY_MS || '0', 10); const REQUIRE_AUTH = process.env.REQUIRE_AUTH === 'true'; +const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID || 'test-client'; +const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || 'test-secret'; +const OAUTH_TOKEN = process.env.OAUTH_TOKEN || 'test-token'; const NO_TOOLS = process.env.NO_TOOLS === 'true'; const NO_RESOURCES = process.env.NO_RESOURCES === 'true'; const NO_PROMPTS = process.env.NO_PROMPTS === 'true'; @@ -638,10 +644,64 @@ async function main() { } } + // OAuth discovery endpoint (accessible without auth) + if (url.pathname === '/.well-known/oauth-authorization-server' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + issuer: `http://localhost:${PORT}`, + token_endpoint: `http://localhost:${PORT}/token`, + grant_types_supported: ['client_credentials'], + token_endpoint_auth_methods_supported: ['client_secret_post'], + }) + ); + return; + } + + // OAuth token endpoint (accessible without auth) + if (url.pathname === '/token' && req.method === 'POST') { + // Read form-encoded body + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = Buffer.concat(chunks).toString(); + const params = new URLSearchParams(body); + + const grantType = params.get('grant_type'); + const clientId = params.get('client_id'); + const clientSecret = params.get('client_secret'); + + if (grantType !== 'client_credentials') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unsupported_grant_type' })); + return; + } + + if (clientId !== OAUTH_CLIENT_ID || clientSecret !== OAUTH_CLIENT_SECRET) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid_client' })); + return; + } + + const tokenResponse: Record = { + access_token: OAUTH_TOKEN, + token_type: 'Bearer', + expires_in: 3600, + }; + const scope = params.get('scope'); + if (scope) { + tokenResponse.scope = scope; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(tokenResponse)); + return; + } + // Auth check if (REQUIRE_AUTH) { const auth = req.headers.authorization; - if (!auth || !auth.startsWith('Bearer ')) { + if (!auth || auth !== `Bearer ${OAUTH_TOKEN}`) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; @@ -722,6 +782,10 @@ async function main() { ); console.log(` Latency: ${LATENCY_MS}ms`); console.log(` Auth required: ${REQUIRE_AUTH}`); + if (REQUIRE_AUTH) { + console.log(` OAuth client_id: ${OAUTH_CLIENT_ID}`); + console.log(` OAuth token: ${OAUTH_TOKEN}`); + } if (NO_TOOLS) console.log(` Tools: DISABLED`); if (NO_RESOURCES) console.log(` Resources: DISABLED`); if (NO_PROMPTS) console.log(` Prompts: DISABLED`); diff --git a/test/e2e/suites/basic/client-credentials.test.sh b/test/e2e/suites/basic/client-credentials.test.sh new file mode 100755 index 00000000..8d792519 --- /dev/null +++ b/test/e2e/suites/basic/client-credentials.test.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Test: OAuth client_credentials grant (machine-to-machine authentication) +# +# Verifies that: +# 1. mcpc login --grant client-credentials discovers the token endpoint and obtains a token +# 2. The resulting profile has authType: oauth-client-credentials +# 3. Sessions using the profile can authenticate and call tools +# 4. --json mode returns the expected structure +# 5. Validation rejects bad flag combinations +# 6. Credentials don't leak in verbose output + +source "$(dirname "$0")/../../lib/framework.sh" +test_init "basic/client-credentials" --isolated + +# Start test server with auth required (enables /token and /.well-known endpoints) +start_test_server REQUIRE_AUTH=true + +# ============================================================================= +# Test: login with client_credentials grant +# ============================================================================= + +test_case "login --grant client-credentials succeeds" +run_mcpc login "$TEST_SERVER_URL" \ + --grant client-credentials \ + --client-id test-client \ + --client-secret test-secret +assert_success +assert_contains "$STDOUT" "Authentication successful" +test_pass + +test_case "login --grant client-credentials creates oauth-client-credentials profile" +profiles_file="$MCPC_HOME_DIR/profiles.json" +if [[ ! -f "$profiles_file" ]]; then + test_fail "profiles.json not found" + exit 1 +fi +auth_type=$(jq -r '.profiles["localhost:'"$TEST_SERVER_PORT"'"].default.authType // empty' "$profiles_file") +if [[ "$auth_type" != "oauth-client-credentials" ]]; then + test_fail "Expected authType 'oauth-client-credentials', got '$auth_type'" + exit 1 +fi +# Verify token endpoint was cached +token_ep=$(jq -r '.profiles["localhost:'"$TEST_SERVER_PORT"'"].default.tokenEndpoint // empty' "$profiles_file") +if [[ -z "$token_ep" ]]; then + test_fail "tokenEndpoint should be cached in profile" + exit 1 +fi +test_pass + +test_case "login --grant client-credentials JSON output" +run_mcpc login "$TEST_SERVER_URL" \ + --grant client-credentials \ + --client-id test-client \ + --client-secret test-secret \ + --json +assert_success +assert_json_valid "$STDOUT" +grant=$(echo "$STDOUT" | jq -r '.grant // empty') +if [[ "$grant" != "client-credentials" ]]; then + test_fail "Expected grant 'client-credentials' in JSON output, got '$grant'" + exit 1 +fi +test_pass + +# ============================================================================= +# Test: connect and use session with client_credentials profile +# ============================================================================= + +test_case "session using client_credentials profile can list tools" +SESSION=$(session_name "cc-sess") +run_mcpc connect "$TEST_SERVER_URL" "$SESSION" +assert_success +_SESSIONS_CREATED+=("$SESSION") + +# Wait for bridge to be ready +wait_for "$MCPC $SESSION ping >/dev/null 2>&1" + +run_mcpc "$SESSION" tools-list +assert_success +assert_contains "$STDOUT" "echo" +test_pass + +test_case "session using client_credentials profile can call tools" +run_mcpc "$SESSION" tools-call echo message:=hello +assert_success +assert_contains "$STDOUT" "hello" +test_pass + +# ============================================================================= +# Test: --grant client-credentials with scope +# ============================================================================= + +test_case "login --grant client-credentials with --scope" +run_mcpc login "$TEST_SERVER_URL" \ + --grant client-credentials \ + --client-id test-client \ + --client-secret test-secret \ + --scope "tools:read tools:write" \ + --profile scoped +assert_success +# Check profile has scopes +scopes=$(jq -r '.profiles["localhost:'"$TEST_SERVER_PORT"'"].scoped.scopes // [] | join(" ")' "$profiles_file") +if [[ "$scopes" != "tools:read tools:write" ]]; then + test_fail "Expected scopes 'tools:read tools:write', got '$scopes'" + exit 1 +fi +test_pass + +# ============================================================================= +# Test: validation errors +# ============================================================================= + +test_case "login --grant client-credentials without --client-id fails" +run_mcpc login "$TEST_SERVER_URL" --grant client-credentials --client-secret sec +assert_failure +assert_contains "$STDERR" "requires both --client-id and --client-secret" +test_pass + +test_case "login --grant client-credentials without --client-secret fails" +run_mcpc login "$TEST_SERVER_URL" --grant client-credentials --client-id cid +assert_failure +assert_contains "$STDERR" "requires both --client-id and --client-secret" +test_pass + +test_case "login --grant invalid is rejected" +run_mcpc login "$TEST_SERVER_URL" --grant foo +assert_failure +assert_contains "$STDERR" "Invalid --grant" +test_pass + +test_case "login --token-endpoint without --grant client-credentials is rejected" +run_mcpc login "$TEST_SERVER_URL" --token-endpoint https://example.com/token +assert_failure +assert_contains "$STDERR" "--token-endpoint is only supported with --grant client-credentials" +test_pass + +test_case "login --grant client-credentials with --client-metadata-url is rejected" +run_mcpc login "$TEST_SERVER_URL" \ + --grant client-credentials \ + --client-id cid \ + --client-secret sec \ + --client-metadata-url https://example.com/meta.json +assert_failure +assert_contains "$STDERR" "not supported with --grant client-credentials" +test_pass + +# ============================================================================= +# Test: wrong credentials fail +# ============================================================================= + +test_case "login --grant client-credentials with wrong secret fails" +run_mcpc login "$TEST_SERVER_URL" \ + --grant client-credentials \ + --client-id test-client \ + --client-secret wrong-secret \ + --profile bad +assert_failure +assert_contains "$STDERR" "invalid" +test_pass + +# ============================================================================= +# Test: credentials don't leak in verbose output +# ============================================================================= + +test_case "client secret does not appear in verbose output" +run_mcpc login "$TEST_SERVER_URL" \ + --grant client-credentials \ + --client-id test-client \ + --client-secret test-secret \ + --verbose +assert_success +# The secret should never appear in stderr (verbose logs go to stderr) +if echo "$STDERR" | grep -q "test-secret"; then + test_fail "Client secret leaked in verbose output" + exit 1 +fi +test_pass + +# ============================================================================= +# Test: --help documents --grant flag +# ============================================================================= + +test_case "login --help documents --grant client-credentials" +run_mcpc help login +assert_success +assert_contains "$STDOUT" "--grant" +assert_contains "$STDOUT" "client-credentials" +assert_contains "$STDOUT" "--token-endpoint" +test_pass + +test_done