From 6b5a2b6592d60208bd39ae1b054d484c90ed4e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matya=CC=81s=CC=8C=20Strelec?= Date: Mon, 18 May 2026 12:14:45 +0200 Subject: [PATCH 1/2] feat: add OAuth 2.0 PKCE authentication support Adds JIRA_AUTH_TYPE=oauth mode alongside existing basic auth. Implements full PKCE flow with browser-based authorization, automatic token refresh, and persistent token storage at ~/.mcp-jira-stdio/tokens.json. Co-authored-by: Claude --- .env.example | 20 +++- src/index.ts | 16 ++- src/types/common.ts | 5 +- src/utils/jira-auth.ts | 225 ++++++++++++++++++++++++------------ src/utils/oauth.ts | 254 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 84 deletions(-) create mode 100644 src/utils/oauth.ts diff --git a/.env.example b/.env.example index a7673e7..7309712 100644 --- a/.env.example +++ b/.env.example @@ -5,15 +5,23 @@ # Example: https://yourcompany.atlassian.net JIRA_BASE_URL=https://yourcompany.atlassian.net -# Your Jira account email -# Example: your.email@company.com -JIRA_EMAIL=your.email@company.com +# Authentication type: 'basic' (default) or 'oauth' +# Use 'oauth' if your organization has disabled API token access +JIRA_AUTH_TYPE=basic -# Your Jira API token -# Generate at: https://id.atlassian.com/manage-profile/security/api-tokens -# Click "Create API token" and copy the generated token here +# --- Basic auth (default) --- +# Your Jira account email and API token +# Generate token at: https://id.atlassian.com/manage-profile/security/api-tokens +JIRA_EMAIL=your.email@company.com JIRA_API_TOKEN=your_api_token_here +# --- OAuth 2.0 (use when JIRA_AUTH_TYPE=oauth) --- +# Create an OAuth 2.0 app at https://developer.atlassian.com +# Add callback URL: http://localhost:7789/callback +# Required scopes: read:jira-user, read:jira-work, write:jira-work, offline_access +# JIRA_OAUTH_CLIENT_ID=your_client_id +# JIRA_OAUTH_CLIENT_SECRET=your_client_secret + # Node environment (development/production) # Set to 'development' to enable connection testing and debug messages # Set to 'production' or leave empty for production mode (no connection test, minimal logging) diff --git a/src/index.ts b/src/index.ts index ccd9e64..3fd672d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -238,20 +238,26 @@ async function main() { // Show auth info on startup when configured (skip in DRY_RUN) if (!isDryRun) { - const hasAuthVars = Boolean( - process.env.JIRA_BASE_URL && process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN - ); + const authType = process.env.JIRA_AUTH_TYPE || 'basic'; + const hasAuthVars = + authType === 'oauth' + ? Boolean(process.env.JIRA_BASE_URL && process.env.JIRA_OAUTH_CLIENT_ID && process.env.JIRA_OAUTH_CLIENT_SECRET) + : Boolean(process.env.JIRA_BASE_URL && process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN); if (hasAuthVars) { try { const auth = validateAuth(); - console.error(`๐Ÿ” Authenticated as: ${auth.email}`); + if (authType === 'oauth') { + console.error(`๐Ÿ” Auth: OAuth 2.0 (token will be obtained on first request)`); + } else { + console.error(`๐Ÿ” Authenticated as: ${auth.email}`); + } console.error(`๐ŸŒ Jira instance: ${auth.baseUrl}`); } catch (error: any) { console.error('โš ๏ธ Invalid Jira credentials:', error.message); } } else { - console.error('โš ๏ธ Jira env vars missing (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)'); + console.error('โš ๏ธ Jira env vars missing (JIRA_BASE_URL + JIRA_EMAIL/JIRA_API_TOKEN or JIRA_OAUTH_CLIENT_ID/JIRA_OAUTH_CLIENT_SECRET)'); } } else { console.error('๐Ÿงช DRY_RUN=1 set โ€” skipping Jira auth check'); diff --git a/src/types/common.ts b/src/types/common.ts index c697273..b69ccea 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,8 +2,9 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; export interface JiraAuthConfig { baseUrl: string; - email: string; - apiToken: string; + email?: string; + apiToken?: string; + authType: 'basic' | 'bearer' | 'oauth'; } export interface JiraApiResponse { diff --git a/src/utils/jira-auth.ts b/src/utils/jira-auth.ts index 12ff48e..4ccadd2 100644 --- a/src/utils/jira-auth.ts +++ b/src/utils/jira-auth.ts @@ -3,29 +3,76 @@ import FormData from 'form-data'; import { JiraAuthConfig, RetryConfig } from '../types/common.js'; import { JIRA_CONFIG, ERROR_MESSAGES } from '../config/constants.js'; import { createLogger } from './logger.js'; -// keep client simple to match test expectations +import { getOAuthToken, OAuthConfig } from './oauth.js'; let jiraClient: AxiosInstance | null = null; let jiraMultipartClient: AxiosInstance | null = null; const log = createLogger('jira-auth'); export function validateAuth(): JiraAuthConfig { + const authType = (process.env.JIRA_AUTH_TYPE || 'basic') as 'basic' | 'bearer' | 'oauth'; const baseUrl = process.env.JIRA_BASE_URL; + + if (!baseUrl) { + throw new Error(ERROR_MESSAGES.AUTH_REQUIRED); + } + + const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + + if (authType === 'oauth') { + const clientId = process.env.JIRA_OAUTH_CLIENT_ID; + const clientSecret = process.env.JIRA_OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error( + 'JIRA_OAUTH_CLIENT_ID and JIRA_OAUTH_CLIENT_SECRET are required for OAuth authentication', + ); + } + return { baseUrl: normalizedBaseUrl, authType }; + } + const email = process.env.JIRA_EMAIL; const apiToken = process.env.JIRA_API_TOKEN; - if (!baseUrl || !email || !apiToken) { + if (!email || !apiToken) { throw new Error(ERROR_MESSAGES.AUTH_REQUIRED); } - // Normalize base URL - const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + return { baseUrl: normalizedBaseUrl, email, apiToken, authType }; +} - return { - baseUrl: normalizedBaseUrl, - email, - apiToken, - }; +function addInterceptors(client: AxiosInstance, label: string): void { + client.interceptors.request.use( + (config) => { + log.debug(`Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`); + return config; + }, + (error) => { + log.error(`${label} request error:`, error); + return Promise.reject(error); + }, + ); + + client.interceptors.response.use( + (response) => { + log.debug(`Response: ${response.status} ${response.config.url}`); + return response; + }, + (error) => { + log.error(`Jira API error: ${error.response?.status} ${error.config?.url}`); + + if (error.response?.status === 401) { + throw new Error(ERROR_MESSAGES.INVALID_CREDENTIALS); + } else if (error.response?.status === 404) { + throw new Error('Resource not found or insufficient permissions'); + } else if (error.response?.status === 429) { + throw new Error(ERROR_MESSAGES.RATE_LIMIT_EXCEEDED); + } else if (!error.response) { + throw new Error(ERROR_MESSAGES.NETWORK_ERROR); + } + + return Promise.reject(error); + }, + ); } export function getAuthenticatedClient(): AxiosInstance { @@ -33,34 +80,56 @@ export function getAuthenticatedClient(): AxiosInstance { return jiraClient; } - const auth = validateAuth(); + const authType = (process.env.JIRA_AUTH_TYPE || 'basic') as string; - jiraClient = axios.create({ - baseURL: `${auth.baseUrl}${JIRA_CONFIG.BASE_PATH}`, - timeout: JIRA_CONFIG.DEFAULT_TIMEOUT, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - auth: { - username: auth.email, - password: auth.apiToken, - }, - }); + if (authType === 'oauth') { + jiraClient = axios.create({ + timeout: JIRA_CONFIG.DEFAULT_TIMEOUT, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); - // Add request interceptor for logging - jiraClient.interceptors.request.use( - (config) => { + // Async interceptor: injects OAuth token and correct base URL before each request + jiraClient.interceptors.request.use(async (config) => { + const oauthCfg: OAuthConfig = { + clientId: process.env.JIRA_OAUTH_CLIENT_ID!, + clientSecret: process.env.JIRA_OAUTH_CLIENT_SECRET!, + siteUrl: process.env.JIRA_BASE_URL!, + }; + const { token, cloudId } = await getOAuthToken(oauthCfg); + config.baseURL = `https://api.atlassian.com/ex/jira/${cloudId}${JIRA_CONFIG.BASE_PATH}`; + config.headers.Authorization = `Bearer ${token}`; log.debug(`Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`); return config; - }, - (error) => { - log.error('Request error:', error); - return Promise.reject(error); - } - ); + }); + } else { + const auth = validateAuth(); + jiraClient = axios.create({ + baseURL: `${auth.baseUrl}${JIRA_CONFIG.BASE_PATH}`, + timeout: JIRA_CONFIG.DEFAULT_TIMEOUT, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + auth: { + username: auth.email!, + password: auth.apiToken!, + }, + }); + jiraClient.interceptors.request.use( + (config) => { + log.debug(`Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`); + return config; + }, + (error) => { + log.error('Request error:', error); + return Promise.reject(error); + }, + ); + } - // Add response interceptor for error handling jiraClient.interceptors.response.use( (response) => { log.debug(`Response: ${response.status} ${response.config.url}`); @@ -80,7 +149,7 @@ export function getAuthenticatedClient(): AxiosInstance { } return Promise.reject(error); - } + }, ); return jiraClient; @@ -91,7 +160,7 @@ export async function makeJiraRequest( retryConfig: RetryConfig = { maxRetries: JIRA_CONFIG.MAX_RETRIES, retryDelay: JIRA_CONFIG.RETRY_DELAY, - } + }, ): Promise { const client = getAuthenticatedClient(); let lastError: any; @@ -103,7 +172,6 @@ export async function makeJiraRequest( } catch (error: any) { lastError = error; - // Don't retry on authentication or client errors if ( error.response?.status === 401 || error.response?.status === 403 || @@ -115,17 +183,14 @@ export async function makeJiraRequest( break; } - // Don't retry if custom condition fails if (retryConfig.retryCondition && !retryConfig.retryCondition(error)) { break; } - // Don't retry on last attempt if (attempt === retryConfig.maxRetries) { break; } - // Compute backoff respecting Retry-After if present const retryAfterHeader = error.response?.headers?.['retry-after']; let delayMs = 0; if (retryAfterHeader) { @@ -136,9 +201,7 @@ export async function makeJiraRequest( const jitter = Math.floor(Math.random() * (retryConfig.retryDelay / 2)); delayMs = base + jitter; } - log.warn( - `Retrying request in ${delayMs}ms (attempt ${attempt + 1}/${retryConfig.maxRetries})` - ); + log.warn(`Retrying request in ${delayMs}ms (attempt ${attempt + 1}/${retryConfig.maxRetries})`); await new Promise((resolve) => setTimeout(resolve, delayMs)); } } @@ -164,36 +227,59 @@ export function getMultipartClient(): AxiosInstance { return jiraMultipartClient; } - const auth = validateAuth(); + const authType = (process.env.JIRA_AUTH_TYPE || 'basic') as string; - jiraMultipartClient = axios.create({ - baseURL: `${auth.baseUrl}${JIRA_CONFIG.BASE_PATH}`, - timeout: JIRA_CONFIG.DEFAULT_TIMEOUT, - headers: { - Accept: 'application/json', - 'X-Atlassian-Token': 'no-check', - }, - auth: { - username: auth.email, - password: auth.apiToken, - }, - }); + if (authType === 'oauth') { + jiraMultipartClient = axios.create({ + timeout: JIRA_CONFIG.DEFAULT_TIMEOUT, + headers: { + Accept: 'application/json', + 'X-Atlassian-Token': 'no-check', + }, + }); - // Add request interceptor for logging - jiraMultipartClient.interceptors.request.use( - (config) => { + jiraMultipartClient.interceptors.request.use(async (config) => { + const oauthCfg: OAuthConfig = { + clientId: process.env.JIRA_OAUTH_CLIENT_ID!, + clientSecret: process.env.JIRA_OAUTH_CLIENT_SECRET!, + siteUrl: process.env.JIRA_BASE_URL!, + }; + const { token, cloudId } = await getOAuthToken(oauthCfg); + config.baseURL = `https://api.atlassian.com/ex/jira/${cloudId}${JIRA_CONFIG.BASE_PATH}`; + config.headers.Authorization = `Bearer ${token}`; log.debug( - `Multipart Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}` + `Multipart Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`, ); return config; - }, - (error) => { - log.error('Multipart request error:', error); - return Promise.reject(error); - } - ); + }); + } else { + const auth = validateAuth(); + jiraMultipartClient = axios.create({ + baseURL: `${auth.baseUrl}${JIRA_CONFIG.BASE_PATH}`, + timeout: JIRA_CONFIG.DEFAULT_TIMEOUT, + headers: { + Accept: 'application/json', + 'X-Atlassian-Token': 'no-check', + }, + auth: { + username: auth.email!, + password: auth.apiToken!, + }, + }); + jiraMultipartClient.interceptors.request.use( + (config) => { + log.debug( + `Multipart Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`, + ); + return config; + }, + (error) => { + log.error('Multipart request error:', error); + return Promise.reject(error); + }, + ); + } - // Add response interceptor for error handling jiraMultipartClient.interceptors.response.use( (response) => { log.debug(`Multipart Response: ${response.status} ${response.config.url}`); @@ -213,7 +299,7 @@ export function getMultipartClient(): AxiosInstance { } return Promise.reject(error); - } + }, ); return jiraMultipartClient; @@ -225,7 +311,7 @@ export async function makeMultipartRequest( retryConfig: RetryConfig = { maxRetries: JIRA_CONFIG.MAX_RETRIES, retryDelay: JIRA_CONFIG.RETRY_DELAY, - } + }, ): Promise { const client = getMultipartClient(); let lastError: any; @@ -241,7 +327,6 @@ export async function makeMultipartRequest( } catch (error: any) { lastError = error; - // Don't retry on authentication or client errors if ( error.response?.status === 401 || error.response?.status === 403 || @@ -253,12 +338,10 @@ export async function makeMultipartRequest( break; } - // Don't retry on last attempt if (attempt === retryConfig.maxRetries) { break; } - // Compute backoff respecting Retry-After if present const retryAfterHeader = error.response?.headers?.['retry-after']; let delayMs = 0; if (retryAfterHeader) { @@ -270,7 +353,7 @@ export async function makeMultipartRequest( delayMs = base + jitter; } log.warn( - `Retrying multipart request in ${delayMs}ms (attempt ${attempt + 1}/${retryConfig.maxRetries})` + `Retrying multipart request in ${delayMs}ms (attempt ${attempt + 1}/${retryConfig.maxRetries})`, ); await new Promise((resolve) => setTimeout(resolve, delayMs)); } diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts new file mode 100644 index 0000000..040dd6a --- /dev/null +++ b/src/utils/oauth.ts @@ -0,0 +1,254 @@ +import { createServer } from 'http'; +import { createHash, randomBytes } from 'crypto'; +import { exec } from 'child_process'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import axios from 'axios'; + +const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize'; +const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'; +const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'; + +export const OAUTH_CALLBACK_PORT = 7789; +const REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`; + +const TOKEN_DIR = join(homedir(), '.mcp-jira-stdio'); +const TOKEN_FILE = join(TOKEN_DIR, 'tokens.json'); + +const SCOPES = 'read:jira-user read:jira-work write:jira-work offline_access'; + +export interface OAuthConfig { + clientId: string; + clientSecret: string; + siteUrl: string; +} + +interface StoredTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + cloudId: string; +} + +let memCache: { token: string; cloudId: string; expiresAt: number } | null = null; + +function loadStore(): Record { + try { + return JSON.parse(readFileSync(TOKEN_FILE, 'utf-8')); + } catch { + return {}; + } +} + +function saveStore(store: Record): void { + mkdirSync(TOKEN_DIR, { recursive: true }); + writeFileSync(TOKEN_FILE, JSON.stringify(store, null, 2), { mode: 0o600 }); +} + +function pkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +async function fetchCloudId(accessToken: string, siteUrl: string): Promise { + const res = await axios.get>( + ATLASSIAN_RESOURCES_URL, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + const normalize = (u: string) => u.replace(/\/$/, '').toLowerCase(); + const match = res.data.find((r) => normalize(r.url) === normalize(siteUrl)); + + if (!match) { + const available = res.data.map((r) => r.url).join(', '); + throw new Error( + `Jira site "${siteUrl}" not found in accessible resources.\n` + + `Available sites: ${available}\n` + + `Set JIRA_BASE_URL to one of the available sites.`, + ); + } + + return match.id; +} + +async function exchangeCode( + clientId: string, + clientSecret: string, + code: string, + verifier: string, +): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { + const res = await axios.post(ATLASSIAN_TOKEN_URL, { + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + return { + accessToken: res.data.access_token, + refreshToken: res.data.refresh_token, + expiresIn: res.data.expires_in, + }; +} + +async function doRefresh( + clientId: string, + clientSecret: string, + refreshToken: string, +): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { + const res = await axios.post(ATLASSIAN_TOKEN_URL, { + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + }); + return { + accessToken: res.data.access_token, + refreshToken: res.data.refresh_token ?? refreshToken, + expiresIn: res.data.expires_in, + }; +} + +async function authorize( + clientId: string, + clientSecret: string, + siteUrl: string, +): Promise { + const { verifier, challenge } = pkce(); + const state = randomBytes(16).toString('hex'); + + const authUrl = new URL(ATLASSIAN_AUTH_URL); + authUrl.searchParams.set('audience', 'api.atlassian.com'); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('scope', SCOPES); + authUrl.searchParams.set('redirect_uri', REDIRECT_URI); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('prompt', 'consent'); + authUrl.searchParams.set('code_challenge', challenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + const code = await new Promise((resolve, reject) => { + const server = createServer((req, res) => { + clearTimeout(timeout); + server.close(); + + const url = new URL(req.url!, `http://localhost:${OAUTH_CALLBACK_PORT}`); + const error = url.searchParams.get('error'); + const returnedCode = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + + if (error || returnedState !== state || !returnedCode) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Authorization failed.

You can close this tab.

'); + reject(new Error(error ? `OAuth error: ${error}` : 'Invalid OAuth callback')); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab.

'); + resolve(returnedCode); + }); + + const timeout = setTimeout(() => { + server.closeAllConnections?.(); + server.close(); + reject(new Error('OAuth timed out after 5 minutes')); + }, 5 * 60 * 1000); + + server.on('error', (err: NodeJS.ErrnoException) => { + clearTimeout(timeout); + if (err.code === 'EADDRINUSE') { + reject( + new Error( + `Port ${OAUTH_CALLBACK_PORT} is already in use. Stop the other process and try again.`, + ), + ); + } else { + reject(err); + } + }); + + server.listen(OAUTH_CALLBACK_PORT, () => { + const url = authUrl.toString(); + console.error('\n[Jira MCP] Authorization required.'); + console.error( + `[Jira MCP] Opening browser... If it doesn't open, visit:\n\n ${url}\n`, + ); + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "" "${url}"` + : `xdg-open "${url}"`; + exec(cmd); + }); + }); + + const { accessToken, refreshToken, expiresIn } = await exchangeCode( + clientId, + clientSecret, + code, + verifier, + ); + const cloudId = await fetchCloudId(accessToken, siteUrl); + console.error('[Jira MCP] Authorization successful!\n'); + + return { + accessToken, + refreshToken, + expiresAt: Date.now() + expiresIn * 1000, + cloudId, + }; +} + +export async function getOAuthToken( + cfg: OAuthConfig, +): Promise<{ token: string; cloudId: string }> { + const BUFFER_MS = 5 * 60 * 1000; + + if (memCache && memCache.expiresAt > Date.now() + BUFFER_MS) { + return { token: memCache.token, cloudId: memCache.cloudId }; + } + + const store = loadStore(); + const stored = store[cfg.siteUrl]; + + if (stored) { + if (stored.expiresAt > Date.now() + BUFFER_MS) { + memCache = { token: stored.accessToken, cloudId: stored.cloudId, expiresAt: stored.expiresAt }; + return { token: stored.accessToken, cloudId: stored.cloudId }; + } + + if (stored.refreshToken) { + try { + const { accessToken, refreshToken, expiresIn } = await doRefresh( + cfg.clientId, + cfg.clientSecret, + stored.refreshToken, + ); + const updated: StoredTokens = { + accessToken, + refreshToken, + expiresAt: Date.now() + expiresIn * 1000, + cloudId: stored.cloudId, + }; + store[cfg.siteUrl] = updated; + saveStore(store); + memCache = { token: updated.accessToken, cloudId: updated.cloudId, expiresAt: updated.expiresAt }; + return { token: updated.accessToken, cloudId: updated.cloudId }; + } catch { + // fall through to fresh authorization + } + } + } + + const tokens = await authorize(cfg.clientId, cfg.clientSecret, cfg.siteUrl); + store[cfg.siteUrl] = tokens; + saveStore(store); + memCache = { token: tokens.accessToken, cloudId: tokens.cloudId, expiresAt: tokens.expiresAt }; + return { token: tokens.accessToken, cloudId: tokens.cloudId }; +} From 09cd7baf6bb7a783393a874340a183a9796f3712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matya=CC=81s=CC=8C=20Strelec?= Date: Mon, 18 May 2026 12:19:17 +0200 Subject: [PATCH 2/2] docs: document OAuth 2.0 authentication in README and DOCKER Add setup instructions, environment variable table, troubleshooting entries, and Docker usage examples for the new JIRA_AUTH_TYPE=oauth mode. Co-authored-by: Claude --- DOCKER.md | 70 ++++++++++++++++++++++++--- README.md | 142 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 189 insertions(+), 23 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index bcdd4ae..1403143 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -14,6 +14,8 @@ docker pull ghcr.io/freema/mcp-jira-stdio:latest ### Using docker run +**Basic auth:** + ```bash docker run -it --rm \ -e JIRA_BASE_URL="https://your-instance.atlassian.net" \ @@ -22,6 +24,33 @@ docker run -it --rm \ freema/mcp-jira-stdio:latest ``` +**OAuth 2.0:** + +OAuth requires a browser callback, so it is not suitable for fully headless Docker usage. If you need OAuth inside Docker, expose the callback port and open the printed URL manually on first run: + +```bash +docker run -it --rm \ + -p 7789:7789 \ + -e JIRA_BASE_URL="https://your-instance.atlassian.net" \ + -e JIRA_AUTH_TYPE="oauth" \ + -e JIRA_OAUTH_CLIENT_ID="your-client-id" \ + -e JIRA_OAUTH_CLIENT_SECRET="your-client-secret" \ + freema/mcp-jira-stdio:latest +``` + +To persist tokens across container restarts, mount the token directory: + +```bash +docker run -it --rm \ + -p 7789:7789 \ + -v "$HOME/.mcp-jira-stdio:/root/.mcp-jira-stdio" \ + -e JIRA_BASE_URL="https://your-instance.atlassian.net" \ + -e JIRA_AUTH_TYPE="oauth" \ + -e JIRA_OAUTH_CLIENT_ID="your-client-id" \ + -e JIRA_OAUTH_CLIENT_SECRET="your-client-secret" \ + freema/mcp-jira-stdio:latest +``` + ### Using docker-compose ```bash @@ -41,7 +70,7 @@ Images are published for both amd64 and arm64 architectures. ## Integration with Claude Desktop -Add to your Claude Desktop config: +**Basic auth:** ```json { @@ -52,12 +81,9 @@ Add to your Claude Desktop config: "run", "-i", "--rm", - "-e", - "JIRA_BASE_URL", - "-e", - "JIRA_EMAIL", - "-e", - "JIRA_API_TOKEN", + "-e", "JIRA_BASE_URL", + "-e", "JIRA_EMAIL", + "-e", "JIRA_API_TOKEN", "freema/mcp-jira-stdio:latest" ], "env": { @@ -69,3 +95,33 @@ Add to your Claude Desktop config: } } ``` + +**OAuth 2.0** (with token persistence and callback port): + +```json +{ + "mcpServers": { + "jira": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-p", "7789:7789", + "-v", "/Users/you/.mcp-jira-stdio:/root/.mcp-jira-stdio", + "-e", "JIRA_BASE_URL", + "-e", "JIRA_AUTH_TYPE", + "-e", "JIRA_OAUTH_CLIENT_ID", + "-e", "JIRA_OAUTH_CLIENT_SECRET", + "freema/mcp-jira-stdio:latest" + ], + "env": { + "JIRA_BASE_URL": "https://your-instance.atlassian.net", + "JIRA_AUTH_TYPE": "oauth", + "JIRA_OAUTH_CLIENT_ID": "your-client-id", + "JIRA_OAUTH_CLIENT_SECRET": "your-client-secret" + } + } + } +} +``` diff --git a/README.md b/README.md index 594d20e..bbf1ba0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ A Model Context Protocol (MCP) server for Jira API integration. Enables reading, The fastest way to add this MCP server to Claude Code: +**Basic auth (API token):** + ```bash claude mcp add jira npx mcp-jira-stdio@latest \ --env JIRA_BASE_URL=https://yourcompany.atlassian.net \ @@ -27,10 +29,21 @@ claude mcp add jira npx mcp-jira-stdio@latest \ --env JIRA_API_TOKEN=your-api-token ``` +**OAuth 2.0** (for organizations that have disabled API token access): + +```bash +claude mcp add jira npx mcp-jira-stdio@latest \ + --env JIRA_BASE_URL=https://yourcompany.atlassian.net \ + --env JIRA_AUTH_TYPE=oauth \ + --env JIRA_OAUTH_CLIENT_ID=your-client-id \ + --env JIRA_OAUTH_CLIENT_SECRET=your-client-secret +``` + Replace the values with your actual Jira credentials: - **JIRA_BASE_URL**: Your Jira instance URL (e.g., `https://yourcompany.atlassian.net`) -- **JIRA_EMAIL**: Your Jira account email -- **JIRA_API_TOKEN**: Your Jira API token ([generate here](https://id.atlassian.com/manage-profile/security/api-tokens)) +- **JIRA_EMAIL**: Your Jira account email *(basic auth only)* +- **JIRA_API_TOKEN**: Your Jira API token ([generate here](https://id.atlassian.com/manage-profile/security/api-tokens)) *(basic auth only)* +- **JIRA_OAUTH_CLIENT_ID / JIRA_OAUTH_CLIENT_SECRET**: OAuth 2.0 app credentials ([see OAuth setup](#oauth-20-setup)) *(OAuth only)* That's it! The server will be automatically configured and ready to use. @@ -76,11 +89,16 @@ task build ### 3. Jira API Setup -1. Go to your Jira instance settings -2. Create an API token: - - **Jira Cloud**: Go to Account Settings โ†’ Security โ†’ Create and manage API tokens - - **Jira Server**: Use your username and password (or create an application password) -3. Note your Jira base URL (e.g., `https://yourcompany.atlassian.net`) +Two authentication methods are supported: + +#### Basic Auth (API Token) โ€” default + +1. Go to [Atlassian API tokens](https://id.atlassian.com/manage-profile/security/api-tokens) and create a token +2. Note your Jira base URL (e.g., `https://yourcompany.atlassian.net`) + +#### OAuth 2.0 โ€” for organizations that have disabled API token access + +See the [OAuth 2.0 Setup](#oauth-20-setup) section below. ### 4. Configuration @@ -95,7 +113,7 @@ cp .env.example .env task env ``` -Example `.env` contents: +**Basic auth** `.env`: ```env JIRA_BASE_URL=https://your-instance.atlassian.net @@ -103,7 +121,14 @@ JIRA_EMAIL=your-email@example.com JIRA_API_TOKEN=your-api-token ``` -**Note:** Generate your API token at [https://id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens) +**OAuth 2.0** `.env`: + +```env +JIRA_BASE_URL=https://your-instance.atlassian.net +JIRA_AUTH_TYPE=oauth +JIRA_OAUTH_CLIENT_ID=your-client-id +JIRA_OAUTH_CLIENT_SECRET=your-client-secret +``` ### 5. Test Connection @@ -121,6 +146,8 @@ task jira:projects Use the quick install command (recommended): +**Basic auth:** + ```bash claude mcp add jira npx mcp-jira-stdio@latest \ --env JIRA_BASE_URL=https://yourcompany.atlassian.net \ @@ -128,6 +155,16 @@ claude mcp add jira npx mcp-jira-stdio@latest \ --env JIRA_API_TOKEN=your-api-token ``` +**OAuth 2.0:** + +```bash +claude mcp add jira npx mcp-jira-stdio@latest \ + --env JIRA_BASE_URL=https://yourcompany.atlassian.net \ + --env JIRA_AUTH_TYPE=oauth \ + --env JIRA_OAUTH_CLIENT_ID=your-client-id \ + --env JIRA_OAUTH_CLIENT_SECRET=your-client-secret +``` + #### For Claude Desktop Add to your Claude Desktop config: @@ -136,6 +173,8 @@ Add to your Claude Desktop config: - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - **Linux**: `~/.config/claude/claude_desktop_config.json` +**Basic auth:** + ```json { "mcpServers": { @@ -151,6 +190,24 @@ Add to your Claude Desktop config: } ``` +**OAuth 2.0:** + +```json +{ + "mcpServers": { + "jira": { + "command": "mcp-jira-stdio", + "env": { + "JIRA_BASE_URL": "https://your-instance.atlassian.net", + "JIRA_AUTH_TYPE": "oauth", + "JIRA_OAUTH_CLIENT_ID": "your-client-id", + "JIRA_OAUTH_CLIENT_SECRET": "your-client-secret" + } + } + } +} +``` + #### Alternative: Using npx ```json @@ -249,8 +306,9 @@ task inspector:dev Notes: -- Startup no longer blocks on Jira connectivity. If Jira env vars are missing, the server still starts and lists tools; tool calls will fail with a clear auth error until you set `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN`. +- Startup no longer blocks on Jira connectivity. If required env vars are missing the server still starts and lists tools; tool calls will fail with a clear auth error. - Connection testing runs only in development/test (`NODE_ENV=development` or `test`). Failures are logged but do not terminate the server, so the inspector can still display tools. +- When using OAuth, the browser authorization prompt fires on the **first tool call**, not at startup. ### Testing @@ -331,6 +389,19 @@ jira_get_visible_projects({ - Check project permissions in Jira - Ensure you're using the correct Jira instance +**OAuth: "Port 7789 is already in use"** + +- Another process is using the callback port. Stop it and retry, or kill it with `lsof -ti:7789 | xargs kill`. + +**OAuth: "Jira site not found in accessible resources"** + +- The `JIRA_BASE_URL` must match exactly one of the sites returned by Atlassian (e.g., `https://yourcompany.atlassian.net`). +- Delete `~/.mcp-jira-stdio/tokens.json` to force a fresh authorization if credentials changed. + +**OAuth: browser does not open automatically** + +- The server prints the authorization URL to stderr โ€” copy and open it manually. + **MCP Connection Issues** - Ensure you're using the built version (`dist/index.js`) @@ -377,12 +448,51 @@ npm run inspector ## ๐Ÿ” Environment Variables -| Variable | Required | Description | Example | -| ---------------- | -------- | ----------------- | ------------------------------- | -| `JIRA_BASE_URL` | Yes | Jira instance URL | `https://company.atlassian.net` | -| `JIRA_EMAIL` | Yes | Your Jira email | `user@example.com` | -| `JIRA_API_TOKEN` | Yes | Jira API token | `ATxxx...` | -| `NODE_ENV` | No | Environment mode | `development` or `production` | +| Variable | Required | Description | Example | +| --------------------------- | --------------------- | ------------------------------------- | ------------------------------- | +| `JIRA_BASE_URL` | Yes | Jira instance URL | `https://company.atlassian.net` | +| `JIRA_AUTH_TYPE` | No | Auth method: `basic` (default) or `oauth` | `oauth` | +| `JIRA_EMAIL` | Yes *(basic auth)* | Your Jira account email | `user@example.com` | +| `JIRA_API_TOKEN` | Yes *(basic auth)* | Jira API token | `ATxxx...` | +| `JIRA_OAUTH_CLIENT_ID` | Yes *(OAuth)* | OAuth 2.0 app client ID | `abc123` | +| `JIRA_OAUTH_CLIENT_SECRET` | Yes *(OAuth)* | OAuth 2.0 app client secret | `secret...` | +| `NODE_ENV` | No | Environment mode | `development` or `production` | + +## ๐Ÿ”‘ OAuth 2.0 Setup + +Use OAuth 2.0 when your Atlassian organization has disabled personal API token access. + +### 1. Create an OAuth 2.0 app + +1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and click **Create**. +2. Choose **OAuth 2.0 integration**. +3. Under **Permissions**, add the **Jira API** and enable these scopes: + - `read:jira-user` + - `read:jira-work` + - `write:jira-work` + - `offline_access` (required for token refresh) +4. Under **Authorization**, add the callback URL: `http://localhost:7789/callback` +5. Copy the **Client ID** and **Client Secret**. + +### 2. Configure the server + +Set these environment variables (alongside `JIRA_BASE_URL`): + +```env +JIRA_AUTH_TYPE=oauth +JIRA_OAUTH_CLIENT_ID=your-client-id +JIRA_OAUTH_CLIENT_SECRET=your-client-secret +``` + +### 3. First-run authorization + +On the first tool call, the server will: + +1. Open your browser to the Atlassian authorization page. +2. Ask you to grant access to the app. +3. Store the tokens in `~/.mcp-jira-stdio/tokens.json` (mode `600`). + +Subsequent calls use stored tokens and refresh them automatically โ€” no browser interaction needed until the refresh token expires. ## ๐Ÿค Contributing