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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [Unreleased]

### Added

- **Non-Blocking OAuth Flow for AI Agents**
- `AuthRequiredError` now includes authorization URL for immediate action
- Callback server runs in background (5 min timeout) - CLI returns immediately
- `mcp-cli` (list all) shows working servers + auth URLs for servers needing login
- Random port by default to avoid conflicts with multiple OAuth servers
- Updated README with sequence diagram and AI agent guidance

## [0.3.0] - 2026-01-22

### Added
Expand Down
136 changes: 136 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,141 @@ Restrict which tools are available from a server using `allowedTools` and `disab
"disabledTools": ["delete_file"]
```

### OAuth Authentication

For HTTP MCP servers that require OAuth (like Notion, GitHub, etc.), the CLI handles authentication automatically:

```mermaid
sequenceDiagram
participant AI as AI Agent
participant CLI as mcp-cli
participant Server as Callback Server
participant User as User
AI->>CLI: mcp-cli
CLI->>CLI: Detect some servers need auth
CLI->>Server: Spawn background callback server
CLI-->>AI: List working servers + auth URL for others
AI->>User: "Please authenticate via this link"
User->>Server: Completes OAuth in browser
Server->>Server: Saves tokens to ~/.mcp-cli/tokens/
User->>AI: "Done"
AI->>CLI: mcp-cli (retry)
CLI-->>AI: All servers now listed
```

**Configuration:**

Most OAuth-enabled servers work with just a URL - the CLI handles dynamic client registration and PKCE automatically:

```json
{
"mcpServers": {
"notion": {
"url": "https://mcp.notion.com/mcp"
}
}
}
```

**OAuth Configuration Options:**

For servers requiring custom OAuth settings, use the `oauth` object:

```json
{
"mcpServers": {
"my-server": {
"url": "https://api.example.com/mcp",
"oauth": {
"callbackPort": 8095,
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"scope": "read write",
"grantType": "authorization_code"
}
}
}
}
```

| Option | Description | Default |
|--------|-------------|---------|
| `callbackPort` | Port for OAuth callback server | Random |
| `clientId` | Pre-registered OAuth client ID | (dynamic registration) |
| `clientSecret` | OAuth client secret (for confidential clients) | (none) |
| `scope` | OAuth scopes to request | (none) |
| `grantType` | `authorization_code` or `client_credentials` | `authorization_code` |

**Examples by scenario:**

1. **Public server with dynamic registration** (most common):
```json
{
"notion": { "url": "https://mcp.notion.com/mcp" }
}
```

2. **Server with pre-registered public client:**
```json
{
"github": {
"url": "https://mcp.github.com/mcp",
"oauth": { "clientId": "abc123" }
}
}
```

3. **Confidential client (client_credentials):**
```json
{
"internal-api": {
"url": "https://api.internal.com/mcp",
"oauth": {
"clientId": "service-account",
"clientSecret": "secret-key",
"grantType": "client_credentials"
}
}
}
```

**Token Storage:**

Tokens are persisted in `~/.mcp-cli/` with secure file permissions (0600):

```
~/.mcp-cli/
├── tokens/server-name.json # Access/refresh tokens
├── clients/server-name.json # Dynamic client registration
└── verifiers/server-name.txt # PKCE verifiers (temporary)
```

**Clearing cached tokens:**

```bash
rm ~/.mcp-cli/tokens/notion.json # Re-authenticate on next call
rm -rf ~/.mcp-cli/ # Clear all OAuth data
```

**For AI Agents (non-interactive mode):**

When listing multiple servers, working servers show their tools while auth-required servers display an actionable auth URL:

```
deepwiki
• read_wiki_structure
• read_wiki_contents
• ask_question

notion
• <error: [AUTH REQUIRED] notion
Authenticate at: https://mcp.notion.com/authorize?...
Callback server running in background (5 min timeout).
After authenticating, confirm "done" and retry this command.>
```

The callback server stays running in the background. After the user authenticates and confirms, retry the command to see all servers.

### Config Resolution

The CLI searches for configuration in this order:
Expand All @@ -360,6 +495,7 @@ The CLI searches for configuration in this order:
| `MCP_STRICT_ENV` | Error on missing `${VAR}` in config | `true` |
| `MCP_NO_DAEMON` | Disable connection caching (force fresh connections) | `false` |
| `MCP_DAEMON_TIMEOUT` | Idle timeout for cached connections (seconds) | `60` |
| `MCP_CLI_HOME` | Override OAuth storage directory (default: `$HOME`) | (none) |

## Using with AI Agents

Expand Down
109 changes: 105 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* MCP Client - Connection management for MCP servers
*/

import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
Expand All @@ -25,6 +26,8 @@ import {
cleanupOrphanedDaemons,
getDaemonConnection,
} from './daemon-client.js';
import { formatCliError, oauthFlowError } from './errors.js';
import { McpCliOAuthProvider } from './oauth/index.js';
import { VERSION } from './version.js';

// Re-export config utilities for convenience
Expand All @@ -49,6 +52,30 @@ export interface McpConnection {
isDaemon: boolean;
}

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type ConnectOptions = Record<string, never>;

/**
* Error thrown when authentication is required but interactive auth is disabled
*/
export class AuthRequiredError extends Error {
public readonly serverName: string;
public readonly authUrl: string | undefined;

constructor(serverName: string, authUrl?: string) {
const message = authUrl
? `[AUTH REQUIRED] ${serverName}
Authenticate at: ${authUrl}
Callback server running in background (5 min timeout).
After authenticating, confirm "done" and retry this command.`
: `Server "${serverName}" requires authentication. Run 'mcp-cli info ${serverName}' to start authentication.`;
super(message);
this.name = 'AuthRequiredError';
this.serverName = serverName;
this.authUrl = authUrl;
}
}

export interface ServerInfo {
name: string;
version?: string;
Expand Down Expand Up @@ -95,6 +122,11 @@ function getRetryConfig(): RetryConfig {
* Uses error codes when available, falls back to message matching
*/
export function isTransientError(error: Error): boolean {
// AuthRequiredError should never be retried - it indicates callback server is running
if (error.name === 'AuthRequiredError') {
return false;
}

// Check error code first (more reliable than message matching)
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code) {
Expand Down Expand Up @@ -217,10 +249,12 @@ export async function safeClose(close: () => Promise<void>): Promise<void> {
/**
* Connect to an MCP server with retry logic
* Captures stderr from stdio servers to include in error messages
* Handles OAuth flow for HTTP servers with authentication
*/
export async function connectToServer(
serverName: string,
config: ServerConfig,
options: ConnectOptions = {},
): Promise<ConnectedClient> {
// Collect stderr for better error messages
const stderrChunks: string[] = [];
Expand All @@ -237,9 +271,29 @@ export async function connectToServer(
);

let transport: StdioClientTransport | StreamableHTTPClientTransport;
let authProvider: McpCliOAuthProvider | undefined;

if (isHttpServer(config)) {
transport = createHttpTransport(config);
const result = createHttpTransport(serverName, config);
transport = result.transport;
authProvider = result.authProvider;

// Pre-start callback server for authorization_code flow to determine actual port
// This ensures the redirect_uri in the authorization request uses the correct port
const oauthConfig = (config as HttpServerConfig).oauth;
if (
!oauthConfig?.grantType ||
oauthConfig.grantType === 'authorization_code'
) {
try {
await authProvider.preStartCallbackServer();
} catch (error) {
debug(
`Failed to pre-start callback server: ${(error as Error).message}`,
);
// Continue anyway - server will start during redirectToAuthorization
}
}
} else {
transport = createStdioTransport(config);

Expand All @@ -259,6 +313,17 @@ export async function connectToServer(
try {
await client.connect(transport);
} catch (error) {
if (isOAuthNeeded(error as Error) && authProvider) {
debug(`OAuth authorization required for ${serverName}`);

// Always throw AuthRequiredError with auth URL - CLI is for AI agents
// Callback server continues running in background for 5 min
throw new AuthRequiredError(
serverName,
authProvider.capturedAuthUrl ?? undefined,
);
}

// Enhance error with captured stderr
const stderrOutput = stderrChunks.join('').trim();
if (stderrOutput) {
Expand Down Expand Up @@ -289,17 +354,52 @@ export async function connectToServer(

/**
* Create HTTP transport for remote servers
* Always creates an auth provider to handle OAuth flows (server-initiated or explicit)
*/
function createHttpTransport(
serverName: string,
config: HttpServerConfig,
): StreamableHTTPClientTransport {
): {
transport: StreamableHTTPClientTransport;
authProvider: McpCliOAuthProvider;
} {
const url = new URL(config.url);

return new StreamableHTTPClientTransport(url, {
// Always create OAuth provider for HTTP servers to handle server-initiated OAuth
// If explicit oauth config exists, use it; otherwise use defaults
const oauthConfig = config.oauth || {};
const authProvider = new McpCliOAuthProvider(
serverName,
config.url,
oauthConfig,
);
debug(
`OAuth provider created for ${serverName} (${oauthConfig.grantType || 'authorization_code'})`,
);

const transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: {
headers: config.headers,
},
});

return { transport, authProvider };
}

/**
* Check if an error indicates OAuth authorization is needed
*/
function isOAuthNeeded(error: Error): boolean {
// Check for UnauthorizedError from SDK
if (error instanceof UnauthorizedError) {
return true;
}
// Check for invalid_token error in message (common OAuth error response)
if (error.message.includes('invalid_token')) {
return true;
}
return false;
}

/**
Expand Down Expand Up @@ -390,6 +490,7 @@ export async function callTool(
export async function getConnection(
serverName: string,
config: ServerConfig,
options: ConnectOptions = {},
): Promise<McpConnection> {
// Clean up any orphaned daemons on first call
await cleanupOrphanedDaemons();
Expand Down Expand Up @@ -437,7 +538,7 @@ export async function getConnection(

// Fall back to direct connection
debug(`Using direct connection for ${serverName}`);
const { client, close } = await connectToServer(serverName, config);
const { client, close } = await connectToServer(serverName, config, options);

return {
async listTools(): Promise<ToolInfo[]> {
Expand Down
13 changes: 13 additions & 0 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import {
AuthRequiredError,
type McpConnection,
type ToolInfo,
debug,
Expand Down Expand Up @@ -62,6 +63,7 @@ async function processWithConcurrency<T, R>(

/**
* Fetch tools from a single server (uses daemon if enabled)
* Never opens browser - CLI is used by AI agents, returns auth URL instead
*/
async function fetchServerTools(
serverName: string,
Expand All @@ -77,6 +79,17 @@ async function fetchServerTools(
debug(`${serverName}: loaded ${tools.length} tools`);
return { name: serverName, tools, instructions };
} catch (error) {
// AuthRequiredError is caught and returned as a response, not retried
// This ensures the callback server stays running and the auth URL is shown
if (error instanceof AuthRequiredError) {
debug(`${serverName}: auth required - ${error.message}`);
return {
name: serverName,
tools: [],
error: error.message,
};
}

const errorMsg = (error as Error).message;
debug(`${serverName}: connection failed - ${errorMsg}`);
return {
Expand Down
Loading