Metadata-Version: 2.4
Name: jira-mcp-server-hopcos
Version: 0.2.0
Summary: Production-ready Jira MCP Server with dual transports (stdio + HTTP/SSE) and dual auth modes (Cloud Basic + Data Center PAT)
Author: Jira MCP Contributors
License: Apache-2.0
Project-URL: Homepage, https://github.com/Hopcos/jira-mcp-server
Project-URL: Repository, https://github.com/Hopcos/jira-mcp-server
Project-URL: Documentation, https://github.com/Hopcos/jira-mcp-server
Keywords: jira,mcp,model-context-protocol,atlassian,ai
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp>=1.9.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic>=2.7.0
Requires-Dist: pydantic-settings>=2.3.0
Requires-Dist: typer>=0.12.0
Requires-Dist: python-dotenv>=1.0.1
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: uvicorn>=0.30.0
Requires-Dist: starlette>=0.38.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
Requires-Dist: respx>=0.21.0; extra == "dev"
Requires-Dist: ruff>=0.4.0; extra == "dev"
Requires-Dist: mypy>=1.10.0; extra == "dev"
Dynamic: license-file

# Jira MCP Server

Production-ready **Jira MCP server** exposing Jira projects, issues, sprints,
users, attachments, and worklogs as [Model Context Protocol](https://modelcontextprotocol.io)
tools, resources, and prompts — to any MCP client (Claude Desktop, Claude
Code, Cursor, VS Code) over **stdio** (local) or **HTTP/SSE / Streamable HTTP**
(remote or team-shared).

```
┌──────────────────────────────────────────────────────────────────┐
│                         MCP Client                                │
│   Claude Desktop / Claude Code / Cursor / VS Code / custom        │
└───────────────┬───────────────────────────────────┬──────────────┘
                │ stdio (JSON-RPC over stdin/stdout) │   HTTP (SSE/Streamable)
                ▼                                    ▼
        ┌──────────────────┐              ┌──────────────────────┐
        │   stdio transport │              │   HTTP transport     │
        │   (default)       │              │   /sse /mcp /health  │
        └───────┬──────────┘              └──────────┬───────────┘
                │       same registered server        │
                ▼                                     ▼
        ┌──────────────────────────────────────────────────────────┐
        │               jira_mcp_server.server.create_server()      │
        │   27 tools · 4 resources · 4 prompts (transport-agnostic) │
        └───────────────────────────────┬──────────────────────────┘
                                        ▼
                       ┌────────────────────────────────┐
                       │  JiraClient (httpx async)       │
                       │  auth · rate-limit · retry      │
                       └────────────────┬───────────────┘
                                        ▼
                          Jira REST API / Agile API (HTTPS)
```

## Features

- **Dual transports** — stdio for local integration; HTTP with **SSE** and
  **Streamable HTTP** for remote/team deployments, switchable via CLI flag or
  `MCP_TRANSPORT`.
- **Dual Jira auth** — Jira Cloud (basic: email + API token) and Jira Data
  Center / Server (Bearer PAT).
- **Security first** — HTTPS-only (`JIRA_BASE_URL` refuses plaintext), no
  credentials in logs (token masking), client-side token-bucket rate limiting,
  project whitelist enforcement, tool-level **CRUD permission control**
  (`JIRA_TOOLS`), `confirm` guard on deletion, attachment size limits,
  optional static Bearer auth for the HTTP transport, CORS control.
- **Automatic retry + pagination** — 429/5xx retry with exponential backoff;
  list endpoints paginate automatically.
- **ADF support** — plain-text → Atlassian Document Format conversion for
  descriptions/comments; ADF → text rendering for reads.
- **Type-safe** — Pydantic-validated tool schemas and config.
- **Docker ready** — `Dockerfile` + `docker-compose.yml`.

## Requirements

- Python **3.11+** (3.12 recommended).
- A Jira instance:
  - **Jira Cloud** — an [API token](https://id.atlassian.com/manage-profile/security/api-tokens)
    (email + token for Basic auth), and the user should have at least:
    *Browse projects*, *Create issues*, *Edit issues*, *Manage attachments*,
    *Manage worklogs*, and (for board/sprint tools) access to the relevant
    boards.
  - **Jira Data Center / Server** — a Personal Access Token, and a TLS-terminated
    HTTPS endpoint (the server refuses plaintext).

> **Token management**: create tokens per environment, scope them to the
> least privilege you need, and rotate them regularly. Never commit `.env`.
> If a token must be granted broad Jira permissions, pair it with
> `JIRA_TOOLS` (see [Tool permissions](#tool-permissions-crud-control)) to
> restrict which of those permissions MCP clients can actually drive.

## Installation

### 1. pip

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

### 2. uv

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

### 3. From source

```bash
git clone https://github.com/your-org/jira-mcp-server.git
cd jira-mcp-server
python -m venv .venv
source .venv/bin/activate      # Linux / macOS
pip install -e ".[dev]"
```

On Windows, use PowerShell instead:

```powershell
git clone https://github.com/your-org/jira-mcp-server.git
cd jira-mcp-server
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -e ".[dev]"
```

or (CMD):

```bat
git clone https://github.com/your-org/jira-mcp-server.git
cd jira-mcp-server
python -m venv .venv
.\.venv\Scripts\activate.bat
python -m pip install -e ".[dev]"
```

A successful editable install automatically writes the
`jira-mcp-server` console entry point — and on Windows the
`jira-mcp-server.exe` launcher — into the active environment's
`Scripts` directory. See
[Building the jira-mcp-server.exe console script](#building-the-jira-mcp-serverexe-console-script).

### 4. Docker (HTTP mode)

The Jira credentials are set on the **server container** (server-side), not on
any MCP client:

```bash
docker run -p 8080:8080 \
  -e JIRA_BASE_URL=https://your-domain.atlassian.net \
  -e JIRA_API_TOKEN=your-token \
  -e JIRA_USER_EMAIL=your@email.com \
  -e JIRA_TOOLS=read,create,update \
  jira-mcp-server:latest
```

See [Docker deployment](#docker-deployment) for full details.

## Quick start (3 minutes)

### stdio mode — Claude Desktop

Configure the server **once**, server-side, so the token never appears in the
client config (and is never exposed to the model). The server loads its
configuration from a fixed-name config file **that sits in the same directory
as the server** — so the MCP command stays a bare `jira-mcp-server` with no
path arguments (nothing to expose to the model) and file *values* never need
to enter the environment.

**Option A — a `jira_server.toml` (or `jira_server.json`) next to the server**
(the recommended, most discreet choice — secrets never enter the environment):

```toml
# jira_server.toml – server-side only, never commit, permissions 0600
base_url = "https://your-domain.atlassian.net"
auth_method = "basic"
user_email = "your-email@example.com"
api_token = "your-api-token"        # never in env, never in client config
tools = "read,create,update"
```

The server auto-detects this file in its working directory on startup.

**Option B — a `.env` `KEY=VALUE` file** next to the server installation:

```dotenv
# .env – server-side configuration (never commit this file)
JIRA_BASE_URL=https://your-domain.atlassian.net
JIRA_AUTH_METHOD=basic
JIRA_USER_EMAIL=your-email@example.com
JIRA_API_TOKEN=your-api-token
JIRA_TOOLS=read,create,update
```

Then register the server in Claude Desktop's `claude_desktop_config.json` with
a bare command — no credentials, no `env` block:

```json
{
  "mcpServers": {
    "jira": {
      "command": "jira-mcp-server",
      "args": ["--transport", "stdio"]
    }
  }
}
```

Restart Claude Desktop, open a conversation, and try:
> *"List my Jira projects."* → the server calls `jira_list_projects`.
> *"Create a Bug in PROJ titled 'Login fails on Safari'."* → `jira_create_issue`.

### stdio mode — Cursor / VS Code

The same setup applies — the Jira connection lives in a **server-side file
next to the server** (`jira_server.toml` / `jira_server.json` / `.env`), and
the editor's MCP configuration references the **bare command**. The matching
`mcpServers` block for Cursor (`~/.cursor/mcp.json`) or the VS Code MCP panel
is identical to the Claude Desktop block above — the server auto-discovers
its own config file, never the editor config.

> **Why a config file in the server directory?** A token set in the MCP
> client's `env` block (or in shell environment that the client shows) is
> visible to the AI model running inside the client. A config file **next to
> the server** is found by the server automatically, so the launch command
> has zero arguments and the secret stays out of both the environment and the
> client config. Because discovery uses a **fixed filename**, no path needs to
> be passed to the server either.

### HTTP/SSE mode

Start the service on port 8080 (the Jira connection comes from the
server-side `.env`, exactly as in stdio mode):

```bash
jira-mcp-server --transport http --port 8080
# or using env vars
export MCP_TRANSPORT=http
export MCP_HOST=127.0.0.1
export MCP_PORT=8080
export JIRA_TOOLS=read,create,update
jira-mcp-server
```

Connect from any SSE-capable MCP client:

```json
{
  "mcpServers": {
    "jira": {
      "url": "http://127.0.0.1:8080/sse",
      "headers": { "Authorization": "Bearer your-secret-token" }
    }
  }
}
```

Check it is up:

```bash
curl http://127.0.0.1:8080/health
# {"status":"healthy","server":"jira-mcp-server","version":"0.2.0","transport":"http","jira_configured":true}
```

To require a client token (recommended for anything beyond localhost), start with:

```bash
jira-mcp-server --transport http --port 8080 --auth-mode token --server-token your-secret-token
```

> The token protects who can *connect* to the MCP server; the Jira credentials
> (`JIRA_API_TOKEN`) still govern what the server can *do* in Jira.

## Client token (HTTP auth)

In HTTP/SSE mode you can require clients to authenticate with a static
**Client token** before the MCP session is allowed. Think of it as the
server's "front door" password: it decides **who may connect**, whereas
`JIRA_API_TOKEN` decides **what Jira operations the server may perform**.

### Create a client token

Any reasonably long random string works (the server compares the presented
token by exact string equality). Generate one with secrets, so it cannot be
guessed:

```bash
# Python is cross-platform and already available (the project requires it).
python -c "import secrets; print(secrets.token_urlsafe(32))"
# e.g. 9xKp2vY6QwE3rT8zU1iA0sD5fG7hJ4kN6lM9oP2qR5uW8
```

### Configure the server side

Enable token auth by supplying the same client token through one of:

```bash
# Option A — CLI flag (highest precedence).
jira-mcp-server --transport http --port 8080 \
  --auth-mode token --server-token 9xKp2vY6QwE3rT8zU1iA0sD5fG7hJ4kN6lM9oP2qR5uW8
```

```bash
# Option B — environment variables (equivalent; avoids the token in shell history).
export MCP_AUTH_MODE=token
export MCP_SERVER_TOKEN=9xKp2vY6QwE3rT8zU1iA0sD5fG7hJ4kN6lM9oP2qR5uW8
jira-mcp-server --transport http --port 8080
```

```yaml
# Option C — docker-compose (environment passthrough, already wired).
environment:
  JIRA_TOOLS: ${JIRA_TOOLS:-}
  MCP_TRANSPORT: http
  MCP_AUTH_MODE: token
  MCP_SERVER_TOKEN: ${MCP_SERVER_TOKEN:-}
```

Set both `MCP_AUTH_MODE=token` **and** `MCP_SERVER_TOKEN` together. Note the
exact behavior when one is missing:

- `MCP_AUTH_MODE=token` and **no** `MCP_SERVER_TOKEN` → the server logs a
  warning at startup and serves **unauthenticated** (no auth middleware is
  attached). This is a footgun: if you intend to require a client token, set
  the token too.
- `MCP_AUTH_MODE=none` (or unset) → no client auth at all, regardless of
  `MCP_SERVER_TOKEN`.

### Configure the client side

The client sends the same token in an `Authorization: Bearer …` header:

```json
{
  "mcpServers": {
    "jira": {
      "url": "http://127.0.0.1:8080/sse",
      "headers": { "Authorization": "Bearer 9xKp2vY6QwE3rT8zU1iA0sD5fG7hJ4kN6lM9oP2qR5uW8" }
    }
  }
}
```

Claude Code uses `--header`; Claude Desktop and Cursor/VS Code use the
`headers` map above:

```bash
claude mcp add jira --scope project \
  --url http://127.0.0.1:8080/sse \
  --header "Authorization: Bearer 9xKp2vY6QwE3rT8zU1iA0sD5fG7hJ4kN6lM9oP2qR5uW8"
```

Sanity-check both sides with curl:

```bash
curl -i http://127.0.0.1:8080/health | head -1        # 401 without a token
curl -i -H "Authorization: Bearer 9xKp2vY6QwE3rT8zU1iA0sD5fG7hJ4kN6lM9oP2qR5uW8" \
  http://127.0.0.1:8080/health | head -1              # 200 with the token
```

### Rotate the token

Generate a new token (see above), restart the server with it, and update
every client config. The two-layer layout (server process holds the Jira
credentials, clients only hold the client token) means rotating the client
token does not require reissuing Jira tokens.

## Connecting to Claude Code

Claude Code registers local MCP servers as stdio commands or remote HTTP URLs.
With the package installed (so `jira-mcp-server` is on `PATH` — on Windows
that means `D:\develop\Python312\Scripts\jira-mcp-server.exe` exists). If the
executable is not on your machine yet, generate it from source first
(see [Building the jira-mcp-server.exe console script](#building-the-jira-mcp-serverexe-console-script)),
then return here. From the project root:

### stdio (recommended for a single user)

Configure the Jira connection **on the server process**, never in the client:
the stdio command that Claude Code stores and runs must not contain
`JIRA_API_TOKEN`, the base URL, or even a config-file *path* — because Claude
Code shows the whole command to the AI.

**Step 1 — put a config file next to the server** (in the interpreter's
`Scripts` directory where `jira-mcp-server.exe` lives, OR where the MCP client
launches the server — both are auto-detected). The server auto-detects a
**fixed-named** file, so you never pass its location:

```toml
# jira_server.toml — server-side only, never commit, permissions 0600.
# Put this in the same Scripts directory as jira-mcp-server.exe (or the server's
# working directory). The server finds it automatically on startup; the MCP
# command stays a bare `jira-mcp-server`.
base_url = "https://your-domain.atlassian.net"
auth_method = "basic"
user_email = "you@email.com"
api_token = "ATATT3YOUR_REAL_TOKEN"
tools = "read,create,update"
```

(`jira_server.json` works too, or a `KEY=VALUE` `.env` file in the same
directory — see [Configuration](#configuration).)

**Step 2 — register the server with a bare command** in Claude Code:

```bash
# No --config, no --token-file, no -e/--env: the server reads jira_server.toml
# from its own directory automatically. Nothing sensitive (or even a path to
# something sensitive) is stored in .mcp.json.
claude mcp add jira --scope project \
  -- jira-mcp-server --transport stdio
```

> The server discovers `jira_server.toml` (or `jira_server.json` / `.env`) in
> **two places**: the working directory it is launched from, and the
> interpreter's `Scripts` directory where `jira-mcp-server.exe` lives. That is
> why "next to the server" works: no path is ever passed, so nothing to change
> per client, and you can drop the file next to the exe even if your MCP client
> starts the server from a different cwd. `--token-file` is a manual fallback
> that merges `KEY=VALUE` entries into the process environment.

Notes:

- **None of the strategy above puts a secret or a path on the command line.**
  The MCP client stores only `jira-mcp-server --transport stdio`; the token
  and its location are never shown to the model.
- **Config file values take precedence over environment variables** for the
  fields the file sets; the environment fills any field the file leaves out.
- **`--token-file` / env vars still work** if you prefer them (see
  [Configuration](#configuration)), but the file-next-to-server setup is the
  least exposed.
- **Keep credentials off the `claude mcp add …` command line entirely.** Do
  NOT pass `-e/--env JIRA_API_TOKEN=…`. If you must use environment variables,
  export them in the shell that launches `claude` (not into the MCP config)
  so the child process inherits them:
  ```bash
  export JIRA_BASE_URL=https://your-domain.atlassian.net
  export JIRA_USER_EMAIL=you@email.com
  export JIRA_API_TOKEN=ATATT3YOUR_REAL_TOKEN
  export JIRA_AUTH_METHOD=basic
  export JIRA_TOOLS=read,create,update
  claude mcp add jira --scope project \
    -e JIRA_BASE_URL="$JIRA_BASE_URL" \
    -e JIRA_USER_EMAIL="$JIRA_USER_EMAIL" \
    -e JIRA_AUTH_METHOD=basic \
    -e JIRA_TOOLS="$JIRA_TOOLS" \
    -- jira-mcp-server
  ```
  Exporting keeps the values out of the persisted client config, but they still
  cross the process boundary into the stdio child. `jira_server.toml` next to
  the server avoids even that.

Verify:

```bash
claude mcp list            # jira: Command - ✔ Connected
claude mcp get jira        # shows the resolved command; credentials NOT listed
```

Restart Claude Code (or run `/mcp` to check connection status) and start a new
session — the `jira_*` tools will be available.

> If the server fails to start with
> `Jira API Token is required when JIRA_AUTH_METHOD=basic`, the most likely
> cause is that `JIRA_API_TOKEN` was not visible to the stdio child process.
> Fix it by pointing `--token-file` at a readable file (or exporting the
> variables before launching `claude`) — never by adding the token to the
> client config.

### HTTP/SSE (team-shared / multi-client)

```bash
# terminal 1: start the service (it holds the Jira credentials)
export JIRA_BASE_URL=https://your-domain.atlassian.net
export JIRA_USER_EMAIL=you@email.com
export JIRA_API_TOKEN=<token>
export JIRA_TOOLS=read,create,update
jira-mcp-server --transport http --host 127.0.0.1 --port 8080 \
  --auth-mode token --server-token <client-token>

# terminal 2: register the URL in Claude Code
claude mcp add jira --scope project \
  --url http://127.0.0.1:8080/sse \
  --header "Authorization: Bearer <client-token>"
```

This gives two-layer auth: `<client-token>` guards who may *connect* to the
MCP server, `JIRA_API_TOKEN` governs what it can *do* in Jira. The
`<client-token>` placeholder is the shared secret you generate yourself
(see [Client token (HTTP auth)](#client-token-http-auth)) — it is **not** the
Jira API token.

Because the Jira credentials live in the **server process** (terminal 1), they
never appear in Claude Code's `mcp.json`/`.mcp.json` — an advantage of the
HTTP layout when you want to keep `JIRA_API_TOKEN` out of the client config.

## Environment variables

> All `JIRA_*` variables are configured on the **MCP server process** — in a
> `jira_server.toml`/`jira_server.json` next to the server (preferred), its
> environment, its `.env` file, or a `--token-file` file — never on the MCP
> client. In stdio mode the command the client stores and runs must not carry
> these values, or the AI would see the token.
>
> **Precedence** (highest first): auto-discovered **config file next to the
> server** (`jira_server.toml` / `jira_server.json` in the working directory
> **or** in the interpreter's `Scripts` dir) → env vars / `.env` → `--token-file`.
> For each option, only the fields it actually sets are applied, so the config
> file can hold just the secrets while env supplies the rest.

| Variable | Type | Required | Default | Mode | Description | Example |
|---|---|---|---|---|---|---|
| `JIRA_BASE_URL` | str | **yes** | –(unset) | all | Jira instance URL (server-side); must be `https://` | `https://acme.atlassian.net` |
| `JIRA_AUTH_METHOD` | enum | yes | `basic` | all | `basic` (Cloud) or `bearer` (Data Center) | `basic` |
| `JIRA_USER_EMAIL` | str | if basic | – | all | Account email for Cloud Basic auth | `dev@acme.com` |
| `JIRA_API_TOKEN` | str | **yes** | – | all | Jira API token (Cloud) or PAT (Server) | `ATATT3xxxx…` |
| `JIRA_PROJECT_KEYS` | str | no | – | all | Comma-separated allowlist; reads scoped & writes blocked outside it | `ENG,SALES` |
| `JIRA_TOOLS` | str | no | – | all | Tool permission allowlist (`read`/`create`/`update`/`delete`/`write` or exact tool names); empty ⇒ all | `read,create,update` |
| `JIRA_SEARCH_ENGINE` | enum | no | `jql` | all | Issue search API: `jql` (default) → enhanced `POST /rest/api/3/search/jql`; `get` → `GET /rest/api/3/search/jql`; `auto` → alias for `jql`. The old `/rest/api/3/search` was removed by Jira Cloud (410) | `get` |
| `JIRA_RATE_LIMIT` | int | no | `100` | all | Client-side requests/minute (token bucket) | `200` |
| `JIRA_REQUEST_TIMEOUT` | float | no | `30` | all | Per-request timeout (s) | `30` |
| `JIRA_CONNECT_TIMEOUT` | float | no | `10` | all | Connection-establishment timeout (s) | `10` |
| `JIRA_TOKEN_FILE` | str | no | – | both | Path to a server-side `KEY=VALUE` credentials file merged into the process env | `/etc/jira-mcp/secrets.env` |
| `HTTPS_PROXY` | str | no | – | all | Outbound proxy (honored by httpx `trust_env`) | `https://proxy:8080` |
| `MCP_TRANSPORT` | enum | no | `stdio` | both | `stdio`, `http` (SSE), or `http-streamable` | `http` |
| `MCP_HOST` | str | no | `127.0.0.1` | http | Bind address (use `127.0.0.1` + reverse proxy in prod) | `0.0.0.0` |
| `MCP_PORT` | int | no | `8080` | http | TCP port | `8080` |
| `MCP_AUTH_MODE` | enum | no | `none` | http | `none` or `token` | `token` |
| `MCP_SERVER_TOKEN` | str | if token | – | http | Bearer token clients must send | `change-me` |
| `MCP_CORS_ORIGINS` | str | no | `*` | http | Comma-separated allowed origins | `https://app.example.com` |
| `MCP_LOG_LEVEL` | enum | no | `INFO` | all | `DEBUG`/`INFO`/`WARNING`/`ERROR` | `DEBUG` |
| `MCP_LOG_FILE` | str | no | – | all | Log file; empty ⇒ stderr (never stdout) | `/var/log/jira-mcp.log` |

### Server-side config file (TOML or JSON)

The recommended way to configure the Jira connection: put a **fixed-name**
config file **next to the server** — `jira_server.toml` (or `jira_server.json`)
in the **interpreter's `Scripts` directory** (the folder where `jira-mcp-server.exe`
lives) or in the directory the MCP client launches the server from. The server
auto-discovers it in either place, so the MCP launch command stays a bare
`jira-mcp-server` with **no `--config`, no path, and no environment variables** —
nothing sensitive (or even a path to something sensitive) is ever stored in or
shown to the MCP client.

The file mirrors the `JIRA_*` variables without the prefix and takes
precedence over them. Only the fields present are applied, so you can put just
the secrets (e.g. `api_token`) in the file and leave the rest to the
environment:

```toml
# jira_server.toml — server-side only, never commit, permissions 0600.
# Put it in the same directory as the server; the server auto-discovers it and
# the MCP command stays a bare `jira-mcp-server`.
base_url = "https://acme.atlassian.net"
auth_method = "basic"
user_email = "dev@acme.com"
api_token = "ATATT3YourSecretToken0001"
project_keys = "ENG,SALES"
tools = "read,create,update"
search_engine = "get"        # optional; default "jql" → enhanced POST /rest/api/3/search/jql
rate_limit = 200
```

JSON works too (flat, or nested under a `"jira"` key), and `tools` in the file
is honored exactly like `JIRA_TOOLS` (it restricts which MCP tools are
exposed). `search_engine` mirrors `JIRA_SEARCH_ENGINE`. Values never enter the
environment, so they are invisible to the MCP client and its AI; the file
should be `chmod 0600` and never committed.

Copy [`.env.example`](.env.example) → `.env` (next to the server
installation) for local dev. CLI flags take precedence over environment
variables.

## CLI reference

```
jira-mcp-server [OPTIONS]

Options:
  --transport [stdio|http|http-streamable]  Transport mode (default: stdio; env MCP_TRANSPORT)
  --token-file TEXT        Server-side credentials file (KEY=VALUE); JIRA_* entries are
                           merged into the process env, never into the client command (env JIRA_TOKEN_FILE)
  --search-engine [jql|get|auto]  Issue search API (default: jql → enhanced POST
                           /rest/api/3/search/jql; get → GET /rest/api/3/search/jql;
                           auto → alias for jql; the old /rest/api/3/search was
                           removed by Jira Cloud, returning 410) (env JIRA_SEARCH_ENGINE)
  --host TEXT                HTTP bind host (default: 127.0.0.1; env MCP_HOST)
  --port INTEGER             HTTP bind port (default: 8080; env MCP_PORT)
  --auth-mode [none|token]   HTTP client auth (default: none; env MCP_AUTH_MODE)
  --server-token TEXT        Bearer token for client connections (env MCP_SERVER_TOKEN)
  --cors-origins TEXT        CORS origins, comma-separated (default: *)
  --log-level [DEBUG|INFO|WARNING|ERROR]  Logging level (env MCP_LOG_LEVEL)
  --log-file TEXT            Optional log file (env MCP_LOG_FILE)
  --version                  Print version and exit
  --help                     Show help
```

## Transports

| Characteristic | stdio | HTTP/SSE |
|---|---|---|
| Best for | single local user | remote / team-shared / CI |
| Deployment | spawned by the client | standalone service |
| Clients | Claude Desktop, Cursor, VS Code | any MCP SSE/Streamable client |
| Concurrency | one client | many clients |
| Jira credentials | server-side env / `.env` / `--token-file` | server-side env / `.env` |
| Client auth | none (credentials not offered to client) | Bearer token (`MCP_SERVER_TOKEN`) |
| Network | local only | network reachable |
| Endpoint | stdin/stdout | `/sse` + `/messages/` (SSE) or `/mcp` (streamable); `/health`, `/` |

## Tools

> All tools return JSON text. On failure they return
> `{"isError": true, "content": [{"type": "text", "text": "Jira API Error [403]: …"}]}`.

### Issues

| Tool | Description | Key params |
|---|---|---|
| `jira_create_issue` | Create an issue (auto-converts description to ADF) | `project_key`, `summary`, `issue_type`; optional `description`, `priority`, `assignee_account_id`, `labels`, `components`, `custom_fields`, `parent_key` |
| `jira_update_issue` | Update fields | `issue_key`, `fields` |
| `jira_get_issue` | Get full issue | `issue_key`, `fields*`, `expand*` |
| `jira_delete_issue` | Delete (guarded) | `issue_key`, `confirm` (**must be true**), `delete_subtasks*` |
| `jira_transition_issue` | Transition by id or status name | `issue_key`, `transition_id*`/`target_status*`, `comment*`, `fields*` |
| `jira_get_transitions` | List available transitions | `issue_key` |
| `jira_add_comment` | Add a comment | `issue_key`, `body`, `visibility*`, `visibility_value*` |
| `jira_get_comments` | List comments | `issue_key`, `max_results*`, `start_at*` |
| `jira_link_issues` | Create a link between issues | `inward_issue_key`, `outward_issue_key`, `link_type`, `comment*` |
| `jira_get_issue_links` | List links | `issue_key` |
| `jira_search_issues` | JQL search (whitelist-scoped) via the enhanced `POST /rest/api/3/search/jql` (or GET variant when `JIRA_SEARCH_ENGINE=get`); includes readable fields by default and annotates the effective scope | `jql`, `max_results*`, `start_at*`, `fields*`, `expand*`, `fields_by_keys*` |
| `jira_search_issues_jql_only` | Compact JQL search (key/summary/status/assignee) on the enhanced search API | `jql`, `max_results*` (default 20) |

### Sprints / Boards

| Tool | Description | Key params |
|---|---|---|
| `jira_list_boards` | List boards | `project_key*`, `board_type*` |
| `jira_list_sprints` | List sprints | `board_id`, `state*`, `max_results*` |
| `jira_get_sprint_issues` | Issues in a sprint | `sprint_id` |
| `jira_move_issues_to_sprint` | Move issues to a sprint | `sprint_id`, `issue_keys` |

### Projects

| Tool | Description | Key params |
|---|---|---|
| `jira_list_projects` | List accessible projects | `max_results*` |
| `jira_get_project` | Get project detail | `project_key` |
| `jira_get_project_versions` | List project versions | `project_key` |

### Users

| Tool | Description | Key params |
|---|---|---|
| `jira_search_users` | Search users (privacy-filtered) | `query`, `max_results*` |
| `jira_get_myself` | Authenticated user info / debug auth | – |

### Attachments

| Tool | Description | Key params |
|---|---|---|
| `jira_add_attachment` | Upload a file (≤ 10 MiB) | `issue_key`, `file_path` |
| `jira_list_attachments` | List attachments | `issue_key` |

### Worklogs

| Tool | Description | Key params |
|---|---|---|
| `jira_add_worklog` | Log time | `issue_key`, `time_spent` (e.g. `2h 30m`), `comment*`, `started*` |
| `jira_get_worklogs` | List worklogs | `issue_key` |

### Example return value

`jira_create_issue(project_key="PROJ", summary="Login fails", issue_type="Bug")` →

```json
{"issue":{"id":"10004","key":"PROJ-9","self":"https://acme.atlassian.net/rest/api/3/issue/10004"}}
```

## Resources

MCP resources expose read-only context the model can reference:

| URI (template) | Content |
|---|---|
| `jira://projects` | Static listing of accessible projects (key – name). |
| `jira://project/{project_key}/meta` | Create-issue metadata: issue types + editable fields. |
| `jira://issue/{issue_key}` | Live snapshot of a single issue. |
| `jira://issue/{issue_key}/transitions` | Available workflow transitions. |

Reading `jira://project/PROJ/meta`, for example, returns JSON such as:

```json
{"projects": [{"key": "PROJ", "issuetypes": [{"name": "Bug", "fields": {"summary": {"required": true}}}]}]}
```

## Prompts

Prompts guide the model through structured workflows:

| Prompt | Description | Variables |
|---|---|---|
| `create_bug_report` | Draft a structured Bug + create it | `project_key`, `summary`, `steps_to_reproduce`, `expected_behavior`, `actual_behavior`, `severity` |
| `sprint_review_summary` | Summarize a sprint for review | `sprint_id` |
| `triage_issue` | Recommend priority/component/assignee | `issue_key` |
| `daily_standup_report` | Group last-24h issue changes by people | `project_key`, `sprint_id` (optional) |

Example — after invoking `create_bug_report` the model will assemble the
details and call `jira_create_issue` automatically.

## Tool permissions (CRUD control)

A Jira API token is often granted broad rights (e.g. create, edit, delete),
but a given deployment may only need a subset of them. Rather than
maintaining a separate token per workflow, the server can **restrict which
tools it exposes** via `JIRA_TOOLS`. Disabled tools are never registered, so
clients cannot see them in `tools/list` — an over-permissioned token can
still only drive the tools you opt in to.

`JIRA_TOOLS` is a comma-separated allowlist of category keywords and/or exact
tool names. Empty or unset keeps today's behavior (all tools enabled).

| Keyword | Effect |
|---|---|
| `read` | All read-only tools: `jira_get_*`, `jira_list_*`, `jira_search_*`, `jira_get_*_meta`. No mutating operations. |
| `create` | Create issues, add comments / attachments / worklogs, link issues, move issues to sprints. |
| `update` | Update issue fields and transition issues. |
| `delete` | `jira_delete_issue`. |
| `write` | Shorthand for `create,update,delete`. |

Examples:

```bash
# Read-only deployment (the Jira token may be full-admin; clients can only read).
JIRA_TOOLS=read

# Everything except deletion.
JIRA_TOOLS=read,create,update

# Read + exactly one extra tool.
JIRA_TOOLS=read,jira_add_comment
```

Notes:

- Values are case-insensitive; entries must be a known keyword or a real
  tool name. A typo (e.g. `JIRA_TOOLS=rede`) **fails startup** instead of
  silently dropping tools.
- The current tool-category mapping is:

  | Category | Tools |
  |---|---|
  | `read` | `jira_get_issue`, `jira_get_transitions`, `jira_get_comments`, `jira_get_issue_links`, `jira_get_issue_meta`, `jira_get_project_meta`, `jira_search_issues`, `jira_search_issues_jql_only`, `jira_list_projects`, `jira_get_project`, `jira_get_project_versions`, `jira_list_boards`, `jira_list_sprints`, `jira_get_sprint_issues`, `jira_list_attachments`, `jira_get_worklogs`, `jira_search_users`, `jira_get_myself` |
  | `create` | `jira_create_issue`, `jira_add_comment`, `jira_add_attachment`, `jira_add_worklog`, `jira_link_issues`, `jira_move_issues_to_sprint` |
  | `update` | `jira_update_issue`, `jira_transition_issue` |
  | `delete` | `jira_delete_issue` |

> This layer protects the *tool surface*, not Jira itself. Authorization also
> follows `JIRA_PROJECT_KEYS`: combine both to scope by operation **and** by
> project (e.g. `JIRA_TOOLS=read JIRA_PROJECT_KEYS=ENG` = read-only on one
> project).

## Security & best practices

- **HTTPS only** — `JIRA_BASE_URL` must start with `https://`; plaintext URLs
  are refused at startup.
- **No hardcoded credentials** — everything comes from the server environment
  or server-side files.
- **Server-side credentials only** — `JIRA_API_TOKEN` (and the base URL / user
  email) are configured on the MCP server process, never in the MCP client
  config or on the stdio command. In stdio mode an AI model runs inside the
  client; a token in the client config would be visible to it. Keep the token
  in a server-side `jira_server.toml`/`jira_server.json` (preferred), `.env`,
  or `--token-file` next to the installation.
- **Config file keeps secrets out of the environment** — for maximum
  protection, put `api_token` in the auto-discovered `jira_server.toml`/`jira_server.json`
  (read by the server only), so the value never appears in any environment
  variable that a client or process dump could expose, and the MCP launch
  command needs no path to it.
- **Token masking** — tokens are logged as `ATATT****`, never in full.
- **Never send MCP clients the Jira credentials** — the server exposes JQL as
  MCP tools (so the model never needs to "find the stored credentials" to query
  Jira); the credentials live only in the server-side file/environment.
- **Current search API** — issue search uses the enhanced
  `POST /rest/api/3/search/jql` by default (the only search endpoint Jira Cloud
  still serves; the old `/rest/api/3/search` returns 410), falling back to its
  GET variant with `JIRA_SEARCH_ENGINE=get`. Search requests include a readable
  default field set so results always carry `key`, `summary`, `status`, and
  `assignee`.
- **Project whitelist** — set `JIRA_PROJECT_KEYS=ENG,SALES` to scope all JQL
  searches and block writes to other projects.
- **Tool whitelist** — `JIRA_TOOLS` restricts which tools are exposed
  (see [Tool permissions](#tool-permissions-crud-control)); use it to keep an
  over-permissioned token from driving destructive tools.
- **Deletion guard** — `jira_delete_issue` requires `confirm=true`.
- **Attachment guard** — uploads over 10 MiB are rejected.
- **Rate limiting** — client-side token bucket (default 100 req/min).
- **HTTP transport auth** — enable `--auth-mode token` and a strong
  `MCP_SERVER_TOKEN` for anything networked.
- **Production HTTP** — bind `127.0.0.1` and put the server behind a reverse
  proxy (nginx/Caddy) that terminates TLS; restrict the port to the proxy and
  approved clients; enable request/audit logging.
- **Rotate tokens** — use distinct tokens per environment, rotate quarterly
  (or on any suspected leak), and remove users from Jira when they leave.
- **Least privilege** — give the token user only the Jira permissions the
  workflows need (browse + create/edit specific projects; not Global Admin).

## Docker deployment

### Dockerfile (included)

```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ src/
RUN pip install --no-cache-dir .
ENV MCP_TRANSPORT=http MCP_HOST=0.0.0.0 MCP_PORT=8080 JIRA_AUTH_METHOD=basic JIRA_TOOLS=read,create,update
EXPOSE 8080
CMD ["jira-mcp-server", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
```

### docker-compose.yml (included)

```yaml
services:
  jira-mcp:
    build: .
    ports: ["8080:8080"]
    environment:
      JIRA_BASE_URL: ${JIRA_BASE_URL}
      JIRA_API_TOKEN: ${JIRA_API_TOKEN}
      JIRA_USER_EMAIL: ${JIRA_USER_EMAIL}
      JIRA_PROJECT_KEYS: ${JIRA_PROJECT_KEYS:-}
      JIRA_TOOLS: ${JIRA_TOOLS:-}
      MCP_TRANSPORT: http
      MCP_HOST: 0.0.0.0
      MCP_PORT: 8080
      MCP_AUTH_MODE: ${MCP_AUTH_MODE:-none}
      MCP_SERVER_TOKEN: ${MCP_SERVER_TOKEN:-}
    restart: unless-stopped
```

```bash
docker compose up -d                        # reads .env for JIRA_* / MCP_SERVER_TOKEN
curl http://127.0.0.1:8080/health
```

### Kubernetes (example)

```yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: jira-mcp }
spec:
  replicas: 2
  template:
    metadata: { labels: { app: jira-mcp } }
    spec:
      containers:
        - name: jira-mcp
          image: your-registry/jira-mcp-server:latest
          ports: [{ containerPort: 8080 }]
          envFrom: [{ secretRef: { name: jira-mcp-secrets } }]
          readinessProbe:
            httpGet: { path: /health, port: 8080 }
---
apiVersion: v1
kind: Service
metadata: { name: jira-mcp }
spec:
  selector: { app: jira-mcp }
  ports: [{ port: 8080, targetPort: 8080 }]
```

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `Jira API Error [401]` | Jira token missing/wrong/expired | Regenerate at the API-token page; re-run | 
| `Jira API Error [403]` | Token lacks permission for the operation | Grant the user the Jira permission or widen API token scope |
| `Jira API Error [404]` | Wrong issue/project key | Verify the key; check the project exists and is accessible |
| `Jira API Error [400]` | Invalid field/value for the transition | Read the message; adjust `fields` |
| `Jira API Error [429]` | Jira's own rate limit | Slow down; honor `retry-after`; raise `JIRA_RATE_LIMIT` |
| Connection timed out | Wrong URL / blocked egress / proxy | Check `JIRA_BASE_URL`, outbound network, `HTTPS_PROXY` |
| `Request validation failed` on `/sse` | DNS-rebinding protection from the SDK | Use a real Host header; the SDK allows `127.0.0.1`/`localhost` as default. For proxies, pass `--host 0.0.0.0` or a configured host |
| SSE drops then reconnects | Idle/long-lived connection timeout on the network | Reconnect is automatic in MCP clients; check proxies for `< SSE > 60s` timeouts |
| `401` on the HTTP endpoint | `--auth-mode token` wrong/absent `Authorization` | Send `Authorization: Bearer <MCP_SERVER_TOKEN>` |
| `Connection closed` / `-32000` in Claude Code stdio | The server crashed at startup (often `JIRA_USER_EMAIL required …`): the config file wasn't found, so `JIRA_BASE_URL`/token weren't set | Drop `jira_server.toml` next to `jira-mcp-server.exe` (in the Python `Scripts` dir) and restart; run `jira-mcp-server --transport stdio` manually to see the error |

**Diagnosing Jira auth quickly**: run

```bash
jira-mcp-server --transport stdio
# and in a client, call jira_get_myself — it returns the Jira user the token resolves to.
```

**Rate limiting**: the client limiter blocks the calling task instead of
erroring, so batch prompts just run a little slower. Jira's own limits are
upstream of the token; space out CI loops and use `max_results` liberally.

## Development

### Add a new tool

1. Open `src/jira_mcp_server/tools/issues.py` (or the matching module).
2. Inside the existing `register(registry)` function add:

```python
@registry.tool(name="my_new_tool", title="...", description="...")
async def my_new_tool(
    ctx: Context,
    param1: Annotated[str, Field(description="...")],
    optional: Annotated[int | None, Field(default=None, description="...")] = None,
) -> Any:
    """Docstring used as the default description."""
    client = get_client()
    try:
        data = await client.some_endpoint(param1)
        return dict_result(data, label="result")
    except Exception as exc:
        return error_result(exc)
```

3. Add the matching client method in `src/jira_mcp_server/client.py`.
4. Run the tests.

### Add a new resource

In `src/jira_mcp_server/tools/resources.py`, inside `register()`:

```python
@registry.resource("jira://issue/{issue_key}/comments")
def comments_for_issue(issue_key: str) -> str:
    return json_dumps(get_client().get_comments_blocking_for_resource(issue_key))
```

### Tests

```bash
pytest                                # full suite
pytest -m "not network"               # offline unit + integration tests
pytest tests/test_transport_http.py   # HTTP/SSE app tests
```

The suite uses pytest + pytest-asyncio; HTTP tests run against an
in-process Starlette app via `httpx.ASGITransport` (no live network).

### Style

`ruff` (lint + format) and `mypy` are configured in `pyproject.toml`:

```bash
ruff check src tests
ruff format --check src tests
mypy src
```

Commit messages: conventional-commit style (e.g. `feat: add jira_export_issues`).
Changes land via PR; each PR must pass lint, type checks, and the test suite.

## Building wheel / sdist

Distribution artifacts (`wheel` for `pip install`, `sdist` for source) are
built with [`build`](https://pypi.org/project/build/) — a build-time tool,
**not** a runtime dependency, so it is installed into the virtual environment
on demand but never added to `pyproject.toml`.

### Windows — `scripts\build.bat`

```bat
scripts\build.bat
```

If `.venv` does not exist it is created; then the script installs `build`
into it, clears stale `build\` / `dist\` / `*.egg-info`, and runs
`python -m build`. Artifacts land in `dist\`:

| File | Purpose |
|---|---|
| `jira_mcp_server-<version>-py3-none-any.whl` | Binary distribution; use directly with `pip install` |
| `jira_mcp_server-<version>.tar.gz` | Source distribution (`sdist`); rebuild the wheel from source |

> **Windows note**: do not delete `.venv` from inside the script — a live
> Python holds locks on native `.pyd`/`.dll` files. Recreate it from a shell
> that is not using it: `rmdir /s /q .venv && python -m venv .venv`.

### macOS / Linux (steps this script automates)

```bash
python -m venv .venv
source .venv/bin/activate
pip install build            # build-time tool only; not a project dependency
python -m build              # builds wheel + sdist into dist/
```

### What goes into each artifact

- **Wheel** — the installed package: all `src/jira_mcp_server/**` modules, the
  `py.typed` type marker, `LICENSE`, entry point, and `METADATA`/`RECORD`.
  Tests and `.env.example` are **not** installed (they are dev-only).
- **sdist** — self-contained source: `src/`, `tests/`, `pyproject.toml`,
  `MANIFEST.in`, `LICENSE`, `README.md`, `py.typed`, and `.env.example`.
  `python -m build` verifies the sdist by rebuilding the wheel from it, so a
  broken manifest fails the build.

### Release checklist (before building)

1. Bump `version` in `pyproject.toml` **and** `src/jira_mcp_server/__init__.py`
   (they must match — the CLI reads the latter, packaging the former).
2. Update `README.md` `## Changelog`; verify `jira-mcp-server --version`.
3. Run `python -m pytest` (all green), `ruff check src tests`, `mypy src`.
4. `scripts\build.bat` (or the macOS/Linux commands above).
5. Inspect `dist\` — exactly one `.whl` and one `.tar.gz` for the new version.

## Publishing to PyPI

The artifacts in `dist\` are ready to upload once they pass the checklist
above. Tags/releases are outside this repo's automation; push a Git tag
(`v<version>`) and upload:

```bash
pip install twine              # upload tool; build/publish-time only
twine check dist\*.whl dist\*.tar.gz     # verify metadata + long description
twine upload dist\jira_mcp_server-<version>-py3-none-any.whl \
              dist\jira_mcp_server-<version>.tar.gz
```

`twine upload` prompts for the PyPI API token (or use
`TWINE_USERNAME=__token__ TWINE_PASSWORD=<token>` for CI). The project
declares its metadata (`name`, `version`, `description`, `license`,
`classifiers`, `[project.urls]`) in `pyproject.toml`, so no `setup.py` is
needed.

> **Version discipline**: never upload as `0.2.0` twice — PyPI rejects an
> existing version. Bump `pyproject.toml` + `src/jira_mcp_server/__init__.py`
> in lockstep and rebuild before every release.

### Verify the published package

Test the exact artifact before announcing a release:

```bash
python -m venv /tmp/verify && /tmp/verify/bin/python -m pip install \
  dist\jira_mcp_server-<version>-py3-none-any.whl
/tmp/verify/bin/jira-mcp-server --version
```

### Building the `jira-mcp-server.exe` console script

There is no separate "build" step for the executable — on Windows it is
created **automatically** when you install the project, from the
`[project.scripts]` entry point in `pyproject.toml`:

```toml
[project.scripts]
jira-mcp-server = "jira_mcp_server.cli:app"
```

#### What the entry point produces

`pip` (via the setuptools backend) generates a small **launcher**:

- On **Windows**: `jira-mcp-server.exe` inside the Python environment's
  `Scripts` folder (e.g. `D:\develop\Python312\Scripts\jira-mcp-server.exe`,
  or `.venv\Scripts\jira-mcp-server.exe` when using a virtual env).
- On **macOS/Linux**: a `jira-mcp-server` shell script on `PATH`.

The launcher is a thin shim that imports `jira_mcp_server.cli` and calls
`app` (a Typer command). All real logic lives in `src/`; the `.exe` is just
a starter.

#### Build it from source (Windows)

```powershell
# 1. Create and activate a virtual environment.
python -m venv .venv
.\.venv\Scripts\Activate.ps1

# 2. Install the project (editable keeps src/ live; plain install copies it).
#    Either is fine. "-e" means you can edit src/ and restart the MCP client
#    without reinstalling.
python -m pip install -e ".[dev]"      # editable, recommended for development
# python -m pip install .              # regular install, recommended for a fixed version

# 3. The .exe is now on PATH inside the venv (or the base Scripts dir).
jira-mcp-server --version              # -> jira-mcp-server 0.2.0
```

#### Verify

```bash
python -m jira_mcp_server --version   # same behavior as the exe
jira-mcp-server --version             # the generated launcher/exe
```

If `jira-mcp-server` is not found, ensure the environment's `Scripts`
directory is on `PATH` (Python's installer usually adds it). To reinstall
after deleting the launcher, re-run `pip install -e .`.

#### Editable vs. regular install

- `pip install -e .` ("editable") registers the launcher but points it back at
  your `src/` tree. Edit source, restart the MCP client, and the change is
  picked up **without reinstalling** — the normal choice while developing.
- `pip install .` ("regular") copies the package into the environment's
  `site-packages`; the launcher runs that frozen copy. Use it for a fixed
  version you do not expect to edit.

Both commands produce the same `jira-mcp-server.exe`; only where the code
lives differs.

#### Build without a virtual environment

If you installed Python globally and cannot or do not want a venv, run
`pip install -e .` directly. The launcher then lands in the base interpreter's
`Scripts` folder (e.g. `D:\develop\Python312\Scripts\jira-mcp-server.exe`),
which must be on `PATH` for MCP clients to find the `jira-mcp-server` command.

### Architecture / how to extend

```
src/jira_mcp_server/
├── cli.py            # typer CLI → transport selection
├── server.py         # create_server(): MCPServer + registration
├── config.py         # pydantic-settings (JIRA_* env), validated, lazy
├── safety.py         # credential masking / normalization
├── permissions.py    # JIRA_TOOLS allowlist: CRUD categories → tool sets
├── client.py         # JiraClient: httpx async, retry, pagination, scope
├── auth.py           # Basic / Bearer header construction
├── errors.py         # JiraError hierarchy + status mapping
├── formatters.py     # ADF ⇄ plain text
├── validators.py     # safe JQL building
├── rate_limiter.py   # token bucket
├── middleware.py     # HTTP auth / logging / CORS middleware + /health
├── transport/
│   ├── stdio.py      # stdio runner
│   ├── http.py       # SSE + Streamable HTTP apps (extends SDK app)
│   └── logging.py    # stderr/file logging, sensitive filter
└── tools/
    ├── core.py       # ToolRegistry, register_server, shared client
    ├── serde.py      # CallToolResult helpers
    ├── issues.py     # 14 issue tools
    ├── projects.py   # 3 project tools
    ├── sprints.py    # 4 board/sprint tools
    ├── users.py      # 2 user tools
    ├── attachments.py# 2 attachment tools
    ├── worklog.py    # 2 worklog tools
    ├── resources.py  # 4 MCP resources
    └── prompts.py    # 4 MCP prompts
```

### Planned / extension points

- Webhook events (the transport layer is decoupled so an HTTP Webhook route
  can be added without touching the tools).
- Multi-Jira-instance support (the config layer is a single `Settings` object;
  a future `MCP_INSTANCES` could create one per instance).
- Per-tool granular Prompts / completion metadata.

## Changelog

### v0.3.0 (2026-08-07)

- **Search results now always carry issue keys** — the enhanced
  `/rest/api/3/search/jql` endpoint returns bare `{"id": ...}` records unless a
  `fields` array is requested, so `jira_search_issues` without an explicit
  `fields` used to surface empty keys/summaries (models interpreted this as "no
  results"). Searches now request a readable default field set (`summary`,
  `status`, `assignee`) so every result includes `key` and the core fields.
- **Search results are annotated with the effective project scope** — when a
  `JIRA_PROJECT_KEYS` allowlist is configured, search payloads now include the
  allowed projects (and a note when a query matches nothing), so a model
  understands when the allowlist — not a bad query — is why a search returns no
  rows for a project outside the whitelist.
- **Config file discovery now also checks the interpreter's `Scripts`
  directory** — `jira_server.toml`/`jira_server.json` placed next to
  `jira-mcp-server.exe` (in `<python>/Scripts` or `<venv>/Scripts`) is found
  even when the MCP client launches the server from a **different working
  directory**. This fixes the Windows failure where Claude Code reported
  "Connection closed / -32000" because the server crashed at startup with no
  config (the toml *was* next to the exe, but only the cwd was searched).
- **Issue search moved off the deprecated endpoint** — `jira_search_issues` /
  `jira_search_issues_jql_only` now use the current Jira search API:
  `GET /rest/api/3/search` by default (documented by Atlassian as the primary
  search endpoint), and `POST /rest/api/3/search/jql` (enhanced search, with
  `fields_by_keys` support) when `JIRA_SEARCH_ENGINE=jql`. The deprecated
  `POST /rest/api/3/search` is never called, so tools no longer look "legacy"
  and models have no reason to try to reach past the MCP tools for Jira data.
- **CLI/config**: added `--search-engine` (env `JIRA_SEARCH_ENGINE`; also
  settable as `search_engine` in `jira_server.toml`/`jira_server.json`),
  validated at startup. Startup logs now state which search API is enabled.
- **Credential guard on search results** — issue-search tool output is
  sanitized server-side: any key named like a credential (`token`,
  `password`, `credentials`, `authorization`, `access_token`, …) is stripped,
  and token-like strings inside nested values are masked. Even if Jira (or a
  proxy) ever returned such a field, the model would never see it — which
  also means the model has no need to seek out the server's stored
  credentials itself.
- **Docs**: README tool table, env-var table, CLI reference, config-file
  section, and security checklist updated for the search engine and the "use
  the MCP tools, never reach for stored credentials" note.

### v0.2.0 (2026-08-06)

- **Credentials are server-side only (`JIRA_API_TOKEN` out of the MCP client)** —
  the Jira base URL, user email, and API token are no longer accepted as MCP
  client parameters in stdio mode: passing them through the client config would
  expose the token to the AI running in the client. The server reads them from
  its own environment (a `.env` file next to the installation) or from a
  separate `--token-file`/`JIRA_TOKEN_FILE` `KEY=VALUE` file. All stdio
  examples in this document now register a credential-free command.
  `JIRA_BASE_URL` is resolved server-side only (defaults to unset at
  construction; the validation still refuses plaintext).
- **CLI**: added `jira-mcp-server --token-file <path>` (env `JIRA_TOKEN_FILE`)
  which merges `JIRA_*` entries into the process environment before settings
  are built; secrets never appear on the client command line.
- **Config file next to the server (auto-discovered)** — the server now reads
  a fixed-name `jira_server.toml` / `jira_server.json` in the directory it is
  launched from, so the whole Jira connection (e.g. `api_token`, `base_url`,
  `auth_method`) lives in one server-side file with **no `--config`/path on
  the MCP launch command** and without entering any environment variable
  (visible to MCP clients/AI). `tools` there restricts the MCP tool surface
  exactly like `JIRA_TOOLS`. Existing `JIRA_*` env / `.env` / `--token-file`
  paths keep working alongside.
- **Tests**: test environment is now isolated from real host `.env` and
  `JIRA_*`/`MCP_*` variables (added config-file load tests and env isolation in
  `tests/conftest.py`).
- **Docs**: all documentation is now English-only (previously contained Chinese
  sections and changelog entries).
- **Packaging**: added `scripts\build.bat` and `MANIFEST.in`, added the missing
  `py.typed` PEP 561 marker, and documented wheel/sdist building and PyPI
  publishing. `python -m build` now produces a complete
  `jira_mcp_server-<version>-py3-none-any.whl` and `.tar.gz` (previously the
  sdist was missing `pyproject.toml` and the wheel lacked `py.typed`).

### v0.1.1 (2026-08-06)

- **Tool permission control (`JIRA_TOOLS`)** — restrict which MCP tools are
  exposed via comma-separated CRUD keywords (`read`/`create`/`update`/
  `delete`/`write`) or exact tool names. Disabled tools are not registered, so
  clients never see them; typos fail startup. See
  [Tool permissions](#tool-permissions-crud-control).
- **Docs**: added the full from-source install guide (Windows
  PowerShell/CMD) and how to generate `jira-mcp-server.exe` locally; added the
  [Client token (HTTP auth)](#client-token-http-auth) section covering
  client-token generation, server-side and client-side configuration, and
  rotation.

### v0.1.0 (2026-08-06)

- Initial release.

---

*Jira MCP Server is not affiliated with, endorsed by, or sponsored by Atlassian.
"Jira" is a trademark of Atlassian Pty Ltd.*
