Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- New `tasks-result <taskId>` 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 <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 <server> --grant client-credentials --client-id <id> --client-secret <secret> [--scope "..."] [--token-endpoint <url>]`. mcpc persists the credentials in the OS keychain and automatically re-issues access tokens on expiry.

### Changed

Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 54 additions & 1 deletion src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof storeKeychainOAuthTokenInfo>[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,
Expand Down
66 changes: 66 additions & 0 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,6 +23,8 @@ export async function login(
clientSecret?: string;
clientMetadataUrl?: string | false;
callbackPort?: number;
grant?: string;
tokenEndpoint?: string;
}
): Promise<void> {
try {
Expand All @@ -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');
}
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
}
Expand Down
25 changes: 25 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port>', 'Loopback port for OAuth callback (default: 13316–13325)')
.option(
'--grant <type>',
'OAuth grant type: "authorization-code" (default, interactive browser) or ' +
'"client-credentials" (non-interactive, machine-to-machine)'
)
.option(
'--token-endpoint <url>',
'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).
Expand All @@ -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 }`')}
`
)
Expand All @@ -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),
});
});
Expand Down
Loading