Skip to main content
Glama
ranson21

jira-readonly-mcp

by ranson21
README.md
# jira-readonly-mcp

A small, dependency-light [MCP](https://modelcontextprotocol.io) server that gives local
coding agents (OpenCode, or anything else that speaks MCP over stdio) safe, **read-only**
access to an existing Jira instance — without ever handing the LLM your Jira credentials.

> **This project is read-only.** It cannot create, edit, comment on, transition, delete,
> reassign, or otherwise modify anything in Jira. See [Threat & security model](#threat--security-model)
> for how that's enforced.

```
OpenCode / local LLM
     │  MCP (stdio, JSON-RPC)
     ▼
jira-readonly-mcp   ← owns the Jira credential; the LLM never sees it
     │  Jira REST API (GET only)
     ▼
Jira (Cloud or Server/Data Center)
```

## What this is for

Local coding agents are often asked to look at a Jira ticket ("summarize DEMO-123", "what's
blocking this epic?"). The naive way to enable that is to give the agent your Jira URL and
token directly, which means the token ends up in prompts, logs, and potentially the model
provider's request. This server instead sits in between: it holds the credential, calls Jira
itself, strips it down to compact structured data, and hands that to the model. The model
never sees `JIRA_TOKEN`.

## Threat & security model

**What this protects against:** the LLM (or a malicious prompt injected into a Jira ticket)
never has the ability to obtain your Jira credential, and never has the ability to send a
write request to Jira, because this process is architecturally incapable of it.

Concretely:

- The Jira credential lives only in this server's environment variables. It is never
  included in any MCP message, tool schema, tool result, or log line.
- [`src/jira/client.ts`](src/jira/client.ts) exposes exactly one HTTP method: `get()`. There
  is no `post`/`put`/`patch`/`delete` method anywhere in the codebase for a compromised or
  confused tool call to reach. Every outgoing request also passes through
  [`assertReadOnlyMethod()`](src/security/guard.ts), which throws if the method is anything
  but `GET`/`HEAD`. This is enforced in code, not by asking the model nicely — see
  `tests/guard.test.ts` and `tests/client.test.ts`.
- The six MCP tools this server registers ([below](#available-tools)) only ever call `.get()`.
  There is no tool for creating, editing, commenting on, transitioning, deleting, or
  reassigning issues, and none is planned — adding one would require deliberately building a
  new write path through a client that currently cannot make one.
- Responses are passed through [`src/security/sanitize.ts`](src/security/sanitize.ts) before
  being returned to the model, which redacts common credential shapes (Bearer/Basic auth
  headers, `password=`/`api_key=`/`secret=`-style assignments, PEM private key blocks, AWS
  access key IDs, Atlassian API token shapes) that might otherwise be echoed back inside a
  ticket description, a comment, or a verbose upstream error message.

**What this does *not* protect against, and is explicitly out of scope:**

- **The sanitizer is defense-in-depth, not a security boundary.** It's a best-effort regex
  scrubber over text that Jira users wrote. It will not catch every possible secret shape, and
  it is not a substitute for keeping real secrets out of Jira in the first place. Treat it as
  a safety net, not a guarantee.
- This server does not sandbox or rate-limit the *content* of what Jira returns — if a ticket
  contains something sensitive that doesn't match a redaction pattern, it will reach the model
  like any other ticket field.
- This server trusts whatever process starts it to supply correct environment variables. It
  does not manage secrets storage, rotation, or audit logging beyond what you configure
  yourself.
- Anyone who can run this process (or read your `.env`) can read anything your Jira account
  can read. Scope the account/token you use accordingly (see
  [Authentication configuration](#authentication-configuration)).

## Available tools

| Tool | Description |
| --- | --- |
| `get_issue(issue_key)` | Full compact view of one issue: summary, description, status, type, priority, acceptance criteria (if configured), comments, and linked issues. |
| `search_issues(jql, limit?)` | Runs a JQL query, returns compact rows (key, summary, status, type, priority, assignee). Result count is capped. |
| `get_comments(issue_key, limit?)` | Comments on an issue as `{ author, body, created }`. |
| `get_related_issues(issue_key)` | Linked issues, subtasks, and parent for an issue. |
| `get_my_open_issues(limit?)` | Unresolved issues assigned to the authenticated user. |
| `get_project_metadata(project_key)` | Basic project info: name, type, lead. |

None of these accept a payload that could mutate Jira — there's no `fields` argument for
writing values, no `transition` argument, nothing beyond what's needed to select what to read.

## Installation on macOS

Requires Node.js 18.17+ (Apple Silicon or Intel; no native/compiled dependencies).

```bash
git clone https://github.com/ranson21/jira-readonly-mcp.git
cd jira-readonly-mcp
npm install
npm run build
```

This produces `dist/index.js`, a plain Node script you point OpenCode (or any MCP client) at.

## Authentication configuration

Copy the example env file and fill in your real values — **do not commit `.env`** (it's
already git-ignored):

```bash
cp .env.example .env
```

### `.env.example`

```env
# Copy this file to .env and fill in real values.
# .env is git-ignored - never commit it.
#
# All values below are FAKE placeholders for illustration only.

# Base URL of your Jira instance (Cloud or Server/Data Center), no trailing slash.
# Cloud example:   https://your-company.atlassian.net
# Server/DC example: https://jira.example-corp.internal
JIRA_BASE_URL=https://example.atlassian.net

# --- Authentication ---
# Jira Cloud: use your Atlassian account email + an API token
#   (create one at https://id.atlassian.com/manage-profile/security/api-tokens)
# Jira Server/Data Center: usually a Personal Access Token (PAT), no username needed
#   (create one under your Jira profile -> Personal Access Tokens)

# Required for Jira Cloud basic auth. Leave unset for Server/DC bearer-token auth.
JIRA_USERNAME=you@example.com

# Jira Cloud API token, or Jira Server/DC Personal Access Token.
JIRA_TOKEN=REPLACE_WITH_YOUR_TOKEN

# Optional: force the auth scheme instead of auto-detecting from JIRA_USERNAME.
# One of: basic | bearer
# JIRA_AUTH_TYPE=basic

# Optional: Jira REST API version to call. Defaults to "2", which both Jira
# Cloud and Jira Server/Data Center support for read operations.
# JIRA_API_VERSION=2

# Optional: custom field ID that holds "Acceptance Criteria" in your Jira
# instance, e.g. customfield_10040. Leave unset if you don't use one.
# JIRA_ACCEPTANCE_CRITERIA_FIELD=customfield_10040

# Optional: default/maximum number of results returned by search tools.
# JIRA_DEFAULT_SEARCH_LIMIT=25
# JIRA_MAX_SEARCH_LIMIT=50

# Optional: request timeout in milliseconds.
# JIRA_REQUEST_TIMEOUT_MS=15000

# Optional: diagnostic log verbosity. One of: debug | info | warn | error.
# Logs always go to stderr, never stdout (stdout is reserved for MCP JSON-RPC
# traffic). Defaults to "info".
# JIRA_MCP_LOG_LEVEL=info
```

Two supported auth modes, auto-detected from whether `JIRA_USERNAME` is set (or force one
with `JIRA_AUTH_TYPE=basic|bearer`):

- **Basic** (`JIRA_USERNAME` + `JIRA_TOKEN`): sends `Authorization: Basic base64(user:token)`.
  This is the standard way to authenticate to **Jira Cloud** — `JIRA_USERNAME` is your
  Atlassian account email, `JIRA_TOKEN` is an API token from
  <https://id.atlassian.com/manage-profile/security/api-tokens>.
- **Bearer** (`JIRA_TOKEN` only, no `JIRA_USERNAME`): sends `Authorization: Bearer <token>`.
  This is the standard way to authenticate to **Jira Server/Data Center** with a Personal
  Access Token, created from your Jira profile's "Personal Access Tokens" page. No Jira
  administrator action is required to create a PAT for your own account.

Neither mode requires Jira admin access, Rovo/Atlassian-admin features, or any change to the
Jira instance itself — just a normal authenticated-user credential.

### Optional: macOS Keychain

If you'd rather not keep the token in a plaintext `.env` file, store it in Keychain and
export it into the environment at launch time instead of setting `JIRA_TOKEN` directly:

```bash
security add-generic-password -a "$USER" -s jira-readonly-mcp-token -w 'your-token-here'
```

Then wrap the launch command (e.g. in the OpenCode config below) so `JIRA_TOKEN` is populated
from Keychain right before the server starts, for example with a small shell wrapper:

```bash
#!/bin/sh
# run.sh
export JIRA_TOKEN="$(security find-generic-password -a "$USER" -s jira-readonly-mcp-token -w)"
exec node "$(dirname "$0")/dist/index.js"
```

Point OpenCode's `command` at `run.sh` instead of `node dist/index.js` directly. This keeps
the token out of `.env`, shell history, and process-listing tools; it's still visible to
anything that can read this process's environment once it's running, same as any other
env-var-based secret.

## OpenCode MCP configuration

Add this to your OpenCode config (e.g. `opencode.json` or `~/.config/opencode/opencode.json`),
using OpenCode's [local MCP server schema](https://opencode.ai/docs/mcp-servers/):

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "jira": {
      "type": "local",
      "command": ["node", "/absolute/path/to/jira-readonly-mcp/dist/index.js"],
      "enabled": true,
      "environment": {
        "JIRA_BASE_URL": "https://your-company.atlassian.net",
        "JIRA_USERNAME": "you@example.com",
        "JIRA_TOKEN": "your-real-token-goes-here-not-in-git"
      }
    }
  }
}
```

Use an absolute path for `command` — OpenCode resolves it relative to its own working
directory, not this repo. If you're using the Keychain wrapper above, point `command` at
`run.sh` and drop `JIRA_TOKEN` from `environment`.

## Example tool calls

Once configured, ask your agent things like:

- "Read Jira issue DEMO-123 and summarize the requested work."
- "Search Jira for open bugs in project DEMO with `search_issues` using JQL
  `project = DEMO AND type = Bug AND status != Done`."
- "What issues are linked to DEMO-123?" (uses `get_related_issues`)
- "What's currently assigned to me in Jira?" (uses `get_my_open_issues`)

A `get_issue` result looks like:

```json
{
  "key": "DEMO-123",
  "summary": "Add dark mode toggle to settings page",
  "description": "Users have requested a dark mode toggle...",
  "status": "In Progress",
  "type": "Story",
  "priority": "Medium",
  "acceptanceCriteria": null,
  "comments": [
    { "author": "Sam Sample", "body": "Started on this.", "created": "2026-01-06T12:00:00.000+0000" }
  ],
  "links": [
    { "type": "blocks", "key": "DEMO-124", "summary": "Design dark mode palette", "status": "Done" }
  ]
}
```

## Logging

This server writes diagnostic logs to **stderr only**. It never writes anything to stdout,
because stdout is the MCP JSON-RPC transport for this stdio server — any stray line written
there would corrupt the protocol stream and break the client. `console.error` (or an
equivalent stderr-targeted call) is used everywhere logging happens; `console.log` is never
used in this codebase.

Control verbosity with `JIRA_MCP_LOG_LEVEL` (`debug` | `info` | `warn` | `error`, default
`info`). Each line looks like:

```
[2026-09-13T01:19:17.703Z] [INFO] [jira-readonly-mcp] tool invoked {"tool":"get_issue","issueKey":"DEMO-123"}
```

What gets logged, and at what level:

| Event | Level |
| --- | --- |
| Server startup (target base URL, auth type, log level — never the token) | info |
| Tool invoked (tool name, issue key/JQL/project key/limit as applicable) | info |
| Outbound Jira request start (method + path only, no query string) | debug |
| Outbound Jira request complete (method, path, HTTP status, elapsed ms) | info |
| Sanitization complete (tool name only) | debug |
| Tool succeeded (tool name only) | info |
| Errors (auth failures, network failures, tool failures) | error |

What is **never** logged, at any level: the Jira token, the `Authorization` header value,
passwords/secrets, full issue descriptions or comment bodies, raw Jira API response bodies,
or other ticket content. As an extra safety net on top of that, every structured log field
also passes through the same redaction layer used for tool output
([`src/security/sanitize.ts`](src/security/sanitize.ts)) before being written — see
[Threat & security model](#threat--security-model) for why that's defense-in-depth, not a
guarantee.

If you don't see any log output, make sure you're capturing your MCP client's stderr stream —
OpenCode and most MCP clients keep it separate from the tool-call output you see in chat. When
running the server directly, `2>` redirection or just watching the terminal (stderr isn't
buffered the way piped stdout can be) will show it.

## Verifying the server without exposing credentials

You can confirm the server works end-to-end using fake/placeholder credentials against a
Jira instance you control (or just verify it *fails safely* against a bogus host), without
ever putting a real token in a terminal that a shared log might capture:

```bash
npm run build

JIRA_BASE_URL="https://your-real-instance.atlassian.net" \
JIRA_USERNAME="you@example.com" \
JIRA_TOKEN="$(security find-generic-password -a "$USER" -s jira-readonly-mcp-token -w)" \
node dist/index.js
```

The server logs a single non-sensitive startup line to stderr (`connected (stdio), read-only,
target=..., auth=...`) and then waits for MCP JSON-RPC messages on stdin. You can drive it
manually with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):

```bash
npx @modelcontextprotocol/inspector node dist/index.js
```

Inspector lets you call `tools/list` and `tools/call` interactively in a browser UI without
ever printing your token to the terminal. Run `npm test` first (see [Testing](#testing)) to
verify all parsing/sanitization/auth-handling logic without touching a real Jira instance at
all.

## Jira Cloud vs. Data Center notes

This server calls the Jira REST API v2 endpoints (`/rest/api/2/...`) by default, which are
supported by **both** Jira Cloud and Jira Server/Data Center for the read operations this
project uses. You generally don't need to change anything.

- **Jira Cloud**: use Basic auth (email + API token). Descriptions/comments are internally
  ADF (Atlassian Document Format) documents even under `/rest/api/2/`; this server flattens
  them to plain text automatically (`src/jira/transform.ts`).
- **Jira Server/Data Center**: use Bearer auth (Personal Access Token). Descriptions/comments
  are typically plain text/wiki markup strings, which pass through unchanged.
- If your instance needs `/rest/api/3/...` for some reason, set `JIRA_API_VERSION=3` in
  `.env`. The response parsing already handles both plain-text and ADF descriptions, so this
  should work either way.
- This server does not assume Rovo MCP, Atlassian admin/Connect app access, or any
  Cloud-specific feature — only the ordinary REST endpoints available to any authenticated
  user (`/issue/{key}`, `/issue/{key}/comment`, `/search`, `/project/{key}`).

## Troubleshooting

- **"Missing required environment variable JIRA_BASE_URL / JIRA_TOKEN"** — you haven't set up
  `.env` (or the `environment` block in your OpenCode config). See
  [Authentication configuration](#authentication-configuration).
- **`JiraAuthError` / HTTP 401 or 403** — the token is invalid, expired, or the auth mode is
  wrong for your deployment (Cloud wants Basic + email, Server/DC wants Bearer + PAT). Try
  `JIRA_AUTH_TYPE=basic` or `JIRA_AUTH_TYPE=bearer` explicitly to rule out mis-detection.
- **"Jira returned a non-JSON response"** — usually means `JIRA_BASE_URL` is wrong (e.g.
  pointing at a login page or reverse proxy), or the wrong `JIRA_API_VERSION` for your
  deployment.
- **Empty `links` array you expected to be populated** — Jira only returns `issuelinks` when
  the field is requested and the link type is visible to your account/permission scheme. This
  is a Jira permissions detail, not a bug in this server.
- **`acceptanceCriteria` is always `null`** — set `JIRA_ACCEPTANCE_CRITERIA_FIELD` to the
  custom field ID that holds it in your Jira instance (e.g. `customfield_10040`). Field IDs
  are instance-specific; ask your Jira admin or check an issue's "View field" metadata.
- **OpenCode doesn't see the tools** — double check `command` is an absolute path to
  `dist/index.js` and that you ran `npm run build` (OpenCode runs the compiled output, not
  the TypeScript source).

## Running entirely locally

Aside from the HTTPS requests this server makes directly to your `JIRA_BASE_URL`, everything
else — the MCP process, the OpenCode/local-model side, tool schema handling, and the
sanitization layer — runs entirely on your machine. No third-party service, telemetry
endpoint, or analytics call is contacted. This makes it a good fit for local model setups
(OpenCode + a locally-served model, e.g. via MLX/OptiQ) where you want to keep everything
except the necessary Jira traffic on-device.

## Testing

```bash
npm test
```

Tests cover issue/comment/project parsing (including ADF vs. plain-text descriptions),
malformed/partial Jira responses, JQL search handling and result-limit clamping, credential
redaction (pattern-based and key-name-based), rejection of any non-GET HTTP method, and
authentication-failure handling (401/403). All tests run against fixtures with fictional data
in `tests/fixtures/` — no network access, no real Jira instance required.

## Development

```bash
npm run dev    # run src/index.ts directly with tsx, for local iteration
npm run build  # compile to dist/
npm test       # run the test suite
```

## License

MIT — see [LICENSE](LICENSE).

TDQS

A3.7/5.0

Scored across 6 tools

Disambiguation4/5

Most tools have clearly distinct targets (single issue, JQL search, comments, links, my issues, project metadata). However, get_issue already returns comments and linked issues, so it partially overlaps with get_comments and get_related_issues, and get_my_open_issues is essentially a preset of search_issues. Descriptions do help clarify the intended use cases.

Naming Consistency4/5

All names use snake_case with a verb_noun structure (get_issue, get_comments, get_related_issues, get_project_metadata, get_my_open_issues). The only deviation is search_issues, which is still readable and semantically appropriate for a query action rather than a fetch.

Tool Count5/5

Six tools is well-scoped for a focused read-only Jira integration. Each tool maps to a distinct read operation with no filler or redundancy beyond the mild overlap noted.

Completeness4/5

The read-only surface covers the core read paths: fetch, search, comments, links/subtasks, assigned work, and project metadata. Minor gaps exist for read-only needs like attachments, worklogs, or listing all accessible projects, but these are workable omissions.

Maintenance

ActivityMaintained
ResponsivenessNo issues