jira-fetch
v0.6.0
Published
Fetch Jira Cloud issues into Markdown files with frontmatter and attachments — a CLI and MCP server for AI agents.
Maintainers
Readme
jira-fetch
Fetch Jira Cloud issues into Markdown files with YAML frontmatter, attachments and all — from the terminal, or over MCP so an AI agent reads Jira through a config file it does not control.
This project was written entirely by AI. Every line was produced by Claude — tested, but never human-reviewed. Read it before you trust it with credentials.
jira-fetch DN-1243 --out tmp
# tmp/DN-1243.md
# tmp/.DN-1243/screenshot_01.pngThe document is a plain Markdown file: metadata in the frontmatter, a heading that links back to the
ticket, the description as Markdown, then every comment after a --- rule. The frontmatter carries
only what the ticket actually has — a key you do not see is a value Jira did not have.
Attachments are downloaded next to it and linked relatively, so it stays readable offline. Only the
ones actually embedded in the description or a comment get a link in the body; the rest are still
listed in the frontmatter's assets, the complete index of what was fetched.
Install
npm install -g jira-fetchWorks on macOS, Linux and Windows, x64 and arm64.
npm update -g jira-fetch # upgrade
npx jira-fetch DN-1243 # run it once, installing nothingConfiguration
Your credentials and your filters live in one YAML file per project, in your own configuration directory, outside every repository:
| | |
| ------------ | ------------------------------------------------------------------------------------ |
| macOS, Linux | ~/.config/jira-fetch/<project-path>.yml |
| Windows | %APPDATA%\jira-fetch\<project-path>.yml (that is %USERPROFILE%\AppData\Roaming\) |
The file is named after the git repository root, so you never pick it and there is nothing to pass:
no environment variables, no flags, no config file in the project. jira-fetch therefore runs only
inside a repository. The MCP server is why it works this way.
jira-fetch setup # credentials, output folder, people
jira-fetch filters # which tickets are fetched
jira-fetch config-file # print its path, whether or not it exists yetsetup asks for your site, your account email and an API token — including where to create one —
and checks them against Jira before writing anything. Once they work, it shows a form with the rest
of the settings and their current values, so you change what you care about and leave the rest.
To edit the file by hand afterwards:
$EDITOR "$(jira-fetch config-file)"A minimal file:
project: /home/you/code/thing # the repository this configuration is for
baseUrl: https://your-site.atlassian.net
email: [email protected]
token: ATATT3xFfGF0...What your Jira site contains
Filters are easier to write when you can see what there is to filter on, so jira-fetch keeps a
copy of your projects' labels, fields and the values they accept, issue types, statuses, components,
versions, sprints and people:
jira-fetch cache DN SUP # read those projects; they are remembered for next time
jira-fetch cache # read whatever has gone out of date
jira-fetch cache --show # what is cached, and how old
jira-fetch cache --clear # delete itjira-fetch filters does this for you before it offers anything, so there is nothing to run first —
the command above is for looking at what was read, or for reading a project you have not filtered
on yet.
| | |
| ------------ | ------------------------------------ |
| macOS, Linux | ~/.cache/jira-fetch/<hash>/ |
| Windows | %APPDATA%\jira-fetch\cache\<hash>\ |
It refreshes itself as it ages, so there is nothing to run on a schedule. Anything it could not read
— a project you cannot create issues in, a site with no Agile boards, a token without permission for
some of it — is recorded as unreadable rather than as empty, and --show says which. Nothing in
there is anything a fresh read cannot produce again, so deleting it is always safe.
Usage
jira-fetch <ISSUE-KEY>... fetch one or more issues by key
jira-fetch --jql "<JQL>" fetch every issue matching a query
jira-fetch mcp serve the same pipeline over MCP (see below)
jira-fetch setup configure this project, interactively
jira-fetch filters choose which tickets are fetched
jira-fetch config-file print the path of this project's config file
jira-fetch cache <KEY>... read what your Jira projects contain
-o, --out <dir> output directory (default: current directory)
-n, --dry-run report what would be fetched and filtered; write nothing
-v, --verbose per-issue progress and filter decisions on stderrExit codes: 0 success · 1 runtime error · 2 usage or config error · 3 nothing written
because every issue was excluded by a filter. An interactive menu left with Ctrl+C exits 130.
Filters
Filters decide which tickets are fetched at all, and which comments make it into the document.
jira-fetch filters builds them by picking from what your site actually has — its labels,
statuses, components, versions, sprints, people, and the values each custom field accepts — so you
are choosing from a list rather than typing from memory.
docs/config-example.yml
shows every option in one file.
# yaml-language-server: $schema=https://raw.githubusercontent.com/casaper/jira-fetch/main/schema/jira-fetch.schema.json
filters:
exclude:
- project: [SUP]
- labels: [wontfix]
- field:
Team: [Platform]
- title:
matches: '^spike:'
flags: i
- reporter: [null]
comments:
exclude:
- author: [Automation for Jira]- Every predicate in a rule must match (AND); rules in a list are OR'd. So the example drops
a ticket in project SUP, or labelled
wontfix, or … includeworks the same way: if it is non-empty, a ticket must match one of its rules to be fetched. Exclude beats include.nullmeans "absent" — an anonymous portal reporter, an unassigned issue, an unset field. It cannot collide with someone actually named "anonymous".fieldreaches every field, built-in ones included, spelled exactly as Jira spells it:field: {Status: [Done, Cancelled]}andfield: {Issue Type: [Bug]}are how you filter by status and by type, and there is no separate predicate for either. A rawcustomfield_10101works in place of a name — and is whatjira-fetch filterswrites, showing you the name on screen, so do not be surprised to findcustomfield_10101:where you picked "Team". A name is resolved through the cached field list; an id is matched before it, so a rule that names one keeps meaning the same field whatever happens to that cache.- A field name that does not resolve stops the run with exit code 2, before any issue is fetched — as does one that resolves to two fields, since Jira lets two custom fields share a name. The error names both ids so you can pick one.
tagsis an alias forlabels.- Comment filters drop comments, never the ticket, and are exclude-only.
The $schema line gives editors autocomplete and inline validation. It is generated from the same
Zod definitions the CLI validates against, so the two cannot drift; point it at a local copy if you
would rather not fetch it.
People
How much the document says about the people on a ticket is up to you:
people:
roles: [reporter, assignee, commenter] # who appears at all; [] leaves people out entirely
fields: [name, email] # what is recorded about them; at least one
nameFormat: full # or: initialsThose are the defaults, and roles and fields are independent axes. nameFormat: initials writes
Kaspar Vollenweider as KV, in the frontmatter and in comment headings alike; it shortens a name
and only a name, so a heading falling through to an email address keeps it whole. All of this is
presentation only — reporter and assignee filters read the issue itself, so leaving someone
out of the document never changes which tickets are fetched.
What "never fetched" really means
Only the project prefix is decided without fetching, since it comes from the issue key.
Everything else needs the payload, so the filter runs before comments and attachments — where the
cost and every disk write are. A filtered ticket costs one request and writes nothing; --dry-run
and -v show it happening.
Restricting JQL
allowJql: false makes --jql fail with exit code 2. It also removes the search_issues tool
from the MCP server, so an agent cannot query either. Use it when someone should only fetch tickets
by key. It gates the flag, not the requests the tool makes on its own.
MCP server
jira-fetch mcp serves the same pipeline over the
Model Context Protocol on stdin/stdout, so an agent — Claude
Code, or any other MCP client — can read Jira through this tool.
The reason to want that is not convenience. It is that the agent's access is decided by a config file, not by instructions you give it:
- It cannot write to Jira, because there is no tool that writes. Not "the agent was told not
to" — the capability is absent from
tools/list, so there is nothing to talk it into. - The filters above decide which issues it may fetch, exactly as they do at the terminal. There is no tool argument, no query and no prompt that reaches past them.
- JQL is not a way around them. A query only chooses candidate keys; each key then goes through the same filter stages as a key you typed yourself.
Which is cheaper than minting a separate read-only Atlassian token for every class of ticket you want to fence off.
Setting it up with Claude Code
npm install -g jira-fetch
claude mcp add --scope user jira-fetch -- jira-fetch mcp --out docs/jiraOr without installing anything, at the cost of npx resolving the package on each launch:
claude mcp add --scope user jira-fetch -- npx -y jira-fetch mcp --out docs/jiraThere is nothing else to pass: the server finds the same config file the CLI would, derived from the
repository it is started in. --scope user puts the definition in ~/.claude.json rather than a
.mcp.json in the project, which keeps the launch command itself out of the tree the agent edits.
Check it once: -v prints the config file the server resolved, on stderr, where Claude Code
shows it as MCP server output. If it is not the file you meant, nothing else here is true of your
setup.
What the agent cannot do
- It cannot rewrite the policy. There is no config file inside the project; the path is computed from the repository root, so it lands outside the tree the agent edits.
- It cannot reach the credentials. The token is in that one file and nowhere else — not in the environment its shell inherits, and not in any flag it could pass.
But this is not a sandbox. The server runs as you, and so does the agent's shell — cat
"$(jira-fetch config-file)" is a command, not a trick. The only hard boundary is on Atlassian's
side, so use a token whose account cannot see what you do not want read.
The guarantee is also about what the server will fetch, not what an agent can read. Filters are
evaluated at fetch time, so tightening them later does not remove documents already written, and
those are readable with the agent's ordinary file tools. Point --out at a directory you can
clear if that matters to you.
// ~/.claude/settings.json — one write, every project
{
"permissions": {
"deny": [
"Read(~/.config/jira-fetch/**)",
"Edit(~/.config/jira-fetch/**)",
"Read(~/.cache/jira-fetch/**)",
"Edit(~/.cache/jira-fetch/**)"
]
}
}// <project>/.claude/settings.local.json
{ "permissions": { "deny": ["Bash(jira-fetch setup:*)"] } }Both directories go at user scope deliberately: a deny at any scope beats an allow at any other,
so a project cannot grant back what they take away. Read also covers Grep, Glob and the file
reads Claude Code recognises inside Bash. setup merges them into whatever is already in those
files and adds nothing on a second run — and it refuses to run without a terminal, which an agent's
shell does not have.
The configuration directory holds your token. The cache beside it holds no credentials, but it does
hold every person your projects can assign to, and the field list in it takes part in deciding which
field a field: filter means.
These stop the well-behaved path and are worth having for that, but they do not reach a script that opens either file itself, and an agent with a shell can edit the settings files too.
The tools
| Tool | What it does |
| --------------- | ------------------------------------------------------------------------ |
| fetch_issues | Writes a document for each issue key given, up to 50 |
| search_issues | The same, for each issue a JQL query matches, up to limit (default 25) |
search_issues is not offered at all when the config sets allowJql: false — it is missing
from tools/list, rather than present and refusing.
Both write into the output directory fixed when the server starts and return a link to each document. No tool takes a path: the agent chooses which issues it wants, never where bytes land. No ticket content travels through the protocol — the agent reads the files.
An issue the config denies is reported as not available (no such issue, or not permitted by this
server configuration) and no file is written for it. A nonexistent issue gets the same
sentence, on purpose: answering differently would let an agent map your deny-list by probing keys
one at a time. Jira's own API already conflates the two.
