#
tokens: 42829/50000 45/48 files (page 1/3)
lines: off (toggle) GitHub
raw markdown copy
This is page 1 of 3. Use http://codebase.md/infinitiq-tech/mcp-jira?page={x} to view the full context.

# Directory Structure

```
├── .env.example
├── .github
│   ├── copilot-instructions.md
│   └── ISSUE_TEMPLATE
│       ├── bug_report.yml
│       ├── config.yml
│       ├── copilot.yml
│       ├── story.yml
│       └── task.yml
├── .gitignore
├── .lock
├── bin
│   ├── activate
│   ├── activate.csh
│   ├── activate.fish
│   ├── Activate.ps1
│   ├── dotenv
│   ├── httpx
│   ├── jirashell
│   ├── mcp
│   ├── mcp-server-jira
│   ├── normalizer
│   ├── pip
│   ├── pip3
│   ├── pip3.10
│   ├── python
│   ├── python3
│   ├── python3.10
│   └── uvicorn
├── claude_reference
│   ├── jira_advanced.txt
│   ├── jira_api_documentation.txt
│   ├── jira_examples.txt
│   ├── jira_installation.md
│   └── jira_shell.txt
├── Dockerfile
├── docs
│   └── create_issues_v3_conversion.md
├── glama.json
├── jira.py
├── LICENSE
├── mcp
│   ├── __init__.py
│   ├── server
│   │   └── __init__.py
│   └── types
│       └── __init__.py
├── MCPReadme.md
├── pyproject.toml
├── pytest.ini
├── pyvenv.cfg
├── README.md
├── run_server.py
├── src
│   ├── __init__.py
│   ├── mcp
│   │   ├── __init__.py
│   │   ├── server
│   │   │   ├── __init__.py
│   │   │   └── stdio.py
│   │   └── types
│   │       └── __init__.py
│   └── mcp_server_jira
│       ├── __init__.py
│       ├── __main__.py
│       ├── jira_v3_api.py
│       └── server.py
├── tests
│   ├── __init__.py
│   ├── test_add_comment_v3_api_only.py
│   ├── test_bulk_create_issues_v3_api.py
│   ├── test_create_issue_v3_api_only.py
│   ├── test_create_issues_integration.py
│   ├── test_create_jira_issues_server.py
│   ├── test_get_transitions_v3.py
│   ├── test_jira_v3_api.py
│   ├── test_search_issues_v3_api.py
│   ├── test_server.py
│   └── test_transition_issue_v3_api_only.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.lock:
--------------------------------------------------------------------------------

```

```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg

# Environment variables and secrets
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
*.env
!.env.example

# IDE files
.idea/
.vscode/
*.swp
*.swo
*~

# Logs
logs
*.log

# OS specific
.DS_Store
Thumbs.db

# Test files
test_*.py
!tests/test_*.py
```

--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------

```
# Jira MCP Server - Example Environment File
# Rename this file to .env and update with your Jira credentials

# Required - URL of your Jira instance
JIRA_SERVER_URL=https://your-instance.atlassian.net

# Authentication method ('basic_auth' or 'token_auth')
JIRA_AUTH_METHOD=basic_auth

# For Jira Cloud (basic_auth with API token)
[email protected]
JIRA_TOKEN=your_api_token

# For Jira Server/Data Center (basic_auth with password)
# JIRA_USERNAME=your_username
# JIRA_PASSWORD=your_password

# For Jira Server/Data Center (token_auth with PAT)
# JIRA_AUTH_METHOD=token_auth
# JIRA_TOKEN=your_personal_access_token
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Jira MCP Server

A Model Context Protocol (MCP) server for interacting with Jira's REST API using the `jira-python` library. This server integrates with Claude Desktop and other MCP clients, allowing you to interact with Jira using natural language commands.

<a href="https://glama.ai/mcp/servers/@InfinitIQ-Tech/mcp-jira">
  <img width="380" height="200" src="https://glama.ai/mcp/servers/@InfinitIQ-Tech/mcp-jira/badge" alt="Jira Server MCP server" />
</a>

## Features

- Get all accessible Jira projects
- Get details for a specific Jira issue
- Search issues using JQL (Jira Query Language)
- Create new Jira issues
- Add comments to issues
- Get available transitions for an issue
- Transition issues to new statuses

## Installation

### Prerequisites

- Python 3.9 or higher
- A Jira instance (Cloud, Server, or Data Center)
- [uv](https://github.com/astral-sh/uv) (optional but recommended for dependency management)

### Activate a virtual environment (recommended)

```bash
# Install a Virtual Environment (VENV) in the mcp server directory
python -m venv .
# Activate the virtual environment
source bin/activate
```
### Using uv (recommended)
```
# Install uv if you don't have it
pip install uv

# Install the Jira MCP server
uv pip install mcp-server-jira
```

### Using pip

```bash
pip install mcp-server-jira
```

## Configuration

### Environment Variables

Configure the server using environment variables:

- `JIRA_SERVER_URL`: URL of your Jira server
- `JIRA_AUTH_METHOD`: Authentication method ('basic_auth' or 'token_auth')
- `JIRA_USERNAME`: Username for basic auth
- `JIRA_PASSWORD`: Password for basic auth
- `JIRA_TOKEN`: API token or Personal Access Token

### Environment File (Local Development)

You can also create a `.env` file in the root directory with your configuration:

```
JIRA_SERVER_URL=https://your-jira-instance.atlassian.net
JIRA_AUTH_METHOD=basic_auth
[email protected]
JIRA_TOKEN=your_api_token
```

## Usage

### Command Line

```bash
python -m mcp_server_jira
```

### Docker

```bash
docker build -t mcp-jira .
docker run --env-file .env -p 8080:8080 mcp-jira
```

## Claude Desktop Integration

To use this server with Claude Desktop:

1. Install the server using one of the methods above
2. In Claude Desktop:
   - Go to Settings -> Developer
   - Click Edit Config
   - Open the json configuraiton in your editor of choice
   - Add the following JSON:
(`NOTE`: The environment variables used are for token Auth and will not work with other authentication methods)
```json
{
  "mcpServers": {
    "jira": {
      "command": "<PATH TO UV> i.e. /Users/<MYUSERNAME>/.local/bin/uv",
      "args": [
          "--directory",
          "<PATH TO JIRA MCP>",
          "run",
          "mcp-server-jira"
      ],
      "env": {
          "JIRA_SERVER_URL": "https://<ORG>.atlassian.net/",
          "JIRA_AUTH_METHOD": "token_auth",
          "JIRA_USERNAME": "<USERNAME>",
          "JIRA_TOKEN": "<TOKEN>"
      }
    }
  }
}
```

3. Now you can interact with Jira by asking Claude questions like:
   - "Show me all my projects in Jira"
   - "Get details for issue PROJECT-123"
   - "Create a new bug in the PROJECT with summary 'Fix login issue'"
   - "Find all open bugs assigned to me"

## Authentication

The server supports multiple authentication methods:

### Basic Authentication

For Jira Server/Data Center with username and password:

```bash
JIRA_SERVER_URL="https://jira.example.com"
JIRA_AUTH_METHOD="basic_auth"
JIRA_USERNAME="your_username"
JIRA_PASSWORD="your_password"
```

### API Token (Jira Cloud)

For Jira Cloud using an API token:

```bash
JIRA_SERVER_URL="https://your-domain.atlassian.net"
JIRA_AUTH_METHOD="basic_auth"
JIRA_USERNAME="[email protected]"
JIRA_TOKEN="your_api_token"
```

### Personal Access Token (Jira Server/Data Center)

For Jira Server/Data Center (8.14+) using a PAT:

```bash
JIRA_SERVER_URL="https://jira.example.com"
JIRA_AUTH_METHOD="token_auth"
JIRA_TOKEN="your_personal_access_token"
```

## Available Tools

1. `get_projects`: Get all accessible Jira projects
2. `get_issue`: Get details for a specific Jira issue by key
3. `search_issues`: Search for Jira issues using JQL
4. `create_issue`: Create a new Jira issue
5. `add_comment`: Add a comment to a Jira issue
6. `get_transitions`: Get available workflow transitions for a Jira issue
7. `transition_issue`: Transition a Jira issue to a new status

## License
MIT
```

--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------

```python
# Test package initialization

```

--------------------------------------------------------------------------------
/src/mcp/__init__.py:
--------------------------------------------------------------------------------

```python
# Stub package for mcp modules

```

--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------

```python
# namespace package for CLI server code

```

--------------------------------------------------------------------------------
/mcp/__init__.py:
--------------------------------------------------------------------------------

```python
"""
Stub package for mcp to satisfy imports in server module.
"""

```

--------------------------------------------------------------------------------
/src/mcp_server_jira/__init__.py:
--------------------------------------------------------------------------------

```python
"""
MCP Server for Jira API integration.
"""

__version__ = "0.1.0"

```

--------------------------------------------------------------------------------
/glama.json:
--------------------------------------------------------------------------------

```json
{
	"$schema": "https://glama.ai/mcp/schemas/server.json",
	  "maintainers": [
	    "froggomad"
	  ]
}

```

--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------

```
[pytest]
# Ignore local library packages to avoid import conflicts
addopts = --ignore=lib
# Only look for tests in the tests directory
testpaths = tests
norecursedirs = lib

```

--------------------------------------------------------------------------------
/src/mcp/server/stdio.py:
--------------------------------------------------------------------------------

```python
"""
Stub stdio_server for mcp.server.stdio
"""

def stdio_server(*args, **kwargs):
    """
    Stub implementation of the stdio_server function for mcp.server.stdio.
    
    This function accepts any arguments but performs no operations and always returns None.
    """
    return None

```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------

```yaml
blank_issues_enabled: false
contact_links:
  - name: 📚 Documentation
    url: https://github.com/InfinitIQ-Tech/mcp-jira/blob/main/README.md
    about: Check the README for setup and usage instructions
  - name: 💬 Discussions
    url: https://github.com/InfinitIQ-Tech/mcp-jira/discussions
    about: Ask questions and discuss ideas with the community
```

--------------------------------------------------------------------------------
/jira.py:
--------------------------------------------------------------------------------

```python
"""
Stub module for jira.JIRA to satisfy imports in server.
"""

class JIRA:
    """Stub Jira client class"""
    def __init__(self, *args, **kwargs):
        # Stub initializer
        """
        Initializes the JIRA stub instance without performing any setup.
        
        Accepts any positional or keyword arguments for compatibility with code expecting a JIRA client.
        """
        pass

```

--------------------------------------------------------------------------------
/src/mcp_server_jira/__main__.py:
--------------------------------------------------------------------------------

```python
import asyncio
import os

from .server import serve


def main() -> None:
    # Get configuration from environment variables
    server_url = os.environ.get("JIRA_SERVER_URL")
    auth_method = os.environ.get("JIRA_AUTH_METHOD")
    username = os.environ.get("JIRA_USERNAME")
    password = os.environ.get("JIRA_PASSWORD")
    token = os.environ.get("JIRA_TOKEN")

    asyncio.run(
        serve(
            server_url=server_url,
            auth_method=auth_method,
            username=username,
            password=password,
            token=token,
        )
    )


if __name__ == "__main__":
    main()

```

--------------------------------------------------------------------------------
/src/mcp/types/__init__.py:
--------------------------------------------------------------------------------

```python
"""
Stub package for mcp.types to satisfy imports.
"""

from typing import Any, Dict, Sequence, Union

class TextContent:
    def __init__(self, type: str, text: str):
        """
        Initializes a TextContent instance with a specified type and text.
        
        Args:
            type: The type of the text content.
            text: The textual content.
        """
        self.type = type
        self.text = text

class ImageContent:
    pass

class EmbeddedResource:
    pass

class Tool:
    def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
        """
        Initializes a Tool instance with a name, description, and input schema.
        
        Args:
            name: The name of the tool.
            description: A brief description of the tool.
            inputSchema: A dictionary defining the expected input schema for the tool.
        """
        self.name = name
        self.description = description
        self.inputSchema = inputSchema

```

--------------------------------------------------------------------------------
/mcp/types/__init__.py:
--------------------------------------------------------------------------------

```python
"""
Stub package for mcp.types to satisfy imports in server module.
"""
from typing import Any, Dict

class TextContent:
    def __init__(self, type: str, text: str):
        """
        Initializes a TextContent instance with a specified type and text.
        
        Args:
            type: The type of the text content.
            text: The textual content.
        """
        self.type = type
        self.text = text

class ImageContent:
    pass

class EmbeddedResource:
    pass

class Tool:
    def __init__(self, name: str, description: str, inputSchema: Dict[str, Any]):
        """
        Initializes a Tool instance with a name, description, and input schema.
        
        Args:
            name: The name of the tool.
            description: A brief description of the tool's purpose.
            inputSchema: A dictionary defining the expected input schema for the tool.
        """
        self.name = name
        self.description = description
        self.inputSchema = inputSchema

```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
FROM python:3.10-slim

WORKDIR /app

# Install uv for dependency management
# Note: In production environments, proper SSL certificates should be used
# The --trusted-host flags are included to handle CI/CD environments with certificate issues
RUN pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir uv

# Copy pyproject.toml and uv.lock (if it exists)
COPY pyproject.toml uv.lock* ./

# Copy source code and README (required for package build)
COPY src/ ./src/
COPY README.md ./

# Create and use a virtual environment with uv
# uv provides isolated virtual environments for MCP servers, preventing conflicts with global Python environment
RUN uv venv /opt/venv && \
    VIRTUAL_ENV=/opt/venv uv pip install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org --no-cache-dir -e .

# Activate the virtual environment for all subsequent commands
ENV PATH="/opt/venv/bin:$PATH"

# Set the entrypoint
ENTRYPOINT ["python", "-m", "mcp_server_jira"]
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mcp-server-jira"
description = "MCP server for Jira API integration"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
version = "0.1.0"
dependencies = [
    "jira>=3.5.0",
    "mcp[cli]>=1.0.0",
    "pydantic>=2.0.0",
    "python-dotenv>=1.0.0",
    "httpx>=0.24.0",
]

[project.scripts]
mcp-server-jira = "mcp_server_jira.__main__:main"

[project.optional-dependencies]
dev = [
    "pytest",
    "black",
    "isort",
    "mypy",
    "uv",
]

[tool.black]
line-length = 88
target-version = ["py310"]

[tool.isort]
profile = "black"
line_length = 88

[tool.pytest]
testpaths = ["tests"]
python_files = "test_*.py"

[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false

[dependency-groups]
dev = [
    "pytest>=8.3.5",
    "pytest-asyncio>=1.0.0",
]

# Remove uv-specific config
```

--------------------------------------------------------------------------------
/mcp/server/__init__.py:
--------------------------------------------------------------------------------

```python
"""
Stub package for mcp.server to satisfy imports in top-level server module.
"""
class Server:
    def __init__(self, *args, **kwargs):
        """
        Initializes the Server stub with arbitrary arguments.
        
        This constructor accepts any arguments but performs no initialization.
        """
        pass

    def list_tools(self):
        """
        Returns a decorator that leaves the decorated function unchanged.
        
        This method is a stub and does not modify or register the function in any way.
        """
        def decorator(fn): return fn
        return decorator

    def call_tool(self):
        """
        Returns a decorator that leaves the decorated function unchanged.
        
        Use this as a placeholder for tool registration in stub implementations.
        """
        def decorator(fn): return fn
        return decorator

    def create_initialization_options(self):
        """
        Returns an empty dictionary representing initialization options.
        """
        return {}

    async def run(self, *args, **kwargs):
        """
        Placeholder asynchronous run method with no implementation.
        """
        pass

```

--------------------------------------------------------------------------------
/run_server.py:
--------------------------------------------------------------------------------

```python
#!/usr/bin/env python3
"""
Simple script to run the Jira MCP server with detailed logging
"""
import asyncio
import logging
import os
import sys

from src.mcp_server_jira.server import serve

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[logging.StreamHandler(sys.stdout)]
)

logger = logging.getLogger()

async def main():
    logger.info("Starting Jira MCP server...")
    
    # Get configuration from environment variables
    server_url = os.environ.get("JIRA_SERVER_URL")
    auth_method = os.environ.get("JIRA_AUTH_METHOD")
    username = os.environ.get("JIRA_USERNAME")
    password = os.environ.get("JIRA_PASSWORD")
    token = os.environ.get("JIRA_TOKEN")
    
    logger.info(f"Server URL: {server_url or 'Not configured'}")
    logger.info(f"Auth Method: {auth_method or 'Not configured'}")
    
    try:
        await serve(
            server_url=server_url,
            auth_method=auth_method,
            username=username,
            password=password,
            token=token
        )
    except Exception as e:
        logger.error(f"Error running server: {e}")
        raise

if __name__ == "__main__":
    asyncio.run(main())
```

--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------

```markdown
This is a python based repository using the jira pip package via the Model Context Protocol. This enables users utilizing MCP-enabled LLMS to work with jira in natural language rather than learning the API themselves.

NOTE: There is an ongoing effort to convert from the jira pip package to utilizing the V3 REST API directly.

## Code Standards

### Development Flow
- Build/Run: to ensure compatibilty with the end-user, use `uv` as this is what we recommended.
  - `uv run mcp-server-jira`
- Test: to ensure functionality, create and run unit tests using pytest

## Key Guidelines
1. Think like "Uncle Bob" (Robert Martin)
2. Write clean, modular code that follows the Single Responsibility Principle
3. Ensure best-practices with MCP servers are followed by referring to `/MCPReadme.md`
4. Ensure instructions to agents are updated via `@server.list_tools()` - this follows a specific schema so don't be creative with keys.
5. Maintain existing code structure and organization unless otherwise directed
6. Use dependency injection patterns where appropriate
7. Write unit tests for new functionality.
8. Document public APIs and complex logic. Suggest changes to the `docs/` folder when appropriate.
9. Use black, isort, and mypy for code quality

```

--------------------------------------------------------------------------------
/src/mcp/server/__init__.py:
--------------------------------------------------------------------------------

```python
"""
Stub package for mcp.server to satisfy imports.
"""

class Server:
    """Stubbed Server class"""
    def __init__(self, *args, **kwargs):
        """
        Initializes the Server stub with arbitrary arguments.
        
        This constructor accepts any positional or keyword arguments but does not perform any initialization logic.
        """
        pass

    def list_tools(self):
        """
        Returns a decorator that leaves the decorated function unchanged.
        
        This is a placeholder implementation with no operational effect.
        """
        def decorator(fn):
            return fn
        return decorator

    def call_tool(self):
        """
        Returns a decorator that leaves the decorated function unchanged.
        
        This is a placeholder implementation with no operational effect.
        """
        def decorator(fn):
            return fn
        return decorator

    def create_initialization_options(self):
        """
        Returns an empty dictionary representing initialization options.
        
        This stub method provides a placeholder for initialization configuration.
        """
        return {}

    async def run(self, *args, **kwargs):
        """
        Placeholder asynchronous method for running the server.
        
        This stub implementation does not perform any actions.
        """
        pass

```

--------------------------------------------------------------------------------
/claude_reference/jira_installation.md:
--------------------------------------------------------------------------------

```markdown
1. Installation
The easiest (and best) way to install jira-python is through pip:

pip install 'jira[cli]'
This will handle installation of the client itself as well as the requirements. The [cli] part installs dependencies for the jirashell binary, and may be omitted if you just need the library.

If you’re going to run the client standalone, we strongly recommend using a virtualenv:

python -m venv jira_python
source jira_python/bin/activate
pip install 'jira[cli]'
or:

python -m venv jira_python
jira_python/bin/pip install 'jira[cli]'
Doing this creates a private Python “installation” that you can freely upgrade, degrade or break without putting the critical components of your system at risk.

Source packages are also available at PyPI:

https://pypi.python.org/pypi/jira/

1.1. Dependencies
Python >=3.9 is required.

requests - python-requests library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work.

requests-oauthlib - Used to implement OAuth. The latest version as of this writing is 1.3.0.

requests-kerberos - Used to implement Kerberos.

ipython - The IPython enhanced Python interpreter provides the fancy chrome used by Headers.

filemagic - This library handles content-type autodetection for things like image uploads. This will only work on a system that provides libmagic; Mac and Unix will almost always have it preinstalled, but Windows users will have to use Cygwin or compile it natively. If your system doesn’t have libmagic, you’ll have to manually specify the contentType parameter on methods that take an image object, such as project and user avatar creation.

Installing through pip takes care of these dependencies for you.
```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------

```yaml
name: Bug Report
description: Report a bug to help us improve
title: "[Bug]: "
labels: ["bug"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for taking the time to fill out this bug report!
  - type: input
    id: summary
    attributes:
      label: Summary
      description: A brief summary of the bug
      placeholder: Describe the bug in one sentence
    validations:
      required: true
  - type: textarea
    id: expected-behavior
    attributes:
      label: Expected Behavior
      description: What did you expect to happen?
      placeholder: Describe what you expected to happen
    validations:
      required: true
  - type: textarea
    id: actual-behavior
    attributes:
      label: Actual Behavior
      description: What actually happened instead?
      placeholder: Describe what actually happened
    validations:
      required: true
  - type: textarea
    id: reproduction-steps
    attributes:
      label: Steps to Reproduce
      description: Please provide detailed steps to reproduce the issue
      placeholder: |
        1. Go to '...'
        2. Click on '....'
        3. Scroll down to '....'
        4. See error
    validations:
      required: true
  - type: textarea
    id: environment
    attributes:
      label: Environment Information
      description: Please provide details about your environment
      placeholder: |
        - OS: [e.g. Windows 10, macOS 11.0, Ubuntu 20.04]
        - Python Version: [e.g. 3.10.0]
        - MCP Server Jira Version: [e.g. 0.1.0]
        - Jira Version: [e.g. Cloud, Server 8.x]
    validations:
      required: false
  - type: textarea
    id: additional-context
    attributes:
      label: Additional Context
      description: Add any other context about the problem here, including logs, screenshots, etc.
      placeholder: Any additional information that might be helpful
    validations:
      required: false
```

--------------------------------------------------------------------------------
/claude_reference/jira_advanced.txt:
--------------------------------------------------------------------------------

```
4. Advanced
4.1. Resource Objects and Properties
The library distinguishes between two kinds of data in the Jira REST API: resources and properties.

A resource is a REST entity that represents the current state of something that the server owns; for example, the issue called “ABC-123” is a concept managed by Jira which can be viewed as a resource obtainable at the URL http://jira-server/rest/api/latest/issue/ABC-123. All resources have a self link: a root-level property called self which contains the URL the resource originated from. In jira-python, resources are instances of the Resource object (or one of its subclasses) and can only be obtained from the server using the find() method. Resources may be connected to other resources: the issue Resource is connected to a user Resource through the assignee and reporter fields, while the project Resource is connected to a project lead through another user Resource.

Important

A resource is connected to other resources, and the client preserves this connection. In the above example, the object inside the issue object at issue.fields.assignee is not just a dict – it is a full-fledged user Resource object. Whenever a resource contains other resources, the client will attempt to convert them to the proper subclass of Resource.

A properties object is a collection of values returned by Jira in response to some query from the REST API. Their structure is freeform and modeled as a Python dict. Client methods return this structure for calls that do not produce resources. For example, the properties returned from the URL http://jira-server/rest/api/latest/issue/createmeta are designed to inform users what fields (and what values for those fields) are required to successfully create issues in the server’s projects. Since these properties are determined by Jira’s configuration, they are not resources.

The Jira client’s methods document whether they will return a Resource or a properties object.
```

--------------------------------------------------------------------------------
/bin/activate.fish:
--------------------------------------------------------------------------------

```
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/); you cannot run it directly.

function deactivate  -d "Exit virtual environment and return to normal shell environment"
    # reset old environment variables
    if test -n "$_OLD_VIRTUAL_PATH"
        set -gx PATH $_OLD_VIRTUAL_PATH
        set -e _OLD_VIRTUAL_PATH
    end
    if test -n "$_OLD_VIRTUAL_PYTHONHOME"
        set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
        set -e _OLD_VIRTUAL_PYTHONHOME
    end

    if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
        set -e _OLD_FISH_PROMPT_OVERRIDE
        # prevents error when using nested fish instances (Issue #93858)
        if functions -q _old_fish_prompt
            functions -e fish_prompt
            functions -c _old_fish_prompt fish_prompt
            functions -e _old_fish_prompt
        end
    end

    set -e VIRTUAL_ENV
    set -e VIRTUAL_ENV_PROMPT
    if test "$argv[1]" != "nondestructive"
        # Self-destruct!
        functions -e deactivate
    end
end

# Unset irrelevant variables.
deactivate nondestructive

set -gx VIRTUAL_ENV "/Users/kennethdubroff/Development/AI/Vibe/Claude 3.7/claudemcp/servers-main/src/jira"

set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/bin" $PATH

# Unset PYTHONHOME if set.
if set -q PYTHONHOME
    set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
    set -e PYTHONHOME
end

if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
    # fish uses a function instead of an env var to generate the prompt.

    # Save the current fish_prompt function as the function _old_fish_prompt.
    functions -c fish_prompt _old_fish_prompt

    # With the original prompt function renamed, we can override with our own.
    function fish_prompt
        # Save the return status of the last command.
        set -l old_status $status

        # Output the venv prompt; color taken from the blue of the Python logo.
        printf "%s%s%s" (set_color 4B8BBE) "(jira) " (set_color normal)

        # Restore the return status of the previous command.
        echo "exit $old_status" | .
        # Output the original/"old" prompt.
        _old_fish_prompt
    end

    set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
    set -gx VIRTUAL_ENV_PROMPT "(jira) "
end

```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/story.yml:
--------------------------------------------------------------------------------

```yaml
name: User Story
description: Create a user story for new functionality
title: "[Story]: "
labels: ["story", "enhancement"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for creating a user story! Please fill out the sections below to help us understand your requirements.
  - type: textarea
    id: user-story
    attributes:
      label: User Story
      description: Write your user story in the format "As a [user type], I want [goal] so that [benefit]"
      placeholder: |
        As a [user type],
        I want [goal],
        so that [benefit].
    validations:
      required: true
  - type: textarea
    id: background
    attributes:
      label: Background
      description: Provide context and background information for this story
      placeholder: |
        Describe the background, context, and any relevant information that helps understand why this story is needed.
    validations:
      required: true
  - type: textarea
    id: acceptance-criteria
    attributes:
      label: Acceptance Criteria
      description: Define the specific criteria that must be met for this story to be considered complete
      placeholder: |
        - [ ] Criterion 1
        - [ ] Criterion 2
        - [ ] Criterion 3
        
        Given [initial context]
        When [action taken]
        Then [expected result]
    validations:
      required: true
  - type: textarea
    id: notes
    attributes:
      label: Notes
      description: Any additional notes, considerations, or implementation details
      placeholder: |
        - Technical considerations
        - Dependencies
        - Edge cases to consider
        - Design considerations
    validations:
      required: false
  - type: textarea
    id: non-functional-requirements
    attributes:
      label: Non-Functional Requirements
      description: Specify any non-functional requirements such as performance, security, usability, etc.
      placeholder: |
        - Performance: [e.g., response time < 2 seconds]
        - Security: [e.g., authentication required]
        - Usability: [e.g., accessible via keyboard navigation]
        - Scalability: [e.g., support up to 1000 concurrent users]
    validations:
      required: false
  - type: dropdown
    id: priority
    attributes:
      label: Priority
      description: What is the priority of this story?
      options:
        - Low
        - Medium
        - High
        - Critical
    validations:
      required: false
  - type: input
    id: story-points
    attributes:
      label: Story Points (estimate)
      description: Estimated complexity/effort for this story
      placeholder: "e.g., 1, 2, 3, 5, 8, 13"
    validations:
      required: false
```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/copilot.yml:
--------------------------------------------------------------------------------

```yaml
name: Copilot Request
description: Request assistance from GitHub Copilot for code generation, review, or optimization
title: "[Copilot]: "
labels: ["copilot", "ai-assistance"]
assignees: ["Copilot"]
body:
  - type: markdown
    attributes:
      value: |
        🤖 **Copilot Request Template**
        
        This issue will be automatically assigned to @copilot for AI-powered assistance. Please provide detailed information about what you need help with.
  - type: dropdown
    id: request-type
    attributes:
      label: Type of Assistance
      description: What kind of help do you need from Copilot?
      options:
        - Code Generation
        - Code Review
        - Bug Investigation
        - Optimization
        - Documentation
        - Testing
        - Refactoring
        - Architecture Advice
        - Other
    validations:
      required: true
  - type: textarea
    id: description
    attributes:
      label: Description
      description: Describe what you need assistance with
      placeholder: |
        Provide a clear description of what you need help with. Be as specific as possible.
    validations:
      required: true
  - type: textarea
    id: context
    attributes:
      label: Context
      description: Provide relevant context, existing code, or background information
      placeholder: |
        - Current implementation (if any)
        - Related files or modules
        - Constraints or requirements
        - Previous attempts or approaches tried
    validations:
      required: false
  - type: textarea
    id: requirements
    attributes:
      label: Requirements & Constraints
      description: Specify any specific requirements, constraints, or preferences
      placeholder: |
        - Language/framework preferences
        - Performance requirements
        - Coding standards to follow
        - Compatibility requirements
        - Security considerations
    validations:
      required: false
  - type: textarea
    id: expected-outcome
    attributes:
      label: Expected Outcome
      description: What do you expect as the result of this request?
      placeholder: |
        Describe what you expect to receive:
        - Code examples
        - Recommendations
        - Analysis
        - Documentation updates
        - Test cases
    validations:
      required: true
  - type: dropdown
    id: urgency
    attributes:
      label: Urgency
      description: How urgent is this request?
      options:
        - Low - When time permits
        - Medium - Within a few days
        - High - Within 24 hours
        - Critical - ASAP
    validations:
      required: false
  - type: textarea
    id: additional-info
    attributes:
      label: Additional Information
      description: Any other relevant information, links, or resources
      placeholder: |
        - Links to documentation
        - Related issues or PRs
        - External resources
        - Screenshots or diagrams
    validations:
      required: false
```

--------------------------------------------------------------------------------
/claude_reference/jira_shell.txt:
--------------------------------------------------------------------------------

```
3. jirashell
There is no substitute for play. The only way to really know a service, API or package is to explore it, poke at it, and bang your elbows – trial and error. A REST design is especially well-suited for active exploration, and the jirashell script (installed automatically when you use pip) is designed to help you do exactly that.

pip install jira[cli]
Run it from the command line

jirashell -s https://jira.atlassian.com
<Jira Shell (https://jira.atlassian.com)>

*** Jira shell active; client is in 'jira'. Press Ctrl-D to exit.

In [1]:
This is a specialized Python interpreter (built on IPython) that lets you explore Jira as a service. Any legal Python code is acceptable input. The shell builds a JIRA client object for you (based on the launch parameters) and stores it in the jira object.

Try getting an issue

In [1]: issue = jira.issue('JRA-1330')
issue now contains a reference to an issue Resource. To see the available properties and methods, hit the TAB key

In [2]: issue.
issue.delete  issue.fields  issue.id      issue.raw     issue.update
issue.expand  issue.find    issue.key     issue.self

In [2]: issue.fields.
issue.fields.aggregateprogress              issue.fields.customfield_11531
issue.fields.aggregatetimeestimate          issue.fields.customfield_11631
issue.fields.aggregatetimeoriginalestimate  issue.fields.customfield_11930
issue.fields.aggregatetimespent             issue.fields.customfield_12130
issue.fields.assignee                       issue.fields.customfield_12131
issue.fields.attachment                     issue.fields.description
issue.fields.comment                        issue.fields.environment
issue.fields.components                     issue.fields.fixVersions
issue.fields.created                        issue.fields.issuelinks
issue.fields.customfield_10150              issue.fields.issuetype
issue.fields.customfield_10160              issue.fields.labels
issue.fields.customfield_10161              issue.fields.mro
issue.fields.customfield_10180              issue.fields.progress
issue.fields.customfield_10230              issue.fields.project
issue.fields.customfield_10575              issue.fields.reporter
issue.fields.customfield_10610              issue.fields.resolution
issue.fields.customfield_10650              issue.fields.resolutiondate
issue.fields.customfield_10651              issue.fields.status
issue.fields.customfield_10680              issue.fields.subtasks
issue.fields.customfield_10723              issue.fields.summary
issue.fields.customfield_11130              issue.fields.timeestimate
issue.fields.customfield_11230              issue.fields.timeoriginalestimate
issue.fields.customfield_11431              issue.fields.timespent
issue.fields.customfield_11433              issue.fields.updated
issue.fields.customfield_11434              issue.fields.versions
issue.fields.customfield_11435              issue.fields.votes
issue.fields.customfield_11436              issue.fields.watches
issue.fields.customfield_11437              issue.fields.workratio
Since the Resource class maps the server’s JSON response directly into a Python object with attribute access, you can see exactly what’s in your resources.
```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/task.yml:
--------------------------------------------------------------------------------

```yaml
name: Task
description: Create a task for work that needs to be done
title: "[Task]: "
labels: ["task"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for creating a task! Please provide details about the work that needs to be done.
  - type: textarea
    id: user-story
    attributes:
      label: User Story (if applicable)
      description: If this task relates to a user story, write it in the format "As a [user type], I want [goal] so that [benefit]"
      placeholder: |
        As a [user type],
        I want [goal],
        so that [benefit].
        
        (Leave blank if not applicable)
    validations:
      required: false
  - type: textarea
    id: task-description
    attributes:
      label: Task Description
      description: Describe what needs to be done
      placeholder: |
        Provide a clear description of the task to be completed.
    validations:
      required: true
  - type: textarea
    id: background
    attributes:
      label: Background
      description: Provide context and background information for this task
      placeholder: |
        Describe the background, context, and any relevant information that helps understand why this task is needed.
    validations:
      required: false
  - type: textarea
    id: acceptance-criteria
    attributes:
      label: Acceptance Criteria
      description: Define the specific criteria that must be met for this task to be considered complete
      placeholder: |
        - [ ] Criterion 1
        - [ ] Criterion 2
        - [ ] Criterion 3
        
        The task is complete when:
        - [specific outcome 1]
        - [specific outcome 2]
    validations:
      required: true
  - type: input
    id: parent-issue
    attributes:
      label: Parent Issue (optional)
      description: Link to a parent issue if this task is part of a larger story or epic
      placeholder: "#123 or https://github.com/owner/repo/issues/123"
    validations:
      required: false
  - type: textarea
    id: notes
    attributes:
      label: Notes
      description: Any additional notes, considerations, or implementation details
      placeholder: |
        - Technical considerations
        - Dependencies
        - Implementation approach
        - Resources needed
    validations:
      required: false
  - type: textarea
    id: non-functional-requirements
    attributes:
      label: Non-Functional Requirements
      description: Specify any non-functional requirements such as performance, security, usability, etc.
      placeholder: |
        - Performance: [e.g., response time < 2 seconds]
        - Security: [e.g., authentication required]
        - Usability: [e.g., accessible via keyboard navigation]
        - Maintainability: [e.g., code coverage > 80%]
    validations:
      required: false
  - type: dropdown
    id: priority
    attributes:
      label: Priority
      description: What is the priority of this task?
      options:
        - Low
        - Medium
        - High
        - Critical
    validations:
      required: false
  - type: input
    id: effort-estimate
    attributes:
      label: Effort Estimate
      description: Estimated time or complexity for this task
      placeholder: "e.g., 2 hours, 1 day, 1 week, or story points: 1, 2, 3, 5, 8"
    validations:
      required: false
```

--------------------------------------------------------------------------------
/docs/create_issues_v3_conversion.md:
--------------------------------------------------------------------------------

```markdown
# Convert create_issues to v3 REST API - Implementation Summary

## Overview
This document summarizes the conversion of the `create_jira_issues` method from using the legacy Jira Python SDK to the v3 REST API, following the established patterns in the codebase.

## Changes Made

### 1. JiraV3APIClient - New Method
**File:** `src/mcp_server_jira/jira_v3_api.py`

Added `bulk_create_issues()` method:
- Uses POST `/rest/api/3/issue/bulk` endpoint
- Supports up to 50 issues per API specification
- Takes `issue_updates` array with proper v3 API structure
- Returns response with `issues` and `errors` arrays

### 2. JiraServer - Method Conversion
**File:** `src/mcp_server_jira/server.py`

Converted `create_jira_issues()` method:
- Changed from synchronous to `async` method
- Uses `self._get_v3_api_client()` instead of legacy client
- Maintains existing interface and return format for backward compatibility
- Added ADF (Atlassian Document Format) conversion for descriptions

### 3. Server Integration
**File:** `src/mcp_server_jira/server.py` (line ~1420)

Updated the tool call handler:
- Changed from synchronous to `await` call
- Updated logging messages to reflect async operation

## Key Technical Features

### ADF Conversion
String descriptions are automatically converted to Atlassian Document Format:
```python
# Input: "Simple text description"
# Output:
{
    "type": "doc",
    "version": 1,
    "content": [
        {
            "type": "paragraph",
            "content": [{"type": "text", "text": "Simple text description"}]
        }
    ]
}
```

### Issue Type Conversion
Maintains existing case conversion logic:
- `"bug"` → `"Bug"`
- `"new feature"` → `"New Feature"`
- `"STORY"` → `"Story"`
- Custom types preserved as-is

### Field Processing
Preserves all existing field processing:
- Project: `"PROJ"` → `{"key": "PROJ"}`
- Labels: `"label"` → `["label"]`
- Assignee: `"user"` → `{"name": "user"}`

### Backward Compatibility
- Supports both `"issue_type"` and `"issuetype"` fields
- Accepts both string and object formats for existing fields
- Returns same result format as legacy implementation

## API Format Transformation

### Input Format (unchanged)
```python
field_list = [
    {
        "project": "PROJ",
        "summary": "Test issue",
        "description": "Simple description",
        "issue_type": "bug"
    }
]
```

### v3 API Format (internal transformation)
```python
{
    "issueUpdates": [
        {
            "fields": {
                "project": {"key": "PROJ"},
                "summary": "Test issue",
                "description": {
                    "type": "doc",
                    "version": 1,
                    "content": [...]
                },
                "issuetype": {"name": "Bug"}
            }
        }
    ]
}
```

### Output Format (unchanged)
```python
[
    {
        "key": "PROJ-1",
        "id": "10000",
        "self": "https://...",
        "success": True
    }
]
```

## Error Handling
- Maintains existing validation for required fields
- Preserves error message format and logging
- Handles v3 API errors with backward-compatible result format
- Supports partial success scenarios (some issues created, some failed)

## Testing
Comprehensive test suite created:

1. **Unit Tests**: `test_bulk_create_issues_v3_api.py`
   - v3 API client method testing
   - Validation and error handling

2. **Server Tests**: `test_create_jira_issues_server.py`
   - Server method integration
   - Field conversion and ADF formatting

3. **Integration Tests**: `test_create_issues_integration.py`
   - End-to-end workflow testing
   - Backward compatibility verification

## Manual Verification
All functionality verified through comprehensive manual testing:
- ✅ Field validation working correctly
- ✅ ADF conversion functioning properly  
- ✅ Issue type conversion logic correct
- ✅ Error handling preserved
- ✅ Backward compatibility maintained

## Migration Notes
This change is **fully backward compatible**:
- Existing code using `create_jira_issues()` will continue to work
- Same input format supported
- Same output format returned
- All existing field processing preserved

The only visible change is that the method is now `async` and must be called with `await`.

## Future Maintenance
When maintaining this code:
1. Follow the established v3 API patterns from other converted methods
2. Preserve the ADF conversion for description fields
3. Maintain backward compatibility with existing field formats
4. Use the comprehensive test suite to validate changes
```

--------------------------------------------------------------------------------
/tests/test_create_issue_v3_api_only.py:
--------------------------------------------------------------------------------

```python
"""Test cases for create_issue V3 API client only"""

import asyncio
from unittest.mock import AsyncMock, Mock, patch

import pytest

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient


class TestCreateIssueV3API:
    """Test suite for create_issue V3 API client"""

    @pytest.mark.asyncio
    async def test_v3_api_create_issue_success(self):
        """Test successful create issue request with V3 API"""
        # Mock 201 Created response (standard for successful creation)
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            "id": "10000",
            "key": "PROJ-123",
            "self": "https://test.atlassian.net/rest/api/3/issue/10000",
        }
        mock_response.text = '{"id":"10000","key":"PROJ-123","self":"https://test.atlassian.net/rest/api/3/issue/10000"}'
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        # Replace the client instance
        client.client = mock_client

        fields = {
            "project": {"key": "PROJ"},
            "summary": "Test issue",
            "description": "Test description",
            "issuetype": {"name": "Bug"},
        }

        result = await client.create_issue(fields=fields)

        # Verify the response
        assert result["id"] == "10000"
        assert result["key"] == "PROJ-123"
        assert result["self"] == "https://test.atlassian.net/rest/api/3/issue/10000"

        # Verify the request was made with correct parameters
        mock_client.request.assert_called_once()
        call_args = mock_client.request.call_args
        assert call_args[1]["method"] == "POST"
        assert "/rest/api/3/issue" in call_args[1]["url"]

        # Verify the payload
        payload = call_args[1]["json"]
        assert payload["fields"] == fields

    @pytest.mark.asyncio
    async def test_v3_api_create_issue_with_optional_params(self):
        """Test create issue with optional parameters"""
        # Mock 201 Created response
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            "id": "10001",
            "key": "PROJ-124",
            "self": "https://test.atlassian.net/rest/api/3/issue/10001",
        }
        mock_response.text = '{"id":"10001","key":"PROJ-124","self":"https://test.atlassian.net/rest/api/3/issue/10001"}'
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        # Replace the client instance
        client.client = mock_client

        fields = {
            "project": {"key": "PROJ"},
            "summary": "Test issue with update",
            "description": "Test description",
            "issuetype": {"name": "Task"},
        }

        update = {"labels": [{"add": "urgent"}]}

        properties = [{"key": "test-property", "value": "test-value"}]

        result = await client.create_issue(
            fields=fields, update=update, properties=properties
        )

        # Verify the response
        assert result["id"] == "10001"
        assert result["key"] == "PROJ-124"

        # Verify the request was made with correct parameters
        mock_client.request.assert_called_once()
        call_args = mock_client.request.call_args

        # Verify the payload contains all optional parameters
        payload = call_args[1]["json"]
        assert payload["fields"] == fields
        assert payload["update"] == update
        assert payload["properties"] == properties

    @pytest.mark.asyncio
    async def test_v3_api_create_issue_missing_fields(self):
        """Test create issue with missing fields"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="fields is required"):
            await client.create_issue(fields=None)

    @pytest.mark.asyncio
    async def test_v3_api_create_issue_empty_fields(self):
        """Test create issue with empty fields dict"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="fields is required"):
            await client.create_issue(fields={})

```

--------------------------------------------------------------------------------
/tests/test_transition_issue_v3_api_only.py:
--------------------------------------------------------------------------------

```python
"""Test cases for transition_issue V3 API client only"""

import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient


class TestTransitionIssueV3API:
    """Test suite for transition_issue V3 API client"""

    @pytest.mark.asyncio
    async def test_v3_api_transition_issue_success(self):
        """Test successful transition issue request with V3 API"""
        # Mock 204 No Content response (standard for successful transitions)
        mock_response = Mock()
        mock_response.status_code = 204
        mock_response.json.return_value = {}
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        result = await client.transition_issue(
            issue_id_or_key="PROJ-123",
            transition_id="5"
        )

        # Verify the request was made correctly
        mock_client.request.assert_called_once()
        call_args = mock_client.request.call_args

        assert call_args[1]["method"] == "POST"
        assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]
        assert call_args[1]["json"]["transition"]["id"] == "5"
        assert result == {}

    @pytest.mark.asyncio
    async def test_v3_api_transition_issue_with_comment(self):
        """Test transition issue with comment"""
        # Mock 204 No Content response
        mock_response = Mock()
        mock_response.status_code = 204
        mock_response.json.return_value = {}
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        result = await client.transition_issue(
            issue_id_or_key="PROJ-123",
            transition_id="2",
            comment="Issue resolved successfully"
        )

        # Verify the request payload includes properly formatted comment
        call_args = mock_client.request.call_args
        payload = call_args[1]["json"]
        
        assert payload["transition"]["id"] == "2"
        assert "update" in payload
        assert "comment" in payload["update"]
        assert len(payload["update"]["comment"]) == 1
        
        comment_structure = payload["update"]["comment"][0]["add"]["body"]
        assert comment_structure["type"] == "doc"
        assert comment_structure["version"] == 1
        assert len(comment_structure["content"]) == 1
        assert comment_structure["content"][0]["type"] == "paragraph"
        assert comment_structure["content"][0]["content"][0]["text"] == "Issue resolved successfully"

    @pytest.mark.asyncio
    async def test_v3_api_transition_issue_with_fields(self):
        """Test transition issue with field updates"""
        # Mock 204 No Content response
        mock_response = Mock()
        mock_response.status_code = 204
        mock_response.json.return_value = {}
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        fields = {
            "assignee": {"name": "john.doe"},
            "resolution": {"name": "Fixed"}
        }

        result = await client.transition_issue(
            issue_id_or_key="PROJ-123",
            transition_id="3",
            fields=fields
        )

        # Verify the request payload includes fields
        call_args = mock_client.request.call_args
        payload = call_args[1]["json"]
        
        assert payload["transition"]["id"] == "3"
        assert payload["fields"] == fields

    @pytest.mark.asyncio
    async def test_v3_api_transition_issue_missing_issue_key(self):
        """Test transition issue with missing issue key"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="issue_id_or_key is required"):
            await client.transition_issue("", "5")

    @pytest.mark.asyncio
    async def test_v3_api_transition_issue_missing_transition_id(self):
        """Test transition issue with missing transition id"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="transition_id is required"):
            await client.transition_issue("PROJ-123", "")
```

--------------------------------------------------------------------------------
/tests/test_bulk_create_issues_v3_api.py:
--------------------------------------------------------------------------------

```python
"""Test cases for bulk_create_issues V3 API client only"""

import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient


class TestBulkCreateIssuesV3API:
    """Test suite for bulk_create_issues V3 API client"""

    @pytest.mark.asyncio
    async def test_v3_api_bulk_create_issues_success(self):
        """Test successful bulk create issues request with V3 API"""
        # Mock 201 Created response
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                },
                {
                    "id": "10001", 
                    "key": "PROJ-2",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10001"
                }
            ],
            "errors": []
        }
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        # Test data
        issue_updates = [
            {
                "fields": {
                    "project": {"key": "PROJ"},
                    "summary": "First test issue",
                    "description": {
                        "type": "doc",
                        "version": 1,
                        "content": [
                            {
                                "type": "paragraph",
                                "content": [{"type": "text", "text": "Test description"}]
                            }
                        ]
                    },
                    "issuetype": {"name": "Bug"}
                }
            },
            {
                "fields": {
                    "project": {"key": "PROJ"},
                    "summary": "Second test issue",
                    "description": {
                        "type": "doc",
                        "version": 1,
                        "content": [
                            {
                                "type": "paragraph",
                                "content": [{"type": "text", "text": "Another test description"}]
                            }
                        ]
                    },
                    "issuetype": {"name": "Task"}
                }
            }
        ]

        result = await client.bulk_create_issues(issue_updates)

        # Verify the request was made correctly
        mock_client.request.assert_called_once()
        call_args = mock_client.request.call_args

        assert call_args[1]["method"] == "POST"
        assert "/rest/api/3/issue/bulk" in call_args[1]["url"]
        assert call_args[1]["json"]["issueUpdates"] == issue_updates
        
        # Verify the response
        assert "issues" in result
        assert "errors" in result
        assert len(result["issues"]) == 2
        assert result["issues"][0]["key"] == "PROJ-1"
        assert result["issues"][1]["key"] == "PROJ-2"

    @pytest.mark.asyncio
    async def test_v3_api_bulk_create_issues_empty_list(self):
        """Test bulk create issues with empty list"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="issue_updates list cannot be empty"):
            await client.bulk_create_issues([])

    @pytest.mark.asyncio
    async def test_v3_api_bulk_create_issues_too_many(self):
        """Test bulk create issues with too many issues"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        # Create more than 50 issues
        issue_updates = [{"fields": {"project": {"key": "PROJ"}}}] * 51

        with pytest.raises(ValueError, match="Cannot create more than 50 issues"):
            await client.bulk_create_issues(issue_updates)

    @pytest.mark.asyncio
    async def test_v3_api_bulk_create_issues_with_errors(self):
        """Test bulk create issues response with some errors"""
        # Mock response with partial success
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                }
            ],
            "errors": [
                {
                    "failedElementNumber": 1,
                    "elementErrors": {
                        "errorMessages": ["Issue type 'InvalidType' does not exist."]
                    }
                }
            ]
        }
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        # Test data
        issue_updates = [
            {
                "fields": {
                    "project": {"key": "PROJ"},
                    "summary": "Valid issue",
                    "issuetype": {"name": "Bug"}
                }
            },
            {
                "fields": {
                    "project": {"key": "PROJ"},
                    "summary": "Invalid issue", 
                    "issuetype": {"name": "InvalidType"}
                }
            }
        ]

        result = await client.bulk_create_issues(issue_updates)

        # Verify we get both success and error results
        assert len(result["issues"]) == 1
        assert len(result["errors"]) == 1
        assert result["issues"][0]["key"] == "PROJ-1"
        assert "errorMessages" in result["errors"][0]["elementErrors"]
```

--------------------------------------------------------------------------------
/tests/test_add_comment_v3_api_only.py:
--------------------------------------------------------------------------------

```python
"""Test cases for add_comment V3 API client only"""

import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient


class TestAddCommentV3API:
    """Test suite for add_comment V3 API client"""

    @pytest.mark.asyncio
    async def test_v3_api_add_comment_success(self):
        """Test successful add comment request with V3 API"""
        # Mock successful response
        mock_response_data = {
            "id": "10000",
            "body": {
                "type": "doc",
                "version": 1,
                "content": [
                    {
                        "type": "paragraph",
                        "content": [
                            {
                                "type": "text",
                                "text": "This is a test comment"
                            }
                        ]
                    }
                ]
            },
            "author": {
                "accountId": "5b10a2844c20165700ede21g",
                "displayName": "Test User",
                "active": True
            },
            "created": "2021-01-17T12:34:00.000+0000",
            "updated": "2021-01-17T12:34:00.000+0000",
            "self": "https://test.atlassian.net/rest/api/3/issue/10010/comment/10000"
        }

        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = mock_response_data
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        result = await client.add_comment(
            issue_id_or_key="PROJ-123",
            comment="This is a test comment"
        )

        # Verify the request was made correctly
        call_args = mock_client.request.call_args
        assert call_args[1]["method"] == "POST"
        assert "https://test.atlassian.net/rest/api/3/issue/PROJ-123/comment" in call_args[1]["url"]
        
        # Verify the request payload
        payload = call_args[1]["json"]
        assert payload["body"]["type"] == "doc"
        assert payload["body"]["version"] == 1
        assert len(payload["body"]["content"]) == 1
        assert payload["body"]["content"][0]["type"] == "paragraph"
        assert payload["body"]["content"][0]["content"][0]["text"] == "This is a test comment"

        # Verify the response
        assert result == mock_response_data

    @pytest.mark.asyncio
    async def test_v3_api_add_comment_with_visibility(self):
        """Test add comment with visibility settings"""
        # Mock successful response
        mock_response_data = {
            "id": "10001",
            "body": {
                "type": "doc",
                "version": 1,
                "content": [
                    {
                        "type": "paragraph", 
                        "content": [
                            {
                                "type": "text",
                                "text": "Internal comment"
                            }
                        ]
                    }
                ]
            },
            "visibility": {
                "type": "role",
                "value": "Administrators"
            }
        }

        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = mock_response_data
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        visibility = {"type": "role", "value": "Administrators"}
        result = await client.add_comment(
            issue_id_or_key="PROJ-456",
            comment="Internal comment",
            visibility=visibility
        )

        # Verify the request payload includes visibility
        call_args = mock_client.request.call_args
        payload = call_args[1]["json"]
        
        assert "visibility" in payload
        assert payload["visibility"]["type"] == "role"
        assert payload["visibility"]["value"] == "Administrators"
        
        # Verify the response
        assert result == mock_response_data

    @pytest.mark.asyncio
    async def test_v3_api_add_comment_missing_issue_key(self):
        """Test add comment with missing issue key"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="issue_id_or_key is required"):
            await client.add_comment(
                issue_id_or_key="",
                comment="Test comment"
            )

    @pytest.mark.asyncio
    async def test_v3_api_add_comment_missing_comment(self):
        """Test add comment with missing comment text"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="comment is required"):
            await client.add_comment(
                issue_id_or_key="PROJ-123",
                comment=""
            )

    @pytest.mark.asyncio
    async def test_v3_api_add_comment_with_properties(self):
        """Test add comment with properties"""
        # Mock successful response
        mock_response_data = {
            "id": "10002",
            "body": {
                "type": "doc",
                "version": 1,
                "content": [
                    {
                        "type": "paragraph",
                        "content": [
                            {
                                "type": "text",
                                "text": "Comment with properties"
                            }
                        ]
                    }
                ]
            },
            "properties": [
                {
                    "key": "custom-property",
                    "value": "custom-value"
                }
            ]
        }

        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = mock_response_data
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        properties = [{"key": "custom-property", "value": "custom-value"}]
        result = await client.add_comment(
            issue_id_or_key="PROJ-789",
            comment="Comment with properties",
            properties=properties
        )

        # Verify the request payload includes properties
        call_args = mock_client.request.call_args
        payload = call_args[1]["json"]
        
        assert "properties" in payload
        assert len(payload["properties"]) == 1
        assert payload["properties"][0]["key"] == "custom-property"
        assert payload["properties"][0]["value"] == "custom-value"
        
        # Verify the response
        assert result == mock_response_data
```

--------------------------------------------------------------------------------
/tests/test_get_transitions_v3.py:
--------------------------------------------------------------------------------

```python
"""Test cases for get_transitions V3 API conversion"""

import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
from src.mcp_server_jira.server import JiraServer, JiraTransitionResult


class TestGetTransitionsV3APIConversion:
    """Test suite for get_transitions V3 API conversion"""

    @pytest.mark.asyncio
    async def test_v3_api_get_transitions_success(self):
        """Test successful get transitions request with V3 API"""
        # Mock response data matching Jira V3 API format
        mock_response_data = {
            "transitions": [
                {
                    "id": "2",
                    "name": "Close Issue",
                    "to": {
                        "id": "10000",
                        "name": "Done",
                        "description": "Issue is done"
                    },
                    "hasScreen": False,
                    "isAvailable": True
                },
                {
                    "id": "711",
                    "name": "QA Review",
                    "to": {
                        "id": "5",
                        "name": "In Review"
                    },
                    "hasScreen": True,
                    "isAvailable": True
                }
            ]
        }

        # Mock httpx response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = mock_response_data
        mock_response.text = str(mock_response_data)
        mock_response.raise_for_status.return_value = None

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with patch.object(client.client, 'request', new_callable=AsyncMock) as mock_request:
            mock_request.return_value = mock_response

            result = await client.get_transitions("PROJ-123")

            # Verify the result structure
            assert "transitions" in result
            assert len(result["transitions"]) == 2
            assert result["transitions"][0]["id"] == "2"
            assert result["transitions"][0]["name"] == "Close Issue"
            assert result["transitions"][1]["id"] == "711"
            assert result["transitions"][1]["name"] == "QA Review"

            # Verify the API call
            mock_request.assert_called_once()
            call_args = mock_request.call_args
            assert call_args[1]["method"] == "GET"
            assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]

    @pytest.mark.asyncio
    async def test_v3_api_get_transitions_with_parameters(self):
        """Test get transitions with query parameters"""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"transitions": []}
        mock_response.text = "{\"transitions\": []}"
        mock_response.raise_for_status.return_value = None

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with patch.object(client.client, 'request', new_callable=AsyncMock) as mock_request:
            mock_request.return_value = mock_response

            await client.get_transitions(
                issue_id_or_key="PROJ-123",
                expand="transitions.fields",
                transition_id="2",
                skip_remote_only_condition=True
            )

            # Verify the request parameters
            call_args = mock_request.call_args
            params = call_args[1]["params"]
            assert params["expand"] == "transitions.fields"
            assert params["transitionId"] == "2"
            assert params["skipRemoteOnlyCondition"] is True

    @pytest.mark.asyncio
    async def test_v3_api_get_transitions_missing_issue_key(self):
        """Test get transitions with missing issue key"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="issue_id_or_key is required"):
            await client.get_transitions("")

    @pytest.mark.asyncio
    async def test_jira_server_get_transitions_success(self):
        """Test JiraServer get_jira_transitions method"""
        mock_api_response = {
            "transitions": [
                {"id": "2", "name": "Close Issue"},
                {"id": "711", "name": "QA Review"},
                {"id": "31", "name": "Reopen Issue"}
            ]
        }

        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
            mock_get_transitions.return_value = mock_api_response

            result = await server.get_jira_transitions("PROJ-123")

            # Verify the result type and structure
            assert isinstance(result, list)
            assert len(result) == 3
            assert all(isinstance(t, JiraTransitionResult) for t in result)

            # Check specific transition details
            assert result[0].id == "2"
            assert result[0].name == "Close Issue"
            assert result[1].id == "711"
            assert result[1].name == "QA Review"
            assert result[2].id == "31"
            assert result[2].name == "Reopen Issue"

            # Verify the V3 API was called correctly
            mock_get_transitions.assert_called_once_with(issue_id_or_key="PROJ-123")

    @pytest.mark.asyncio
    async def test_jira_server_get_transitions_error_handling(self):
        """Test error handling in get_jira_transitions"""
        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
            mock_get_transitions.side_effect = Exception("API Error")

            with pytest.raises(ValueError) as exc_info:
                await server.get_jira_transitions("PROJ-123")

            assert "Failed to get transitions for PROJ-123" in str(exc_info.value)
            assert "API Error" in str(exc_info.value)

    @pytest.mark.asyncio
    async def test_jira_server_backward_compatibility(self):
        """Test that the new implementation maintains backward compatibility"""
        mock_api_response = {
            "transitions": [
                {"id": "2", "name": "Close Issue"},
                {"id": "711", "name": "QA Review"}
            ]
        }

        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with patch.object(server._v3_api_client, 'get_transitions', new_callable=AsyncMock) as mock_get_transitions:
            mock_get_transitions.return_value = mock_api_response

            result = await server.get_jira_transitions("PROJ-123")

            # Verify the return type matches the original interface
            assert isinstance(result, list)
            assert all(isinstance(t, JiraTransitionResult) for t in result)
            assert all(hasattr(t, 'id') and hasattr(t, 'name') for t in result)

            # Verify specific field types
            assert isinstance(result[0].id, str)
            assert isinstance(result[0].name, str)

    @pytest.mark.asyncio
    async def test_jira_server_method_is_async(self):
        """Test that get_jira_transitions is properly converted to async"""
        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        import inspect
        assert inspect.iscoroutinefunction(server.get_jira_transitions), \
            "get_jira_transitions should be an async method"
```

--------------------------------------------------------------------------------
/tests/test_create_issues_integration.py:
--------------------------------------------------------------------------------

```python
"""
Integration test for create_jira_issues V3 API conversion

This test verifies that the conversion from the legacy Jira Python SDK
to the v3 REST API is working correctly for bulk issue creation.

Run with: python -m pytest tests/test_create_issues_integration.py -v
"""

import pytest
from unittest.mock import Mock, AsyncMock, patch
from src.mcp_server_jira.server import JiraServer


class TestCreateIssuesIntegration:
    """Integration tests for the create_issues v3 API conversion"""

    @pytest.mark.asyncio
    async def test_full_integration_with_v3_api(self):
        """Test the full integration from server method to v3 API client"""
        # Mock successful v3 API response
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                },
                {
                    "id": "10001",
                    "key": "PROJ-2",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10001"
                }
            ],
            "errors": []
        }

        # Create mock v3 client
        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        # Create server instance
        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass"
        )

        # Patch the v3 client creation
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            # Test data representing a typical bulk creation request
            field_list = [
                {
                    "project": "PROJ",
                    "summary": "Implement user login functionality",
                    "description": "Add OAuth2 login with Google and GitHub providers",
                    "issue_type": "story",
                    "labels": ["authentication", "oauth"],
                    "priority": {"name": "High"}
                },
                {
                    "project": "PROJ",
                    "summary": "Fix mobile navigation bug",
                    "description": "Navigation menu not displaying on mobile devices",
                    "issue_type": "bug",
                    "assignee": {"name": "john.doe"}
                }
            ]

            # Execute the method
            result = await server.create_jira_issues(field_list, prefetch=True)

            # Verify v3 client was called
            mock_v3_client.bulk_create_issues.assert_called_once()
            
            # Verify the payload transformation
            call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
            assert len(call_args) == 2

            # Check first issue transformation
            issue1 = call_args[0]["fields"]
            assert issue1["project"]["key"] == "PROJ"
            assert issue1["summary"] == "Implement user login functionality"
            assert issue1["issuetype"]["name"] == "Story"  # Converted from "story"
            assert issue1["labels"] == ["authentication", "oauth"]
            assert issue1["priority"] == {"name": "High"}
            
            # Check ADF format for description
            assert issue1["description"]["type"] == "doc"
            assert "OAuth2 login" in str(issue1["description"])

            # Check second issue transformation
            issue2 = call_args[1]["fields"]
            assert issue2["project"]["key"] == "PROJ"
            assert issue2["summary"] == "Fix mobile navigation bug"
            assert issue2["issuetype"]["name"] == "Bug"  # Converted from "bug"
            assert issue2["assignee"] == {"name": "john.doe"}

            # Verify return format compatibility
            assert len(result) == 2
            assert result[0]["key"] == "PROJ-1"
            assert result[0]["id"] == "10000"
            assert result[0]["success"] is True
            assert result[1]["key"] == "PROJ-2"
            assert result[1]["success"] is True

    @pytest.mark.asyncio
    async def test_error_handling_integration(self):
        """Test error handling in the integrated flow"""
        # Mock v3 API response with partial errors
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                }
            ],
            "errors": [
                {
                    "failedElementNumber": 1,
                    "elementErrors": {
                        "errorMessages": ["Invalid issue type 'InvalidType'"]
                    }
                }
            ]
        }

        # Create mock v3 client
        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        # Create server instance
        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass"
        )

        # Patch the v3 client creation
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            field_list = [
                {
                    "project": "PROJ",
                    "summary": "Valid issue",
                    "description": "This should work",
                    "issue_type": "Bug"
                },
                {
                    "project": "PROJ",
                    "summary": "Invalid issue",
                    "description": "This should fail",
                    "issue_type": "InvalidType"
                }
            ]

            result = await server.create_jira_issues(field_list)

            # Should get both success and error results
            assert len(result) == 2
            
            # Find success and error entries
            success_results = [r for r in result if r.get("success")]
            error_results = [r for r in result if not r.get("success")]
            
            assert len(success_results) == 1
            assert len(error_results) == 1
            assert success_results[0]["key"] == "PROJ-1"
            assert "error" in error_results[0]

    @pytest.mark.asyncio
    async def test_backward_compatibility_with_legacy_format(self):
        """Test that the method maintains backward compatibility with existing usage"""
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                }
            ],
            "errors": []
        }

        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass"
        )

        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            # Test with both new and legacy field formats
            field_list = [
                {
                    # Using 'issuetype' field (legacy format)
                    "project": {"key": "PROJ"},  # Object format
                    "summary": "Legacy format issue",
                    "description": "Using legacy field formats",
                    "issuetype": {"name": "Bug"}  # Object format
                }
            ]

            result = await server.create_jira_issues(field_list)

            # Should work with legacy formats
            assert len(result) == 1
            assert result[0]["success"] is True
            assert result[0]["key"] == "PROJ-1"

            # Verify the payload was transformed correctly
            call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
            issue_fields = call_args[0]["fields"]
            
            # Legacy project object format should be preserved
            assert issue_fields["project"]["key"] == "PROJ"
            # Legacy issuetype object format should be preserved
            assert issue_fields["issuetype"]["name"] == "Bug"
```

--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------

```python
"""
Tests for the main server functionality.
"""

from unittest.mock import AsyncMock, Mock, patch

import pytest

from src.mcp_server_jira.server import JiraProjectResult, JiraServer


class TestJiraServer:
    """Test suite for JiraServer class"""

    def test_init_with_credentials(self):
        """Test JiraServer initialization with credentials"""
        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="token",
            username="testuser",
            token="testtoken",
        )

        assert server.server_url == "https://test.atlassian.net"
        assert server.auth_method == "token"
        assert server.username == "testuser"
        assert server.token == "testtoken"

    @patch.object(JiraServer, "_get_v3_api_client")
    def test_get_jira_projects(self, mock_get_v3_api_client):
        """Test getting Jira projects using v3 API"""
        # Setup mock v3 client
        mock_v3_client = Mock()
        mock_v3_client.get_projects.return_value = {
            "startAt": 0,
            "maxResults": 50,
            "total": 1,
            "isLast": True,
            "values": [
                {
                    "id": "123",
                    "key": "TEST",
                    "name": "Test Project",
                    "lead": {
                        "displayName": "John Doe"
                    }
                }
            ]
        }
        mock_get_v3_api_client.return_value = mock_v3_client

        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="token",
            username="testuser",
            token="testtoken",
        )

        # Call the method
        projects = server.get_jira_projects()

        # Verify results
        assert len(projects) == 1
        assert isinstance(projects[0], JiraProjectResult)
        assert projects[0].key == "TEST"
        assert projects[0].name == "Test Project"
        assert projects[0].id == "123"
        assert projects[0].lead == "John Doe"

        # Verify v3 client was called correctly
        mock_v3_client.get_projects.assert_called_with(
            start_at=0,
            max_results=50
        )

    @patch.object(JiraServer, "_get_v3_api_client")
    def test_get_jira_projects_pagination(self, mock_get_v3_api_client):
        """Test getting Jira projects with pagination"""
        # Setup mock v3 client with pagination
        mock_v3_client = Mock()
        
        # First page response
        page1_response = {
            "startAt": 0,
            "maxResults": 2,
            "total": 3,
            "isLast": False,
            "values": [
                {"id": "10000", "key": "TEST1", "name": "Test Project 1"},
                {"id": "10001", "key": "TEST2", "name": "Test Project 2"}
            ]
        }
        
        # Second page response  
        page2_response = {
            "startAt": 2,
            "maxResults": 2,
            "total": 3,
            "isLast": True,
            "values": [
                {"id": "10002", "key": "TEST3", "name": "Test Project 3"}
            ]
        }
        
        # Configure mock to return different responses for each call
        mock_v3_client.get_projects.side_effect = [page1_response, page2_response]
        mock_get_v3_api_client.return_value = mock_v3_client

        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="token",
            username="testuser",
            token="testtoken",
        )

        # Call the method
        projects = server.get_jira_projects()

        # Should have called get_projects twice due to pagination
        assert mock_v3_client.get_projects.call_count == 2
        
        # Should have collected all 3 projects
        assert len(projects) == 3
        assert projects[0].key == "TEST1"
        assert projects[1].key == "TEST2"
        assert projects[2].key == "TEST3"
        
        # Verify correct pagination parameters
        calls = mock_v3_client.get_projects.call_args_list
        assert calls[0][1]["start_at"] == 0
        assert calls[1][1]["start_at"] == 2

    @patch.object(JiraServer, "_get_v3_api_client")
    def test_create_jira_project_v3_api(self, mock_get_v3_api_client):
        """Test project creation using v3 API"""
        # Setup mock v3 client
        mock_v3_client = Mock()
        mock_v3_client.create_project.return_value = {
            "self": "https://test.atlassian.net/rest/api/3/project/10000",
            "id": "10000",
            "key": "TEST",
            "name": "Test Project",
        }
        mock_get_v3_api_client.return_value = mock_v3_client

        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="token",
            username="testuser",
            token="testtoken",
        )

        # Call the method
        result = server.create_jira_project(
            key="TEST", name="Test Project", ptype="software"
        )

        # Verify results
        assert isinstance(result, JiraProjectResult)
        assert result.key == "TEST"
        assert result.name == "Test Project"

        # Verify v3 client was called correctly
        mock_v3_client.create_project.assert_called_once_with(
            key="TEST",
            name="Test Project",
            assignee=None,
            ptype="software",
            template_name=None,
            avatarId=None,
            issueSecurityScheme=None,
            permissionScheme=None,
            projectCategory=None,
            notificationScheme=None,
            categoryId=None,
            url="",
        )

    @patch.object(JiraServer, "_get_v3_api_client")
    def test_create_jira_project_with_template(self, mock_get_v3_api_client):
        """Test project creation with template using v3 API"""
        # Setup mock v3 client
        mock_v3_client = Mock()
        mock_v3_client.create_project.return_value = {
            "self": "https://test.atlassian.net/rest/api/3/project/10000",
            "id": "10000",
            "key": "TEMP",
            "name": "Template Project",
        }
        mock_get_v3_api_client.return_value = mock_v3_client

        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="token",
            username="testuser",
            token="testtoken",
        )

        # Call the method with template
        result = server.create_jira_project(
            key="TEMP",
            name="Template Project",
            ptype="business",
            template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
            assignee="user123",
        )

        # Verify results
        assert isinstance(result, JiraProjectResult)
        assert result.key == "TEMP"
        assert result.name == "Template Project"

        # Verify v3 client was called with template parameters
        mock_v3_client.create_project.assert_called_once_with(
            key="TEMP",
            name="Template Project",
            assignee="user123",
            ptype="business",
            template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
            avatarId=None,
            issueSecurityScheme=None,
            permissionScheme=None,
            projectCategory=None,
            notificationScheme=None,
            categoryId=None,
            url="",
        )

    def test_get_v3_api_client(self):
        """Test v3 client creation"""
        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="token",
            username="testuser",
            token="testtoken",
        )

        client = server._get_v3_api_client()

        assert client.server_url == "https://test.atlassian.net"
        assert client.username == "testuser"
        assert client.token == "testtoken"
        assert client.password is None

    def test_get_v3_api_client_with_password(self):
        """Test v3 client creation with password"""
        server = JiraServer(
            server_url="https://test.atlassian.net",
            auth_method="basic",
            username="testuser",
            password="testpass",
        )

        client = server._get_v3_api_client()

        assert client.server_url == "https://test.atlassian.net"
        assert client.username == "testuser"
        assert client.password == "testpass"
        assert client.token is None

```

--------------------------------------------------------------------------------
/bin/Activate.ps1:
--------------------------------------------------------------------------------

```
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.

.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.

.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.

.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').

.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.

.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.

.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.

.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.

.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:

PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

For more information on Execution Policies: 
https://go.microsoft.com/fwlink/?LinkID=135170

#>
Param(
    [Parameter(Mandatory = $false)]
    [String]
    $VenvDir,
    [Parameter(Mandatory = $false)]
    [String]
    $Prompt
)

<# Function declarations --------------------------------------------------- #>

<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.

.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.

#>
function global:deactivate ([switch]$NonDestructive) {
    # Revert to original values

    # The prior prompt:
    if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
        Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
        Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
    }

    # The prior PYTHONHOME:
    if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
        Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
        Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
    }

    # The prior PATH:
    if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
        Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
        Remove-Item -Path Env:_OLD_VIRTUAL_PATH
    }

    # Just remove the VIRTUAL_ENV altogether:
    if (Test-Path -Path Env:VIRTUAL_ENV) {
        Remove-Item -Path env:VIRTUAL_ENV
    }

    # Just remove VIRTUAL_ENV_PROMPT altogether.
    if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
        Remove-Item -Path env:VIRTUAL_ENV_PROMPT
    }

    # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
    if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
        Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
    }

    # Leave deactivate function in the global namespace if requested:
    if (-not $NonDestructive) {
        Remove-Item -Path function:deactivate
    }
}

<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.

For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.

If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.

.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
    [String]
    $ConfigDir
) {
    Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"

    # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
    $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue

    # An empty map will be returned if no config file is found.
    $pyvenvConfig = @{ }

    if ($pyvenvConfigPath) {

        Write-Verbose "File exists, parse `key = value` lines"
        $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath

        $pyvenvConfigContent | ForEach-Object {
            $keyval = $PSItem -split "\s*=\s*", 2
            if ($keyval[0] -and $keyval[1]) {
                $val = $keyval[1]

                # Remove extraneous quotations around a string value.
                if ("'""".Contains($val.Substring(0, 1))) {
                    $val = $val.Substring(1, $val.Length - 2)
                }

                $pyvenvConfig[$keyval[0]] = $val
                Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
            }
        }
    }
    return $pyvenvConfig
}


<# Begin Activate script --------------------------------------------------- #>

# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath

Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"

# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
    Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
    Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
    $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
    Write-Verbose "VenvDir=$VenvDir"
}

# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir

# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
    Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
    Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
    if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
        Write-Verbose "  Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
        $Prompt = $pyvenvCfg['prompt'];
    }
    else {
        Write-Verbose "  Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
        Write-Verbose "  Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
        $Prompt = Split-Path -Path $venvDir -Leaf
    }
}

Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"

# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive

# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir

if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {

    Write-Verbose "Setting prompt to '$Prompt'"

    # Set the prompt to include the env name
    # Make sure _OLD_VIRTUAL_PROMPT is global
    function global:_OLD_VIRTUAL_PROMPT { "" }
    Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
    New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt

    function global:prompt {
        Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
        _OLD_VIRTUAL_PROMPT
    }
    $env:VIRTUAL_ENV_PROMPT = $Prompt
}

# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
    Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
    Remove-Item -Path Env:PYTHONHOME
}

# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

```

--------------------------------------------------------------------------------
/tests/test_create_jira_issues_server.py:
--------------------------------------------------------------------------------

```python
"""Test cases for create_jira_issues server method using V3 API"""

import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest

from src.mcp_server_jira.server import JiraServer


class TestCreateJiraIssuesServer:
    """Test suite for create_jira_issues server method"""

    @pytest.mark.asyncio
    async def test_create_jira_issues_server_success(self):
        """Test successful create_jira_issues through server"""
        # Mock the v3 API client response
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                },
                {
                    "id": "10001",
                    "key": "PROJ-2", 
                    "self": "https://test.atlassian.net/rest/api/3/issue/10001"
                }
            ],
            "errors": []
        }

        # Mock the v3 client
        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        # Create server instance
        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass"
        )

        # Mock the _get_v3_api_client method
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            # Test data
            field_list = [
                {
                    "project": "PROJ",
                    "summary": "First test issue",
                    "description": "Test description",
                    "issue_type": "Bug"
                },
                {
                    "project": "PROJ", 
                    "summary": "Second test issue",
                    "description": "Another test description",
                    "issue_type": "Task"
                }
            ]

            result = await server.create_jira_issues(field_list)

            # Verify the v3 client was called correctly
            mock_v3_client.bulk_create_issues.assert_called_once()
            call_args = mock_v3_client.bulk_create_issues.call_args[0][0]

            # Check the transformed data structure
            assert len(call_args) == 2
            assert call_args[0]["fields"]["project"]["key"] == "PROJ"
            assert call_args[0]["fields"]["summary"] == "First test issue"
            assert call_args[0]["fields"]["issuetype"]["name"] == "Bug"
            
            # Check ADF format for description
            assert call_args[0]["fields"]["description"]["type"] == "doc"
            assert call_args[0]["fields"]["description"]["version"] == 1
            assert "Test description" in str(call_args[0]["fields"]["description"])

            # Verify the return format matches the original interface
            assert len(result) == 2
            assert result[0]["key"] == "PROJ-1"
            assert result[0]["id"] == "10000"
            assert result[0]["success"] is True
            assert result[1]["key"] == "PROJ-2"
            assert result[1]["success"] is True

    @pytest.mark.asyncio
    async def test_create_jira_issues_missing_required_fields(self):
        """Test create_jira_issues with missing required fields"""
        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser", 
            password="testpass"
        )

        # Test missing project
        with pytest.raises(ValueError, match="Each issue must have a 'project' field"):
            await server.create_jira_issues([
                {
                    "summary": "Test issue",
                    "description": "Test description",
                    "issue_type": "Bug"
                }
            ])

        # Test missing summary
        with pytest.raises(ValueError, match="Each issue must have a 'summary' field"):
            await server.create_jira_issues([
                {
                    "project": "PROJ",
                    "description": "Test description", 
                    "issue_type": "Bug"
                }
            ])

        # Test missing issue type
        with pytest.raises(ValueError, match="Each issue must have an 'issuetype' or 'issue_type' field"):
            await server.create_jira_issues([
                {
                    "project": "PROJ",
                    "summary": "Test issue",
                    "description": "Test description"
                }
            ])

    @pytest.mark.asyncio
    async def test_create_jira_issues_issue_type_conversion(self):
        """Test issue type conversion for common types"""
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                }
            ],
            "errors": []
        }

        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass" 
        )

        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            # Test lowercase issue type conversion
            field_list = [
                {
                    "project": "PROJ",
                    "summary": "Test issue",
                    "description": "Test description",
                    "issue_type": "bug"  # lowercase
                }
            ]

            await server.create_jira_issues(field_list)

            # Verify issue type was converted to proper case
            call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
            assert call_args[0]["fields"]["issuetype"]["name"] == "Bug"

    @pytest.mark.asyncio
    async def test_create_jira_issues_description_adf_conversion(self):
        """Test that string descriptions are converted to ADF format"""
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1", 
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                }
            ],
            "errors": []
        }

        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass"
        )

        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            field_list = [
                {
                    "project": "PROJ",
                    "summary": "Test issue",
                    "description": "Simple text description",
                    "issue_type": "Bug"
                }
            ]

            await server.create_jira_issues(field_list)

            # Verify description was converted to ADF format
            call_args = mock_v3_client.bulk_create_issues.call_args[0][0]
            description = call_args[0]["fields"]["description"]
            
            assert description["type"] == "doc"
            assert description["version"] == 1
            assert len(description["content"]) == 1
            assert description["content"][0]["type"] == "paragraph"
            assert description["content"][0]["content"][0]["text"] == "Simple text description"

    @pytest.mark.asyncio
    async def test_create_jira_issues_with_errors_in_response(self):
        """Test create_jira_issues handling of error responses"""
        mock_v3_response = {
            "issues": [
                {
                    "id": "10000",
                    "key": "PROJ-1",
                    "self": "https://test.atlassian.net/rest/api/3/issue/10000"
                }
            ],
            "errors": [
                {
                    "failedElementNumber": 1,
                    "elementErrors": {
                        "errorMessages": ["Invalid issue type"]
                    }
                }
            ]
        }

        mock_v3_client = AsyncMock()
        mock_v3_client.bulk_create_issues.return_value = mock_v3_response

        server = JiraServer(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass"
        )

        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            field_list = [
                {
                    "project": "PROJ",
                    "summary": "Valid issue",
                    "description": "Valid description",
                    "issue_type": "Bug"
                },
                {
                    "project": "PROJ",
                    "summary": "Invalid issue",
                    "description": "Invalid description", 
                    "issue_type": "InvalidType"
                }
            ]

            result = await server.create_jira_issues(field_list)

            # Should have one success and one error
            assert len(result) == 2
            
            # Find success and error results
            success_results = [r for r in result if r.get("success")]
            error_results = [r for r in result if not r.get("success")]
            
            assert len(success_results) == 1
            assert len(error_results) == 1
            assert success_results[0]["key"] == "PROJ-1"
            assert "error" in error_results[0]
```

--------------------------------------------------------------------------------
/tests/test_jira_v3_api.py:
--------------------------------------------------------------------------------

```python
# pylint: disable=import-error, protected-access
"""
Tests for the Jira v3 API client functionality.
"""


from unittest.mock import Mock, patch

import pytest  # pylint: disable=import-error

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient


class TestJiraV3APIClient:
    """Test suite for JiraV3APIClient"""

    def test_init_with_username_password(self):
        """Test initialization with username and password"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            password="testpass",
        )
        assert client.server_url == "https://test.atlassian.net"
        assert client.username == "testuser"
        assert client.password == "testpass"
        assert client.token is None

    def test_init_with_token(self):
        """Test initialization with token only"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net", token="test-token"
        )
        assert client.server_url == "https://test.atlassian.net"
        assert client.username is None
        assert client.password is None
        assert client.token == "test-token"

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_make_v3_api_request_success(self, mock_request):
        """Test successful API request"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"key": "TEST", "name": "Test Project"}
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        result = client._make_v3_api_request("POST", "/project", {"test": "data"})

        assert result == {"key": "TEST", "name": "Test Project"}
        mock_request.assert_called_once()

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_make_v3_api_request_error(self, mock_request):
        """Test API request with error response"""
        # Setup mock error response
        mock_response = Mock()
        mock_response.status_code = 400
        mock_response.json.return_value = {"errorMessages": ["Bad request"]}
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="HTTP 400"):
            client._make_v3_api_request("POST", "/project", {"test": "data"})

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_create_project_success(self, mock_request):
        """Test successful project creation"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            "self": "https://test.atlassian.net/rest/api/3/project/10000",
            "id": "10000",
            "key": "TEST",
            "name": "Test Project",
        }
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        result = client.create_project(
            key="TEST", name="Test Project", ptype="software"
        )

        assert result["key"] == "TEST"
        assert result["name"] == "Test Project"
        mock_request.assert_called_once()

        # Verify the request was made with correct data
        call_args = mock_request.call_args
        assert call_args[1]["method"] == "POST"
        assert "/rest/api/3/project" in call_args[1]["url"]

        # Check the request body
        request_data = call_args[1]["json"]
        assert request_data["key"] == "TEST"
        assert request_data["name"] == "Test Project"
        assert request_data["projectTypeKey"] == "software"
        assert request_data["assigneeType"] == "PROJECT_LEAD"

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_create_project_with_template(self, mock_request):
        """Test project creation with template"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            "self": "https://test.atlassian.net/rest/api/3/project/10000",
            "id": "10000",
            "key": "TEMP",
            "name": "Template Project",
        }
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        result = client.create_project(
            key="TEMP",
            name="Template Project",
            ptype="business",
            template_name="com.atlassian.jira-core-project-templates:jira-core-project-management",
            assignee="user123",
        )

        assert result["key"] == "TEMP"
        mock_request.assert_called_once()

        # Verify the request data includes template information
        call_args = mock_request.call_args
        request_data = call_args[1]["json"]
        assert (
            request_data["projectTemplateKey"]
            == "com.atlassian.jira-core-project-templates:jira-core-project-management"
        )
        assert request_data["leadAccountId"] == "user123"
        assert request_data["projectTypeKey"] == "business"

    def test_create_project_missing_key(self):
        """Test project creation with missing key"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="Project key is required"):
            client.create_project(key="")

    def test_create_project_missing_assignee(self):
        """Test project creation with missing assignee"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="Parameter 'assignee'"):
            client.create_project(key="TEST")

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_authentication_username_token(self, mock_request):
        """Test authentication with username and token"""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"test": "data"}
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        client._make_v3_api_request("GET", "/test")

        call_args = mock_request.call_args
        auth = call_args[1]["auth"]
        assert auth == ("testuser", "testtoken")

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_authentication_token_only(self, mock_request):
        """Test authentication with token only"""
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"test": "data"}
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net", token="testtoken"
        )

        client._make_v3_api_request("GET", "/test")

        call_args = mock_request.call_args
        headers = call_args[1]["headers"]
        assert "Authorization" in headers
        assert headers["Authorization"] == "Bearer testtoken"

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_get_projects_success(self, mock_request):
        """Test successful get projects request"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "startAt": 0,
            "maxResults": 50,
            "total": 2,
            "isLast": True,
            "values": [
                {
                    "id": "10000",
                    "key": "TEST",
                    "name": "Test Project",
                    "lead": {"displayName": "John Doe"}
                },
                {
                    "id": "10001",
                    "key": "DEMO", 
                    "name": "Demo Project",
                    "lead": {"displayName": "Jane Smith"}
                }
            ]
        }
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        result = client.get_projects()

        assert result["total"] == 2
        assert len(result["values"]) == 2
        assert result["values"][0]["key"] == "TEST"
        assert result["values"][1]["key"] == "DEMO"
        mock_request.assert_called_once()

        # Verify the request was made to the correct endpoint
        call_args = mock_request.call_args
        assert call_args[1]["method"] == "GET"
        assert "/rest/api/3/project/search" in call_args[1]["url"]

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_get_projects_with_parameters(self, mock_request):
        """Test get projects with query parameters"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "startAt": 10,
            "maxResults": 20,
            "total": 50,
            "isLast": False,
            "values": []
        }
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        result = client.get_projects(
            start_at=10,
            max_results=20,
            order_by="name",
            query="test",
            keys=["PROJ1", "PROJ2"]
        )

        assert result["startAt"] == 10
        assert result["maxResults"] == 20
        mock_request.assert_called_once()

        # Verify the request URL includes query parameters
        call_args = mock_request.call_args
        url = call_args[1]["url"]
        assert "startAt=10" in url
        assert "maxResults=20" in url
        assert "orderBy=name" in url
        assert "query=test" in url
        assert "keys=PROJ1,PROJ2" in url

    @patch("src.mcp_server_jira.jira_v3_api.requests.request")
    def test_get_projects_error(self, mock_request):
        """Test get projects with error response"""
        # Setup mock error response
        mock_response = Mock()
        mock_response.status_code = 401
        mock_response.json.return_value = {"errorMessages": ["Unauthorized"]}
        mock_request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="HTTP 401"):
            client.get_projects()

    @patch("src.mcp_server_jira.jira_v3_api.httpx.AsyncClient")
    async def test_get_transitions_success(self, mock_async_client):
        """Test successful get transitions request"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "transitions": [
                {
                    "id": "2",
                    "name": "Close Issue",
                    "to": {
                        "id": "10000",
                        "name": "Done",
                        "description": "Issue is done"
                    },
                    "hasScreen": False,
                    "isAvailable": True,
                    "isConditional": False,
                    "isGlobal": False,
                    "isInitial": False
                },
                {
                    "id": "711",
                    "name": "QA Review",
                    "to": {
                        "id": "5",
                        "name": "In Review",
                        "description": "Issue is under review"
                    },
                    "hasScreen": True,
                    "isAvailable": True,
                    "isConditional": False,
                    "isGlobal": False,
                    "isInitial": False
                }
            ]
        }
        mock_response.raise_for_status.return_value = None
        
        # Setup mock client
        mock_client_instance = Mock()
        mock_client_instance.request.return_value = mock_response
        mock_async_client.return_value = mock_client_instance

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        result = await client.get_transitions("PROJ-123")

        assert "transitions" in result
        assert len(result["transitions"]) == 2
        assert result["transitions"][0]["id"] == "2"
        assert result["transitions"][0]["name"] == "Close Issue"
        assert result["transitions"][1]["id"] == "711"
        assert result["transitions"][1]["name"] == "QA Review"
        
        # Verify the request was made with correct parameters
        mock_client_instance.request.assert_called_once()
        call_args = mock_client_instance.request.call_args
        assert call_args[1]["method"] == "GET"
        assert "/rest/api/3/issue/PROJ-123/transitions" in call_args[1]["url"]

    @patch("src.mcp_server_jira.jira_v3_api.httpx.AsyncClient")
    async def test_get_transitions_with_parameters(self, mock_async_client):
        """Test get transitions with query parameters"""
        # Setup mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"transitions": []}
        mock_response.raise_for_status.return_value = None
        
        # Setup mock client
        mock_client_instance = Mock()
        mock_client_instance.request.return_value = mock_response
        mock_async_client.return_value = mock_client_instance

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        await client.get_transitions(
            issue_id_or_key="PROJ-123",
            expand="transitions.fields",
            transition_id="2",
            skip_remote_only_condition=True,
            include_unavailable_transitions=False,
            sort_by_ops_bar_and_status=True
        )

        # Verify the request was made with correct parameters
        mock_client_instance.request.assert_called_once()
        call_args = mock_client_instance.request.call_args
        assert call_args[1]["method"] == "GET"
        
        params = call_args[1]["params"]
        assert params["expand"] == "transitions.fields"
        assert params["transitionId"] == "2"
        assert params["skipRemoteOnlyCondition"] is True
        assert params["includeUnavailableTransitions"] is False
        assert params["sortByOpsBarAndStatus"] is True

    async def test_get_transitions_missing_issue_key(self):
        """Test get transitions with missing issue key"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken",
        )

        with pytest.raises(ValueError, match="issue_id_or_key is required"):
            await client.get_transitions("")

```

--------------------------------------------------------------------------------
/claude_reference/jira_examples.txt:
--------------------------------------------------------------------------------

```
2. Examples
Here’s a quick usage example:

# This script shows how to use the client in anonymous mode
# against jira.atlassian.com.
from __future__ import annotations

import re

from jira import JIRA

# By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK
# (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details).
jira = JIRA(server="https://jira.atlassian.com")

# Get all projects viewable by anonymous users.
projects = jira.projects()

# Sort available project keys, then return the second, third, and fourth keys.
keys = sorted(project.key for project in projects)[2:5]

# Get an issue.
issue = jira.issue("JRA-1330")
# Find all comments made by Atlassians on this issue.
atl_comments = [
    comment
    for comment in issue.fields.comment.comments
    if re.search(r"@atlassian.com$", comment.author.key)
]

# Add a comment to the issue.
jira.add_comment(issue, "Comment text")

# Change the issue's summary and description.
issue.update(
    summary="I'm different!", description="Changed the summary to be different."
)

# Change the issue without sending updates
issue.update(notify=False, description="Quiet summary update.")

# You can update the entire labels field like this
issue.update(fields={"labels": ["AAA", "BBB"]})

# Or modify the List of existing labels. The new label is unicode with no
# spaces
issue.fields.labels.append("new_text")
issue.update(fields={"labels": issue.fields.labels})

# Send the issue away for good.
issue.delete()

# Linking a remote jira issue (needs applinks to be configured to work)
issue = jira.issue("JRA-1330")
issue2 = jira.issue("XX-23")  # could also be another instance
jira.add_remote_link(issue.id, issue2)
Another example with methods to authenticate with your Jira:

"""Some simple authentication examples."""

from __future__ import annotations

from collections import Counter

from jira import JIRA
from jira.client import ResultList
from jira.resources import Issue

# Some Authentication Methods
jira = JIRA(
    basic_auth=("admin", "admin"),  # a username/password tuple [Not recommended]
    # basic_auth=("email", "API token"),  # Jira Cloud: a username/token tuple
    # token_auth="API token",  # Self-Hosted Jira (e.g. Server): the PAT token
    # auth=("admin", "admin"),  # a username/password tuple for cookie auth [Not recommended]
)

# Who has authenticated
myself = jira.myself()

# Get the mutable application properties for this server (requires
# jira-system-administrators permission)
props = jira.application_properties()

# Find all issues reported by the admin
issues: ResultList[Issue] = jira.search_issues("assignee=admin")

# Find the top three projects containing issues reported by admin
top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3)
This example shows how to work with Jira Agile / Jira Software (formerly GreenHopper):

# This script shows how to use the client in anonymous mode
# against jira.atlassian.com.
from __future__ import annotations

from jira.client import JIRA

# By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK
# (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details).
# Override this with the options parameter.
jira = JIRA(server="https://jira.atlassian.com")

# Get all boards viewable by anonymous users.
boards = jira.boards()

# Get the sprints in a specific board
board_id = 441
print(f"JIRA board: {boards[0].name} ({board_id})")
sprints = jira.sprints(board_id)
2.1. Quickstart
2.1.1. Initialization
Everything goes through the jira.client.JIRA object, so make one:

from jira import JIRA

jira = JIRA()
This connects to a Jira started on your local machine at http://localhost:2990/jira, which not coincidentally is the default address for a Jira instance started from the Atlassian Plugin SDK.

You can manually set the Jira server to use:

jira = JIRA('https://jira.atlassian.com')
2.1.2. Authentication
At initialization time, jira-python can optionally create an HTTP BASIC or use OAuth 1.0a access tokens for user authentication. These sessions will apply to all subsequent calls to the jira.client.JIRA object.

The library is able to load the credentials from inside the ~/.netrc file, so put them there instead of keeping them in your source code.

2.1.2.1. Cookie Based Authentication
Warning

This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice here.

For Jira Cloud use the basic_auth= (username, api_token) authentication

Pass a tuple of (username, password) to the auth constructor argument:

auth_jira = JIRA(auth=('username', 'password'))
Using this method, authentication happens during the initialization of the object. If the authentication is successful, the retrieved session cookie will be used in future requests. Upon cookie expiration, authentication will happen again transparently.

2.1.2.2. HTTP BASIC
2.1.2.2.1. (username, password)
Warning

This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice here

For Jira Cloud use the basic_auth= (username, api_token) authentication. For Self Hosted Jira (Server, Data Center), consider the Token Auth authentication.

Pass a tuple of (username, password) to the basic_auth constructor argument:

auth_jira = JIRA(basic_auth=('username', 'password'))
2.1.2.2.2. (username, api_token)
Or pass a tuple of (email, api_token) to the basic_auth constructor argument (JIRA Cloud):

auth_jira = JIRA(basic_auth=('email', 'API token'))
See also

For Self Hosted Jira (Server, Data Center), refer to the Token Auth Section.

2.1.2.3. OAuth
Pass a dict of OAuth properties to the oauth constructor argument:

# all values are samples and won't work in your code!
key_cert_data = None
with open(key_cert, 'r') as key_cert_file:
    key_cert_data = key_cert_file.read()

oauth_dict = {
    'access_token': 'foo',
    'access_token_secret': 'bar',
    'consumer_key': 'jira-oauth-consumer',
    'key_cert': key_cert_data
}
auth_jira = JIRA(oauth=oauth_dict)
Note

The OAuth access tokens must be obtained and authorized ahead of time through the standard OAuth dance. For interactive use, jirashell can perform the dance with you if you don’t already have valid tokens.

The access token and token secret uniquely identify the user.

The consumer key must match the OAuth provider configured on the Jira server.

The key cert data must be the private key that matches the public key configured on the Jira server’s OAuth provider.

See https://confluence.atlassian.com/display/JIRA/Configuring+OAuth+Authentication+for+an+Application+Link for details on configuring an OAuth provider for Jira.

2.1.2.4. Token Auth
2.1.2.4.1. Jira Cloud
This is also referred to as an API Token in the Jira Cloud documentation

auth_jira = JIRA(basic_auth=('email', 'API token'))
2.1.2.4.2. Jira Self Hosted (incl. Jira Server/Data Center)
This is also referred to as Personal Access Tokens (PATs) in the Self-Hosted Documentation. The is available from Jira Core >= 8.14:

auth_jira = JIRA(token_auth='API token')
2.1.2.5. Kerberos
To enable Kerberos auth, set kerberos=True:

auth_jira = JIRA(kerberos=True)
To pass additional options to Kerberos auth use dict kerberos_options, e.g.:

auth_jira = JIRA(kerberos=True, kerberos_options={'mutual_authentication': 'DISABLED'})
2.1.3. Headers
Headers can be provided to the internally used requests.Session. If the user provides a header that the jira.client.JIRA also attempts to set, the user provided header will take preference.

For example if you want to use a custom User Agent:

from requests_toolbelt import user_agent

jira = JIRA(
    basic_auth=("email", "API token"),
    options={"headers": {"User-Agent": user_agent("my_package", "0.0.1")}},
)
2.1.4. Issues
Issues are objects. You get hold of them through the JIRA object:

issue = jira.issue('JRA-1330')
Issue JSON is marshaled automatically and used to augment the returned Issue object, so you can get direct access to fields:

summary = issue.fields.summary         # 'Field level security permissions'
votes = issue.fields.votes.votes       # 440 (at least)
If you only want a few specific fields, save time by asking for them explicitly:

issue = jira.issue('JRA-1330', fields='summary,comment')
Reassign an issue:

# requires issue assign permission, which is different from issue editing permission!
jira.assign_issue(issue, 'newassignee')
If you want to unassign it again, just do:

jira.assign_issue(issue, None)
Creating issues is easy:

new_issue = jira.create_issue(project='PROJ_key_or_id', summary='New issue from jira-python',
                              description='Look into this one', issuetype={'name': 'Bug'})
Or you can use a dict:

issue_dict = {
    'project': {'id': 123},
    'summary': 'New issue from jira-python',
    'description': 'Look into this one',
    'issuetype': {'name': 'Bug'},
}
new_issue = jira.create_issue(fields=issue_dict)
You can even bulk create multiple issues:

issue_list = [
{
    'project': {'id': 123},
    'summary': 'First issue of many',
    'description': 'Look into this one',
    'issuetype': {'name': 'Bug'},
},
{
    'project': {'key': 'FOO'},
    'summary': 'Second issue',
    'description': 'Another one',
    'issuetype': {'name': 'Bug'},
},
{
    'project': {'name': 'Bar'},
    'summary': 'Last issue',
    'description': 'Final issue of batch.',
    'issuetype': {'name': 'Bug'},
}]
issues = jira.create_issues(field_list=issue_list)
Note

Project, summary, description and issue type are always required when creating issues. Your Jira may require additional fields for creating issues; see the jira.createmeta method for getting access to that information.

Note

Using bulk create will not throw an exception for a failed issue creation. It will return a list of dicts that each contain a possible error signature if that issue had invalid fields. Successfully created issues will contain the issue object as a value of the issue key.

You can also update an issue’s fields with keyword arguments:

issue.update(summary='new summary', description='A new summary was added')
issue.update(assignee={'name': 'new_user'})    # reassigning in update requires issue edit permission
or with a dict of new field values:

issue.update(fields={'summary': 'new summary', 'description': 'A new summary was added'})
You can suppress notifications:

issue.update(notify=False, description='A quiet description change was made')
and when you’re done with an issue, you can send it to the great hard drive in the sky:

issue.delete()
Updating components:

existingComponents = []
for component in issue.fields.components:
    existingComponents.append({"name" : component.name})
issue.update(fields={"components": existingComponents})
2.1.4.1. Working with Rich Text
You can use rich text in an issue’s description or comment. In order to use rich text, the body content needs to be formatted using the Atlassian Document Format (ADF):

jira = JIRA(basic_auth=("email", "API token"))
comment = {
    "type": "doc",
    "version": 1,
    "content": [
      {
        "type": "codeBlock",
        "content": [
          {
            "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.",
            "type": "text"
          }
        ]
      }
    ]
  }
jira.add_comment("AB-123", comment)
2.1.5. Fields
Example for accessing the worklogs:

issue.fields.worklog.worklogs                                 # list of Worklog objects
issue.fields.worklog.worklogs[0].author
issue.fields.worklog.worklogs[0].comment
issue.fields.worklog.worklogs[0].created
issue.fields.worklog.worklogs[0].id
issue.fields.worklog.worklogs[0].self
issue.fields.worklog.worklogs[0].started
issue.fields.worklog.worklogs[0].timeSpent
issue.fields.worklog.worklogs[0].timeSpentSeconds
issue.fields.worklog.worklogs[0].updateAuthor                # dictionary
issue.fields.worklog.worklogs[0].updated


issue.fields.timetracking.remainingEstimate           # may be NULL or string ("0m", "2h"...)
issue.fields.timetracking.remainingEstimateSeconds    # may be NULL or integer
issue.fields.timetracking.timeSpent                   # may be NULL or string
issue.fields.timetracking.timeSpentSeconds            # may be NULL or integer
2.1.6. Searching
Leverage the power of JQL to quickly find the issues you want:

# Search returns first 50 results, `maxResults` must be set to exceed this
issues_in_proj = jira.search_issues('project=PROJ')
all_proj_issues_but_mine = jira.search_issues('project=PROJ and assignee != currentUser()')

# my top 5 issues due by the end of the week, ordered by priority
oh_crap = jira.search_issues('assignee = currentUser() and due < endOfWeek() order by priority desc', maxResults=5)

# Summaries of my last 3 reported issues
for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3):
    print('{}: {}'.format(issue.key, issue.fields.summary))
2.1.7. Comments
Comments, like issues, are objects. Access issue comments through the parent Issue object or the JIRA object’s dedicated method:

comments_a = issue.fields.comment.comments
comments_b = jira.comments(issue) # comments_b == comments_a
Obtain an individual comment if you know its ID:

comment = jira.comment('JRA-1330', '10234')
Obtain comment author name and comment creation timestamp if you know its ID:

author = jira.comment('JRA-1330', '10234').author.displayName
time = jira.comment('JRA-1330', '10234').created
Adding, editing and deleting comments is similarly straightforward:

comment = jira.add_comment('JRA-1330', 'new comment')    # no Issue object required
comment = jira.add_comment(issue, 'new comment', visibility={'type': 'role', 'value': 'Administrators'})  # for admins only

comment.update(body='updated comment body')
comment.update(body='updated comment body but no mail notification', notify=False)
comment.delete()
Get all images from a comment:

issue = jira.issue('JRA-1330')
regex_for_png = re.compile(r'\!(\S+?\.(jpg|png|bmp))\|?\S*?\!')
pngs_used_in_comment = regex_for_png.findall(issue.fields.comment.comments[0].body)
for attachment in issue.fields.attachment:
    if attachment.filename in pngs_used_in_comment:
        with open(attachment.filename, 'wb') as f:
            f.write(attachment.get())
2.1.8. Transitions
Learn what transitions are available on an issue:

issue = jira.issue('PROJ-1')
transitions = jira.transitions(issue)
[(t['id'], t['name']) for t in transitions]    # [(u'5', u'Resolve Issue'), (u'2', u'Close Issue')]
Note

Only the transitions available to the currently authenticated user will be returned!

Then perform a transition on an issue:

# Resolve the issue and assign it to 'pm_user' in one step
jira.transition_issue(issue, '5', assignee={'name': 'pm_user'}, resolution={'id': '3'})

# The above line is equivalent to:
jira.transition_issue(issue, '5', fields={'assignee':{'name': 'pm_user'}, 'resolution':{'id': '3'}})
2.1.9. Projects
Projects are objects, just like issues:

projects = jira.projects()
Also, just like issue objects, project objects are augmented with their fields:

jra = jira.project('JRA')
print(jra.name)                 # 'JIRA'
print(jra.lead.displayName)     # 'John Doe [ACME Inc.]'
It’s no trouble to get the components, versions or roles either (assuming you have permission):

components = jira.project_components(jra)
[c.name for c in components]                # 'Accessibility', 'Activity Stream', 'Administration', etc.

jira.project_roles(jra)                     # 'Administrators', 'Developers', etc.

versions = jira.project_versions(jra)
[v.name for v in reversed(versions)]        # '5.1.1', '5.1', '5.0.7', '5.0.6', etc.
2.1.10. Watchers
Watchers are objects, represented by jira.resources.Watchers:

watcher = jira.watchers(issue)
print("Issue has {} watcher(s)".format(watcher.watchCount))
for watcher in watcher.watchers:
    print(watcher)
    # watcher is instance of jira.resources.User:
    print(watcher.emailAddress)
You can add users to watchers by their name:

jira.add_watcher(issue, 'username')
jira.add_watcher(issue, user_resource.name)
And of course you can remove users from watcher:

jira.remove_watcher(issue, 'username')
jira.remove_watcher(issue, user_resource.name)
2.1.11. Attachments
Attachments let user add files to issues. First you’ll need an issue to which the attachment will be uploaded. Next, you’ll need the file itself that is going to be attachment. The file could be a file-like object or string, representing path on the local machine. You can also modify the final name of the attachment if you don’t like original. Here are some examples:

# upload file from `/some/path/attachment.txt`
jira.add_attachment(issue=issue, attachment='/some/path/attachment.txt')

# read and upload a file (note binary mode for opening, it's important):
with open('/some/path/attachment.txt', 'rb') as f:
    jira.add_attachment(issue=issue, attachment=f)

# attach file from memory (you can skip IO operations). In this case you MUST provide `filename`.
from io import StringIO
attachment = StringIO()
attachment.write(data)
jira.add_attachment(issue=issue, attachment=attachment, filename='content.txt')
If you would like to list all available attachment, you can do it with through attachment field:

for attachment in issue.fields.attachment:
    print("Name: '{filename}', size: {size}".format(
        filename=attachment.filename, size=attachment.size))
    # to read content use `get` method:
    print("Content: '{}'".format(attachment.get()))
You can delete attachment by id:

# Find issues with attachments:
query = jira.search_issues(jql_str="attachments is not EMPTY", json_result=True, fields="key, attachment")

# And remove attachments one by one
for i in query['issues']:
    for a in i['fields']['attachment']:
        print("For issue {0}, found attach: '{1}' [{2}].".format(i['key'], a['filename'], a['id']))
        jira.delete_attachment(a['id'])

```

--------------------------------------------------------------------------------
/tests/test_search_issues_v3_api.py:
--------------------------------------------------------------------------------

```python
"""Test cases for search_issues V3 API client and server integration"""

import asyncio
from unittest.mock import Mock, patch, AsyncMock
import pytest

from src.mcp_server_jira.jira_v3_api import JiraV3APIClient
from src.mcp_server_jira.server import JiraServer, JiraIssueResult


class TestSearchIssuesV3API:
    """Test suite for search_issues V3 API client"""

    @pytest.mark.asyncio
    async def test_v3_api_search_issues_success(self):
        """Test successful search issues request with V3 API"""
        # Mock successful search response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "issues": [
                {
                    "key": "PROJ-123",
                    "fields": {
                        "summary": "Test issue summary",
                        "description": "Test issue description",
                        "status": {"name": "Open"},
                        "assignee": {"displayName": "John Doe"},
                        "reporter": {"displayName": "Jane Smith"},
                        "created": "2023-01-01T00:00:00.000+0000",
                        "updated": "2023-01-02T00:00:00.000+0000"
                    }
                },
                {
                    "key": "PROJ-124",
                    "fields": {
                        "summary": "Another test issue",
                        "description": "Another description",
                        "status": {"name": "In Progress"},
                        "assignee": None,
                        "reporter": {"displayName": "Bob Wilson"},
                        "created": "2023-01-03T00:00:00.000+0000",
                        "updated": "2023-01-04T00:00:00.000+0000"
                    }
                }
            ],
            "startAt": 0,
            "maxResults": 50,
            "total": 2,
            "isLast": True
        }
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        result = await client.search_issues(
            jql="project = PROJ",
            max_results=10
        )

        # Verify the request was made correctly
        mock_client.request.assert_called_once()
        call_args = mock_client.request.call_args
        
        assert call_args[1]["method"] == "GET"
        assert call_args[1]["url"] == "https://test.atlassian.net/rest/api/3/search/jql"
        assert call_args[1]["params"]["jql"] == "project = PROJ"
        assert call_args[1]["params"]["maxResults"] == 10

        # Verify response
        assert result["total"] == 2
        assert len(result["issues"]) == 2
        assert result["issues"][0]["key"] == "PROJ-123"

    @pytest.mark.asyncio
    async def test_v3_api_search_issues_with_parameters(self):
        """Test search issues with optional parameters"""
        # Mock successful search response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "issues": [],
            "startAt": 0,
            "maxResults": 25,
            "total": 0,
            "isLast": True
        }
        mock_response.text = ""
        mock_response.raise_for_status.return_value = None

        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.return_value = mock_response

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        result = await client.search_issues(
            jql="project = PROJ AND status = Open",
            start_at=10,
            max_results=25,
            fields="summary,status,assignee",
            expand="changelog"
        )

        # Verify the request was made correctly
        mock_client.request.assert_called_once()
        call_args = mock_client.request.call_args
        
        assert call_args[1]["method"] == "GET"
        assert call_args[1]["url"] == "https://test.atlassian.net/rest/api/3/search/jql"
        params = call_args[1]["params"]
        assert params["jql"] == "project = PROJ AND status = Open"
        assert params["startAt"] == 10
        assert params["maxResults"] == 25
        assert params["fields"] == "summary,status,assignee"
        assert params["expand"] == "changelog"

    @pytest.mark.asyncio
    async def test_v3_api_search_issues_missing_jql(self):
        """Test search issues with missing JQL parameter"""
        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser",
            token="testtoken"
        )

        with pytest.raises(ValueError, match="jql parameter is required"):
            await client.search_issues("")

    @pytest.mark.asyncio
    async def test_v3_api_search_issues_api_error(self):
        """Test search issues with API error response"""
        # Mock error response
        mock_response = Mock()
        mock_response.status_code = 400
        mock_response.reason_phrase = "Bad Request"
        mock_response.json.return_value = {"errorMessages": ["Invalid JQL"]}
        
        from httpx import HTTPStatusError, Request, Response
        mock_request = Mock(spec=Request)
        mock_request.url = "https://test.atlassian.net/rest/api/3/search/jql"
        
        # Mock httpx client
        mock_client = AsyncMock()
        mock_client.request.side_effect = HTTPStatusError(
            "400 Bad Request", request=mock_request, response=mock_response
        )

        client = JiraV3APIClient(
            server_url="https://test.atlassian.net",
            username="testuser", 
            token="testtoken"
        )
        
        # Replace the client instance
        client.client = mock_client

        with pytest.raises(ValueError, match="Jira API returned an error: 400"):
            await client.search_issues(jql="invalid jql syntax")


class TestSearchIssuesJiraServer:
    """Test suite for search_issues in JiraServer class"""

    @pytest.mark.asyncio
    async def test_server_search_issues_success(self):
        """Test JiraServer search_issues method with successful V3 API response"""
        # Mock V3 API response
        mock_v3_response = {
            "issues": [
                {
                    "key": "TEST-1",
                    "fields": {
                        "summary": "Test Summary",
                        "description": "Test Description",
                        "status": {"name": "Open"},
                        "assignee": {"displayName": "Test User"},
                        "reporter": {"displayName": "Reporter User"},
                        "created": "2023-01-01T00:00:00.000+0000",
                        "updated": "2023-01-02T00:00:00.000+0000"
                    }
                }
            ]
        }

        # Mock V3 API client
        mock_v3_client = AsyncMock()
        mock_v3_client.search_issues.return_value = mock_v3_response

        # Create JiraServer instance and mock the V3 client
        server = JiraServer()
        server.server_url = "https://test.atlassian.net"
        server.username = "testuser"
        server.token = "testtoken"
        
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            result = await server.search_jira_issues("project = TEST", max_results=10)

        # Verify the result
        assert isinstance(result, list)
        assert len(result) == 1
        issue = result[0]
        assert isinstance(issue, dict)
        assert issue["key"] == "TEST-1"
        assert issue["fields"]["summary"] == "Test Summary"
        assert issue["fields"]["description"] == "Test Description"
        assert issue["fields"]["status"]["name"] == "Open"
        assert issue["fields"]["assignee"]["displayName"] == "Test User"
        assert issue["fields"]["reporter"]["displayName"] == "Reporter User"
        assert issue["fields"]["created"] == "2023-01-01T00:00:00.000+0000"
        assert issue["fields"]["updated"] == "2023-01-02T00:00:00.000+0000"

        # Verify V3 client was called correctly
        mock_v3_client.search_issues.assert_called_once_with(
            jql="project = TEST", start_at=0, max_results=10
        )

    @pytest.mark.asyncio
    async def test_server_search_issues_handles_missing_fields(self):
        """Test JiraServer search_issues method handles missing optional fields gracefully"""
        # Mock V3 API response with minimal data
        mock_v3_response = {
            "issues": [
                {
                    "key": "TEST-2",
                    "fields": {
                        "summary": "Basic Summary",
                        # Missing description, status, assignee, reporter, etc.
                    }
                }
            ]
        }

        # Mock V3 API client
        mock_v3_client = AsyncMock()
        mock_v3_client.search_issues.return_value = mock_v3_response

        # Create JiraServer instance and mock the V3 client
        server = JiraServer()
        server.server_url = "https://test.atlassian.net"
        server.username = "testuser"
        server.token = "testtoken"
        
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            result = await server.search_jira_issues("project = TEST")

        # Verify the result handles missing fields gracefully
        assert isinstance(result, list)
        assert len(result) == 1
        issue = result[0]
        assert isinstance(issue, dict)
        assert issue["key"] == "TEST-2"
        assert issue["fields"]["summary"] == "Basic Summary"
        # Missing description, status, assignee, reporter should be absent or None
        assert issue["fields"].get("description") is None
        assert issue["fields"].get("status") is None
        assert issue["fields"].get("assignee") is None
        assert issue["fields"].get("reporter") is None

    @pytest.mark.asyncio
    async def test_server_search_issues_api_error(self):
        """Test JiraServer search_issues method with API error"""
        # Mock V3 API client that raises an error
        mock_v3_client = AsyncMock()
        mock_v3_client.search_issues.side_effect = ValueError("API connection failed")

        # Create JiraServer instance and mock the V3 client
        server = JiraServer()
        server.server_url = "https://test.atlassian.net"
        server.username = "testuser"
        server.token = "testtoken"
        
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            with pytest.raises(ValueError, match="Failed to search issues"):
                await server.search_jira_issues("project = TEST")

    @pytest.mark.asyncio
    async def test_server_search_issues_pagination(self):
        """Test JiraServer search_issues method handles pagination correctly"""
        # Mock V3 API responses for pagination
        # First page response
        page1_response = {
            "issues": [
                {
                    "key": "TEST-1",
                    "fields": {
                        "summary": "First Issue",
                        "description": "First Description",
                        "status": {"name": "Open"},
                        "assignee": {"displayName": "User 1"},
                        "reporter": {"displayName": "Reporter 1"},
                        "created": "2023-01-01T00:00:00.000+0000",
                        "updated": "2023-01-01T00:00:00.000+0000"
                    }
                },
                {
                    "key": "TEST-2",
                    "fields": {
                        "summary": "Second Issue",
                        "description": "Second Description",
                        "status": {"name": "In Progress"},
                        "assignee": {"displayName": "User 2"},
                        "reporter": {"displayName": "Reporter 2"},
                        "created": "2023-01-02T00:00:00.000+0000",
                        "updated": "2023-01-02T00:00:00.000+0000"
                    }
                }
            ],
            "startAt": 0,
            "maxResults": 2,
            "total": 5,
            "isLast": False
        }

        # Second page response
        page2_response = {
            "issues": [
                {
                    "key": "TEST-3",
                    "fields": {
                        "summary": "Third Issue",
                        "description": "Third Description",
                        "status": {"name": "Done"},
                        "assignee": {"displayName": "User 3"},
                        "reporter": {"displayName": "Reporter 3"},
                        "created": "2023-01-03T00:00:00.000+0000",
                        "updated": "2023-01-03T00:00:00.000+0000"
                    }
                },
                {
                    "key": "TEST-4",
                    "fields": {
                        "summary": "Fourth Issue",
                        "description": "Fourth Description",
                        "status": {"name": "Closed"},
                        "assignee": None,
                        "reporter": {"displayName": "Reporter 4"},
                        "created": "2023-01-04T00:00:00.000+0000",
                        "updated": "2023-01-04T00:00:00.000+0000"
                    }
                }
            ],
            "startAt": 2,
            "maxResults": 2,
            "total": 5,
            "isLast": False
        }

        # Third page response
        page3_response = {
            "issues": [
                {
                    "key": "TEST-5",
                    "fields": {
                        "summary": "Fifth Issue",
                        "description": "Fifth Description",
                        "status": {"name": "Open"},
                        "assignee": {"displayName": "User 5"},
                        "reporter": {"displayName": "Reporter 5"},
                        "created": "2023-01-05T00:00:00.000+0000",
                        "updated": "2023-01-05T00:00:00.000+0000"
                    }
                }
            ],
            "startAt": 4,
            "maxResults": 2,
            "total": 5,
            "isLast": True
        }

        # Mock V3 API client with side_effect to return different pages
        mock_v3_client = AsyncMock()
        mock_v3_client.search_issues.side_effect = [page1_response, page2_response, page3_response]

        # Create JiraServer instance and mock the V3 client
        server = JiraServer()
        server.server_url = "https://test.atlassian.net"
        server.username = "testuser"
        server.token = "testtoken"
        
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            result = await server.search_jira_issues("project = TEST", max_results=10)

        # Verify all issues from all pages were retrieved
        assert isinstance(result, list)
        assert len(result) == 5
        
        # Check each issue dict
        assert isinstance(result[0], dict)
        assert result[0]["key"] == "TEST-1"
        assert result[0]["fields"]["summary"] == "First Issue"
        assert result[0]["fields"]["status"]["name"] == "Open"
        
        assert result[1]["key"] == "TEST-2"
        assert result[1]["fields"]["summary"] == "Second Issue"
        assert result[1]["fields"]["status"]["name"] == "In Progress"
        
        assert result[2]["key"] == "TEST-3"
        assert result[2]["fields"]["summary"] == "Third Issue"
        assert result[2]["fields"]["status"]["name"] == "Done"
        
        assert result[3]["key"] == "TEST-4"
        assert result[3]["fields"]["summary"] == "Fourth Issue"
        assert result[3]["fields"]["status"]["name"] == "Closed"
        # None handling
        assert result[3]["fields"].get("assignee") is None
        
        assert result[4]["key"] == "TEST-5"
        assert result[4]["fields"]["summary"] == "Fifth Issue"
        assert result[4]["fields"]["status"]["name"] == "Open"

        # Verify V3 client was called the correct number of times with correct parameters
        assert mock_v3_client.search_issues.call_count == 3
        
        # Check first call
        first_call = mock_v3_client.search_issues.call_args_list[0]
        assert first_call[1]["jql"] == "project = TEST"
        assert first_call[1]["start_at"] == 0
        assert first_call[1]["max_results"] == 10
        
        # Check second call
        second_call = mock_v3_client.search_issues.call_args_list[1]
        assert second_call[1]["jql"] == "project = TEST"
        assert second_call[1]["start_at"] == 2  # After first 2 issues
        assert second_call[1]["max_results"] == 8  # Remaining needed: 10 - 2 = 8, min(8, 100) = 8
        
        # Check third call
        third_call = mock_v3_client.search_issues.call_args_list[2]
        assert third_call[1]["jql"] == "project = TEST"
        assert third_call[1]["start_at"] == 4  # After first 4 issues
        assert third_call[1]["max_results"] == 6  # Remaining needed: 10 - 4 = 6, min(6, 100) = 6

    @pytest.mark.asyncio
    async def test_server_search_issues_pagination_with_limit(self):
        """Test JiraServer search_issues method respects max_results when paginating"""
        # Mock V3 API responses for multiple pages, but we'll limit results
        page1_response = {
            "issues": [
                {"key": "TEST-1", "fields": {"summary": "First Issue"}},
                {"key": "TEST-2", "fields": {"summary": "Second Issue"}},
                {"key": "TEST-3", "fields": {"summary": "Third Issue"}}
            ],
            "startAt": 0,
            "maxResults": 3,
            "total": 10,
            "isLast": False
        }

        page2_response = {
            "issues": [
                {"key": "TEST-4", "fields": {"summary": "Fourth Issue"}},
                {"key": "TEST-5", "fields": {"summary": "Fifth Issue"}}
            ],
            "startAt": 3,
            "maxResults": 2,  # Only 2 more to reach our limit of 5
            "total": 10,
            "isLast": False
        }

        # Mock V3 API client
        mock_v3_client = AsyncMock()
        mock_v3_client.search_issues.side_effect = [page1_response, page2_response]

        # Create JiraServer instance and mock the V3 client
        server = JiraServer()
        server.server_url = "https://test.atlassian.net"
        server.username = "testuser"
        server.token = "testtoken"
        
        with patch.object(server, '_get_v3_api_client', return_value=mock_v3_client):
            # Request only 5 results max
            result = await server.search_jira_issues("project = TEST", max_results=5)

        # Verify exactly 5 issues were returned (respecting max_results)
        assert isinstance(result, list)
        assert len(result) == 5
        assert result[0]["key"] == "TEST-1"
        assert result[1]["key"] == "TEST-2"
        assert result[2]["key"] == "TEST-3"
        assert result[3]["key"] == "TEST-4"
        assert result[4]["key"] == "TEST-5"

        # Verify pagination stopped at the right point
        assert mock_v3_client.search_issues.call_count == 2
```

--------------------------------------------------------------------------------
/src/mcp_server_jira/jira_v3_api.py:
--------------------------------------------------------------------------------

```python
"""
Jira v3 REST API client module

This module provides direct HTTP client functionality for Jira's v3 REST API,
offering enhanced functionality and security for operations that require the latest API features.
"""

import json
import logging
from typing import Any, Dict, Optional

import httpx

logger = logging.getLogger("JiraMCPLogger")  # Get the same logger instance


class JiraV3APIClient:
    """Client for making direct requests to Jira's v3 REST API"""

    def __init__(
        self,
        server_url: str,
        username: Optional[str] = None,
        password: Optional[str] = None,
        token: Optional[str] = None,
    ):
        """Initialize the v3 API client

        Args:
            server_url: Jira server URL
            username: Username for authentication
            password: Password for basic auth
            token: API token for auth
        """
        self.server_url = server_url.rstrip("/")
        self.username = username
        self.auth_token = token or password

        if not self.username or not self.auth_token:
            raise ValueError(
                "Jira username and an API token (or password) are required for v3 API."
            )

        self.client = httpx.AsyncClient(
            auth=(self.username, self.auth_token),
            headers={"Accept": "application/json", "Content-Type": "application/json"},
            timeout=30.0,
            follow_redirects=True,
        )

    async def _make_v3_api_request(
        self,
        method: str,
        endpoint: str,
        data: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Sends an authenticated async HTTP request to a Jira v3 REST API endpoint.
        """
        url = f"{self.server_url}/rest/api/3{endpoint}"

        logger.debug(f"Attempting to make request: {method} {url}")
        logger.debug(f"Request params: {params}")
        logger.debug(f"Request JSON data: {data}")

        try:
            logger.info(f"AWAITING httpx.client.request for {method} {url}")
            response = await self.client.request(
                method=method.upper(),
                url=url,
                json=data,
                params=params,
            )
            logger.info(
                f"COMPLETED httpx.client.request for {url}. Status: {response.status_code}"
            )
            logger.debug(f"Raw response text (first 500 chars): {str(response.text)[:500]}")

            response.raise_for_status()

            if response.status_code == 204:
                return {}

            return response.json()

        except httpx.HTTPStatusError as e:
            logger.error(
                f"HTTP Status Error for {e.request.url!r}: {e.response.status_code}",
                exc_info=True,
            )
            error_details = f"Jira API returned an error: {e.response.status_code} {e.response.reason_phrase}."
            raise ValueError(error_details)

        except httpx.RequestError as e:
            logger.error(f"Request Error for {e.request.url!r}", exc_info=True)
            raise ValueError(f"A network error occurred while connecting to Jira: {e}")
        except Exception as e:
            logger.critical(
                "An unexpected error occurred in _make_v3_api_request", exc_info=True
            )
            raise

    async def create_project(
        self,
        key: str,
        assignee: str,
        name: Optional[str] = None,
        ptype: str = None,
        template_name: Optional[str] = None,
        avatarId: Optional[int] = None,
        issueSecurityScheme: Optional[int] = None,
        permissionScheme: Optional[int] = None,
        projectCategory: Optional[int] = None,
        notificationScheme: Optional[int] = None,
        categoryId: Optional[int] = None,
        url: str = None,
    ) -> Dict[str, Any]:
        """
        Creates a new Jira project using the v3 REST API.

        Requires a project key and the Atlassian accountId of the project lead (`assignee`). The v3 API mandates that `leadAccountId` is always provided, regardless of default project lead settings or UI behavior. Additional project attributes such as name, type, template, avatar, schemes, category, and documentation URL can be specified.

        Args:
            key: The unique project key (required).
            name: The project name. Defaults to the key if not provided.
            assignee: Atlassian accountId of the project lead (required by v3 API).
            ptype: Project type key (e.g., 'software', 'business', 'service_desk').
            template_name: Project template key for template-based creation.
            avatarId: ID of the avatar to assign to the project.
            issueSecurityScheme: ID of the issue security scheme.
            permissionScheme: ID of the permission scheme.
            projectCategory: ID of the project category.
            notificationScheme: ID of the notification scheme.
            categoryId: Alternative to projectCategory; preferred for v3 API.
            url: URL for project information or documentation.

        Returns:
            A dictionary containing details of the created project as returned by Jira.

        Raises:
            ValueError: If required parameters are missing or project creation fails.
        """
        if not key:
            raise ValueError("Project key is required")

        if not assignee:
            raise ValueError(
                "Parameter 'assignee' (leadAccountId) is required by the Jira v3 API"
            )

        payload = {
            "key": key,
            "name": name or key,
            "leadAccountId": assignee,
            "assigneeType": "PROJECT_LEAD",
            "projectTypeKey": ptype,
            "projectTemplateKey": template_name,
            "avatarId": avatarId,
            "issueSecurityScheme": issueSecurityScheme,
            "permissionScheme": permissionScheme,
            "notificationScheme": notificationScheme,
            "categoryId": categoryId or projectCategory,
            "url": url,
        }

        payload = {k: v for k, v in payload.items() if v is not None}

        print(f"Creating project with v3 API payload: {json.dumps(payload, indent=2)}")
        response_data = await self._make_v3_api_request(
            "POST", "/project", data=payload
        )
        print(f"Project creation response: {json.dumps(response_data, indent=2)}")
        return response_data

    async def get_projects(
        self,
        start_at: int = 0,
        max_results: int = 50,
        order_by: Optional[str] = None,
        ids: Optional[list] = None,
        keys: Optional[list] = None,
        query: Optional[str] = None,
        type_key: Optional[str] = None,
        category_id: Optional[int] = None,
        action: Optional[str] = None,
        expand: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Get projects paginated using the v3 REST API.

        Returns a paginated list of projects visible to the user using the
        /rest/api/3/project/search endpoint.

        Args:
            start_at: The index of the first item to return (default: 0)
            max_results: The maximum number of items to return per page (default: 50)
            order_by: Order the results by a field:
                     - category: Order by project category
                     - issueCount: Order by total number of issues
                     - key: Order by project key
                     - lastIssueUpdatedDate: Order by last issue update date
                     - name: Order by project name
                     - owner: Order by project lead
                     - archivedDate: Order by archived date
                     - deletedDate: Order by deleted date
            ids: List of project IDs to return
            keys: List of project keys to return
            query: Filter projects by query string
            type_key: Filter projects by type key
            category_id: Filter projects by category ID
            action: Filter by action permission (view, browse, edit)
            expand: Expand additional project fields in response

        Returns:
            Dictionary containing the paginated response with projects and pagination info

        Raises:
            ValueError: If the API request fails
        """
        params = {
            "startAt": start_at,
            "maxResults": max_results,
            "orderBy": order_by,
            "id": ids,
            "keys": keys,
            "query": query,
            "typeKey": type_key,
            "categoryId": category_id,
            "action": action,
            "expand": expand,
        }

        params = {k: v for k, v in params.items() if v is not None}

        endpoint = "/project/search"
        print(
            f"Fetching projects with v3 API endpoint: {endpoint} with params: {params}"
        )
        response_data = await self._make_v3_api_request("GET", endpoint, params=params)
        print(f"Projects API response: {json.dumps(response_data, indent=2)}")
        return response_data

    async def get_transitions(
        self,
        issue_id_or_key: str,
        expand: Optional[str] = None,
        transition_id: Optional[str] = None,
        skip_remote_only_condition: Optional[bool] = None,
        include_unavailable_transitions: Optional[bool] = None,
        sort_by_ops_bar_and_status: Optional[bool] = None,
    ) -> Dict[str, Any]:
        """
        Get available transitions for an issue using the v3 REST API.

        Returns either all transitions or a transition that can be performed by the user
        on an issue, based on the issue's status.

        Args:
            issue_id_or_key: Issue ID or key (required)
            expand: Expand additional transition fields in response
            transition_id: Get only the transition matching this ID
            skip_remote_only_condition: Skip remote-only conditions check
            include_unavailable_transitions: Include transitions that can't be performed
            sort_by_ops_bar_and_status: Sort transitions by operations bar and status

        Returns:
            Dictionary containing the transitions response with transition details

        Raises:
            ValueError: If the API request fails
        """
        if not issue_id_or_key:
            raise ValueError("issue_id_or_key is required")

        params = {
            "expand": expand,
            "transitionId": transition_id,
            "skipRemoteOnlyCondition": skip_remote_only_condition,
            "includeUnavailableTransitions": include_unavailable_transitions,
            "sortByOpsBarAndStatus": sort_by_ops_bar_and_status,
        }

        params = {k: v for k, v in params.items() if v is not None}

        endpoint = f"/issue/{issue_id_or_key}/transitions"
        logger.debug(
            f"Fetching transitions with v3 API endpoint: {endpoint} with params: {params}"
        )
        response_data = await self._make_v3_api_request("GET", endpoint, params=params)
        logger.debug(f"Transitions API response: {json.dumps(response_data, indent=2)}")
        return response_data

    async def transition_issue(
        self,
        issue_id_or_key: str,
        transition_id: str,
        fields: Optional[Dict[str, Any]] = None,
        comment: Optional[str] = None,
        history_metadata: Optional[Dict[str, Any]] = None,
        properties: Optional[list] = None,
    ) -> Dict[str, Any]:
        """
        Transition an issue using the v3 REST API.

        Performs an issue transition and, if the transition has a screen,
        updates the fields from the transition screen.

        Args:
            issue_id_or_key: Issue ID or key (required)
            transition_id: ID of the transition to perform (required)
            fields: Dict containing field names and values to update during transition
            comment: Simple string comment to add during transition
            history_metadata: Optional history metadata for the transition
            properties: Optional list of properties to set

        Returns:
            Empty dictionary on success (204 No Content response)

        Raises:
            ValueError: If required parameters are missing or transition fails
        """
        if not issue_id_or_key:
            raise ValueError("issue_id_or_key is required")

        if not transition_id:
            raise ValueError("transition_id is required")

        # Build the request payload
        payload = {"transition": {"id": transition_id}}

        # Add fields if provided
        if fields:
            payload["fields"] = fields

        # Add comment if provided - convert simple string to ADF format
        if comment:
            payload["update"] = {
                "comment": [
                    {
                        "add": {
                            "body": {
                                "type": "doc",
                                "version": 1,
                                "content": [
                                    {
                                        "type": "paragraph",
                                        "content": [{"type": "text", "text": comment}],
                                    }
                                ],
                            }
                        }
                    }
                ]
            }

        # Add optional metadata
        if history_metadata:
            payload["historyMetadata"] = history_metadata

        if properties:
            payload["properties"] = properties

        endpoint = f"/issue/{issue_id_or_key}/transitions"
        logger.debug(f"Transitioning issue with v3 API endpoint: {endpoint}")
        logger.debug(f"Transition payload: {json.dumps(payload, indent=2)}")

        response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
        logger.debug(f"Transition response: {response_data}")
        return response_data

    async def get_issue_types(self) -> Dict[str, Any]:
        """
        Get all issue types for user using the v3 REST API.

        Returns all issue types. This operation can be accessed anonymously.

        Permissions required: Issue types are only returned as follows:
        - if the user has the Administer Jira global permission, all issue types are returned.
        - if the user has the Browse projects project permission for one or more projects,
          the issue types associated with the projects the user has permission to browse are returned.
        - if the user is anonymous then they will be able to access projects with the Browse projects for anonymous users
        - if the user authentication is incorrect they will fall back to anonymous

        Returns:
            List of issue type dictionaries with fields like:
            - avatarId: Avatar ID for the issue type
            - description: Description of the issue type
            - hierarchyLevel: Hierarchy level
            - iconUrl: URL of the issue type icon
            - id: Issue type ID
            - name: Issue type name
            - self: REST API URL for the issue type
            - subtask: Whether this is a subtask type

        Raises:
            ValueError: If the API request fails
        """
        endpoint = "/issuetype"
        logger.debug(f"Fetching issue types with v3 API endpoint: {endpoint}")
        response_data = await self._make_v3_api_request("GET", endpoint)
        logger.debug(f"Issue types API response: {json.dumps(response_data, indent=2)}")
        return response_data

    async def add_comment(
        self,
        issue_id_or_key: str,
        comment: str,
        visibility: Optional[Dict[str, str]] = None,
        properties: Optional[list] = None,
    ) -> Dict[str, Any]:
        """
        Add a comment to an issue using the v3 REST API.

        Args:
            issue_id_or_key: Issue ID or key (required)
            comment: Comment text to add (required)
            visibility: Optional visibility settings (e.g., {"type": "role", "value": "Administrators"})
            properties: Optional list of properties to set

        Returns:
            Dict containing comment details:
            - id: Comment ID
            - body: Comment body in ADF format
            - author: Author information
            - created: Creation timestamp
            - updated: Last update timestamp
            - etc.

        Raises:
            ValueError: If required parameters are missing or comment creation fails
        """
        if not issue_id_or_key:
            raise ValueError("issue_id_or_key is required")

        if not comment:
            raise ValueError("comment is required")

        # Build the request payload with ADF format
        payload = {
            "body": {
                "type": "doc",
                "version": 1,
                "content": [
                    {
                        "type": "paragraph",
                        "content": [{"type": "text", "text": comment}],
                    }
                ],
            }
        }

        # Add optional visibility
        if visibility:
            payload["visibility"] = visibility

        # Add optional properties
        if properties:
            payload["properties"] = properties

        endpoint = f"/issue/{issue_id_or_key}/comment"
        logger.debug(f"Adding comment to issue {issue_id_or_key} with v3 API endpoint: {endpoint}")
        response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
        logger.debug(f"Add comment API response: {json.dumps(response_data, indent=2)}")
        return response_data

    async def create_issue(
        self,
        fields: Dict[str, Any],
        update: Optional[Dict[str, Any]] = None,
        history_metadata: Optional[Dict[str, Any]] = None,
        properties: Optional[list] = None,
        transition: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Create an issue using the v3 REST API.

        Creates an issue or, where the option to create subtasks is enabled in Jira, a subtask.
        A transition may be applied, to move the issue or subtask to a workflow step other than
        the default start step, and issue properties set.

        Args:
            fields: Dict containing field names and values (required).
                   Must include project, summary, description, and issuetype.
            update: Dict containing update operations for fields
            history_metadata: Optional history metadata for the issue creation
            properties: Optional list of properties to set
            transition: Optional transition to apply after creation

        Returns:
            Dictionary containing the created issue details:
            - id: Issue ID
            - key: Issue key
            - self: URL to the created issue
            - transition: Transition result if applied

        Raises:
            ValueError: If required parameters are missing or creation fails
        """
        if not fields:
            raise ValueError("fields is required")

        # Build the request payload
        payload = {"fields": fields}

        # Add optional parameters
        if update:
            payload["update"] = update

        if history_metadata:
            payload["historyMetadata"] = history_metadata

        if properties:
            payload["properties"] = properties

        if transition:
            payload["transition"] = transition

        endpoint = "/issue"
        logger.debug(f"Creating issue with v3 API endpoint: {endpoint}")
        logger.debug(f"Create issue payload: {json.dumps(payload, indent=2)}")

        response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
        logger.debug(f"Create issue response: {response_data}")
        return response_data

    async def search_issues(
        self,
        jql: str,
        start_at: int = 0,
        max_results: int = 50,
        fields: Optional[str] = None,
        expand: Optional[str] = None,
        properties: Optional[list] = None,
        fields_by_keys: Optional[bool] = None,
        fail_fast: Optional[bool] = None,
        reconcile_issues: Optional[list] = None,
    ) -> Dict[str, Any]:
        """
        Search for issues using JQL enhanced search (GET) via v3 REST API.
        
        Searches for issues using JQL. Recent updates might not be immediately visible 
        in the returned search results. If you need read-after-write consistency, 
        you can utilize the reconcileIssues parameter to ensure stronger consistency assurances. 
        This operation can be accessed anonymously.

        Args:
            jql: JQL query string
            start_at: Index of the first issue to return (default: 0)
            max_results: Maximum number of results to return (default: 50)
            fields: Comma-separated list of fields to include in response
            expand: Use expand to include additional information about issues
            properties: List of issue properties to include in response
            fields_by_keys: Reference fields by their key (rather than ID)
            fail_fast: Fail fast when JQL query validation fails
            reconcile_issues: List of issue IDs to reconcile for read-after-write consistency

        Returns:
            Dictionary containing search results with:
            - issues: List of issue dictionaries
            - isLast: Boolean indicating if this is the last page
            - startAt: Starting index of results
            - maxResults: Maximum results per page
            - total: Total number of issues matching the query

        Raises:
            ValueError: If the API request fails or JQL is invalid
        """
        if not jql:
            raise ValueError("jql parameter is required")

        # Build query parameters
        params = {
            "jql": jql,
            "startAt": start_at,
            "maxResults": max_results,
        }

        # Add optional parameters if provided
        params["fields"] = fields if fields is not None else "*all"
        if expand:
            params["expand"] = expand
        if properties:
            params["properties"] = properties
        if fields_by_keys is not None:
            params["fieldsByKeys"] = fields_by_keys
        if fail_fast is not None:
            params["failFast"] = fail_fast
        if reconcile_issues:
            params["reconcileIssues"] = reconcile_issues

        # Remove None values
        params = {k: v for k, v in params.items() if v is not None}

        endpoint = "/search/jql"
        logger.debug(f"Searching issues with v3 API endpoint: {endpoint}")
        logger.debug(f"Search params: {params}")
        
        response_data = await self._make_v3_api_request("GET", endpoint, params=params)
        logger.debug(f"Search issues API response: {json.dumps(response_data, indent=2)}")
        return response_data

    async def bulk_create_issues(
        self, 
        issue_updates: list
    ) -> Dict[str, Any]:
        """
        Bulk create issues using the v3 REST API.

        Creates up to 50 issues and, where the option to create subtasks is enabled in Jira,
        subtasks. Transitions may be applied, to move the issues or subtasks to a workflow
        step other than the default start step, and issue properties set.

        Args:
            issue_updates: List of issue creation specifications. Each item should contain
                          'fields' dict with issue fields, and optionally 'update' dict
                          for additional operations during creation.

        Returns:
            Dict containing:
            - issues: List of successfully created issues with their details
            - errors: List of errors for failed issue creations

        Raises:
            ValueError: If required parameters are missing or bulk creation fails
        """
        if not issue_updates:
            raise ValueError("issue_updates list cannot be empty")

        if len(issue_updates) > 50:
            raise ValueError("Cannot create more than 50 issues in a single bulk operation")

        # Build the request payload for v3 API
        payload = {"issueUpdates": issue_updates}

        endpoint = "/issue/bulk"
        logger.debug(f"Bulk creating issues with v3 API endpoint: {endpoint}")
        logger.debug(f"Payload: {json.dumps(payload, indent=2)}")

        response_data = await self._make_v3_api_request("POST", endpoint, data=payload)
        logger.debug(f"Bulk create response: {json.dumps(response_data, indent=2)}")

        return response_data

```
Page 1/3FirstPrevNextLast