Skip to content
Draft
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
59 changes: 56 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Generate a Jira API Token:
3. Give it a name like **"AI Assistant"**
4. **Copy the generated token** immediately (you won't see it again!)

> **Using Scoped API Tokens?** See [Scoped API Token Configuration](#scoped-api-token-configuration) below for additional setup.

### 2. Try It Instantly

```bash
Expand Down Expand Up @@ -104,6 +106,52 @@ Create `~/.mcp/configs.json` for system-wide configuration:

**Alternative config keys:** The system also accepts `"atlassian-jira"`, `"@aashari/mcp-server-atlassian-jira"`, or `"mcp-server-atlassian-jira"` instead of `"jira"`.

### Scoped API Token Configuration

Atlassian is deprecating non-scoped API tokens in favor of [scoped API tokens](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/). Scoped tokens require a different URL format (`api.atlassian.com` instead of `site.atlassian.net`) — see [CLOUD-12617](https://jira.atlassian.com/browse/CLOUD-12617) for details.

To use scoped API tokens, provide your Cloud ID instead of the site name:

```bash
export ATLASSIAN_USER_EMAIL="your.email@company.com"
export ATLASSIAN_API_TOKEN="your_scoped_api_token"
export ATLASSIAN_CLOUD_ID="your-cloud-id"
```

> **Note:** `ATLASSIAN_SITE_NAME` is not required when using scoped tokens with `ATLASSIAN_CLOUD_ID`.

**How to find your Cloud ID:**

Option 1 - Via tenant info endpoint:
```bash
curl https://your-company.atlassian.net/_edge/tenant_info
```
The response contains your `cloudId`.

Option 2 - Via Atlassian Admin:
1. Go to [admin.atlassian.com](https://admin.atlassian.com)
2. Select your organization
3. The Cloud ID is in the URL or available in the site settings

**Claude Desktop config with scoped tokens:**
```json
{
"mcpServers": {
"jira": {
"command": "npx",
"args": ["-y", "@aashari/mcp-server-atlassian-jira"],
"env": {
"ATLASSIAN_USER_EMAIL": "your.email@company.com",
"ATLASSIAN_API_TOKEN": "your_scoped_api_token",
"ATLASSIAN_CLOUD_ID": "your-cloud-id"
}
}
}
}
```

> **Note:** If `ATLASSIAN_CLOUD_ID` is not set, the server uses the traditional URL format (`https://site.atlassian.net`), which only works with non-scoped API tokens.

## Available Tools

This MCP server provides 5 generic tools that can access any Jira API endpoint:
Expand Down Expand Up @@ -268,15 +316,20 @@ npx -y @aashari/mcp-server-atlassian-jira delete \

### "Authentication failed" or "403 Forbidden"

1. **Check your API Token permissions**:
1. **Using a scoped API token?** Scoped tokens require the `ATLASSIAN_CLOUD_ID` environment variable. See [Scoped API Token Configuration](#scoped-api-token-configuration). Without it, you'll get permission errors like:
- "You do not have permission to create issues in this project"
- "Issue does not exist or you do not have permission to see it"
- Empty results from endpoints that should return data

2. **Check your API Token permissions**:
- Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
- Make sure your token is still active and hasn't expired

2. **Verify your site name format**:
3. **Verify your site name format**:
- If your Jira URL is `https://mycompany.atlassian.net`
- Your site name should be just `mycompany`

3. **Test your credentials**:
4. **Test your credentials**:
```bash
npx -y @aashari/mcp-server-atlassian-jira get --path "/rest/api/3/myself"
```
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 71 additions & 19 deletions src/utils/transport.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,38 +49,90 @@ describe('Transport Utility', () => {
expect(credentials.apiToken).toBeTruthy();
});

it('should include cloudId when ATLASSIAN_CLOUD_ID is set', () => {
const testCloudId = 'test-cloud-id-12345';
const testConfig: Record<string, string | undefined> = {
ATLASSIAN_SITE_NAME: undefined,
ATLASSIAN_USER_EMAIL: 'test@example.com',
ATLASSIAN_API_TOKEN: 'test-token',
ATLASSIAN_CLOUD_ID: testCloudId,
};

const originalGet = config.get.bind(config);

try {
config.get = (key: string) => testConfig[key];
const credentials = getAtlassianCredentials();

expect(credentials).not.toBeNull();
expect(credentials?.cloudId).toBe(testCloudId);
expect(credentials?.siteName).toBeUndefined();
} finally {
config.get = originalGet;
}
});

it('should work with siteName only (no cloudId)', () => {
const testConfig: Record<string, string | undefined> = {
ATLASSIAN_SITE_NAME: 'test-site',
ATLASSIAN_USER_EMAIL: 'test@example.com',
ATLASSIAN_API_TOKEN: 'test-token',
ATLASSIAN_CLOUD_ID: undefined,
};

const originalGet = config.get.bind(config);

try {
config.get = (key: string) => testConfig[key];
const credentials = getAtlassianCredentials();

expect(credentials).not.toBeNull();
expect(credentials?.siteName).toBe('test-site');
expect(credentials?.cloudId).toBeUndefined();
} finally {
config.get = originalGet;
}
});

it('should return null when neither siteName nor cloudId is set', () => {
const testConfig: Record<string, string | undefined> = {
ATLASSIAN_SITE_NAME: undefined,
ATLASSIAN_USER_EMAIL: 'test@example.com',
ATLASSIAN_API_TOKEN: 'test-token',
ATLASSIAN_CLOUD_ID: undefined,
};

const originalGet = config.get.bind(config);

try {
config.get = (key: string) => testConfig[key];
const credentials = getAtlassianCredentials();

expect(credentials).toBeNull();
} finally {
config.get = originalGet;
}
});

it('should return null when environment variables are missing', () => {
// Save original values
const origSiteName = config.get('ATLASSIAN_SITE_NAME');
const origUserEmail = config.get('ATLASSIAN_USER_EMAIL');
const origApiToken = config.get('ATLASSIAN_API_TOKEN');
const originalGet = config.get.bind(config);

// Create test environment without credentials
const testConfig = {
const testConfig: Record<string, string | undefined> = {
ATLASSIAN_SITE_NAME: undefined,
ATLASSIAN_USER_EMAIL: undefined,
ATLASSIAN_API_TOKEN: undefined,
ATLASSIAN_CLOUD_ID: undefined,
};

// Test with missing credentials
try {
// Use Object.defineProperty to temporarily change config.get behavior without mocking
config.get = (key: string) =>
testConfig[key as keyof typeof testConfig];

// Call the function
config.get = (key: string) => testConfig[key];
const credentials = getAtlassianCredentials();

// Verify the result is null
// Verify the credentials are null
expect(credentials).toBeNull();
} finally {
// Restore config behavior for subsequent tests
config.get = (key: string) => {
if (key === 'ATLASSIAN_SITE_NAME') return origSiteName;
if (key === 'ATLASSIAN_USER_EMAIL') return origUserEmail;
if (key === 'ATLASSIAN_API_TOKEN') return origApiToken;
return config.get(key);
};
config.get = originalGet;
}
});
});
Expand Down
35 changes: 28 additions & 7 deletions src/utils/transport.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ transportLogger.debug('Transport utility initialized');
* Interface for Atlassian API credentials
*/
export interface AtlassianCredentials {
siteName: string;
siteName?: string;
cloudId?: string;
userEmail: string;
apiToken: string;
}
Expand Down Expand Up @@ -54,17 +55,33 @@ export function getAtlassianCredentials(): AtlassianCredentials | null {
const siteName = config.get('ATLASSIAN_SITE_NAME');
const userEmail = config.get('ATLASSIAN_USER_EMAIL');
const apiToken = config.get('ATLASSIAN_API_TOKEN');
const cloudId = config.get('ATLASSIAN_CLOUD_ID');

if (!siteName || !userEmail || !apiToken) {
if (!userEmail || !apiToken) {
methodLogger.warn(
'Missing Atlassian credentials. Please set ATLASSIAN_SITE_NAME, ATLASSIAN_USER_EMAIL, and ATLASSIAN_API_TOKEN environment variables.',
'Missing Atlassian credentials. Please set ATLASSIAN_USER_EMAIL and ATLASSIAN_API_TOKEN environment variables.',
);
return null;
}

methodLogger.debug('Using Atlassian credentials');
if (!siteName && !cloudId) {
methodLogger.warn(
'Missing Atlassian site configuration. Please set either ATLASSIAN_SITE_NAME or ATLASSIAN_CLOUD_ID.',
);
return null;
}

if (cloudId) {
methodLogger.debug(
'Using Atlassian credentials with scoped token (cloudId provided)',
);
} else {
methodLogger.debug('Using Atlassian credentials');
}

return {
siteName,
cloudId,
userEmail,
apiToken,
};
Expand All @@ -87,14 +104,18 @@ export async function fetchAtlassian<T>(
'fetchAtlassian',
);

const { siteName, userEmail, apiToken } = credentials;
const { siteName, userEmail, apiToken, cloudId } = credentials;

// Ensure path starts with a slash
const normalizedPath = path.startsWith('/') ? path : `/${path}`;

// Construct the full URL
const baseUrl = `https://${siteName}.atlassian.net`;
const url = `${baseUrl}${normalizedPath}`;
// When cloudId is provided, use the scoped API token URL format (api.atlassian.com)
// Otherwise, use the traditional site-based URL format (site.atlassian.net)
// See: https://jira.atlassian.com/browse/CLOUD-12617
const url = cloudId
? `https://api.atlassian.com/ex/jira/${cloudId}${normalizedPath}`
: `https://${siteName}.atlassian.net${normalizedPath}`;

// Set up authentication and headers
const headers = {
Expand Down