Build an MCP server for Jira and GitHub
A Python MCP server with eight Jira and GitHub tools, an in-memory fake for demos, and a live REST mode that keeps tokens in the environment. Read-only mode, allowlists, and a per-tool rate limit sit in front of every call.
An agent is only as useful as the tools you are willing to let it call. The Jira and GitHub MCP server is a small Model Context Protocol server that exposes eight of those tools: search, create, comment, and transition on Jira, plus list pull requests, summarize a diff, create an issue, and comment on GitHub.
This is a portfolio demo, not a production integration. Nothing in the repo is affiliated with Atlassian or GitHub. The default backend is an in-memory fake, so you can try the protocol with no credentials. The live clients are thin REST wrappers. They are not a hardened gateway, and the README says so up front.
The server is built with the official Python SDK (mcp 2.x) and an MCPServer named jira-github. A client speaks MCP over stdin and stdout. Claude Desktop and Cursor both launch the process and keep that pipe open. Logs are JSON on stderr so they do not collide with the MCP stream. Argument values are not logged. A log line has the tool name, a success flag, an error code, and a duration.
Install and run the mock
Python 3.11 or newer:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
python -m jira_github_mcpThe console script is jira-github-mcp. Either form waits for MCP messages. In a second terminal, the example client starts the server itself, lists the tools, searches Jira for the SSO bug, and summarizes pull request 43:
python examples/agent_example.pyThat script forces MCP_BACKEND=mock and MCP_READ_ONLY=true. You do not need a .env file for it.
The mock workspace
The fake data is one small story so a demo agent has something to read.
- PLAT-101 is a Bug, status Open. The SSO callback drops
stateand returns 500. - PLAT-102 is a Story, status In Progress, about auditing every agent tool call.
- MOB-14 is a Task, status Open, about caching pull request summaries for a mobile review bot.
- acme/platform#41 is a closed spike for an in-memory rate limiter.
- acme/platform#42 is an open pull request for structured audit logging, with 160 additions.
- acme/platform#43 is an open pull request that rejects SSO callbacks missing state.
- acme/platform#7 is an open issue documenting the mock query syntax.
The mock GitHub client only knows acme/platform. Any other repository returns not found. Mock writes disappear when the process exits. There is no database.
Mock issue search understands a small filter syntax, not JQL: project:PLAT, status:"In Progress", type:Bug, key:PLAT-101, plus free text. New mock issues start as Open and must be a Bug, Task, or Story. The mock workflow is Open, then In Progress, then In Review, then Done, and also Open straight to Done. Repeating the current status is a no-op.
What each tool does
| Tool | Mode | Behavior |
|---|---|---|
jira_search_issues | read | Search issues. Results outside the project allowlist are dropped. |
jira_create_issue | write | Create a Bug, Task, or Story. |
jira_add_comment | write | Comment on a key such as PLAT-101. |
jira_transition_issue | write | Change status by the names the backend knows. |
github_list_pull_requests | read | state is open, closed, or all. |
github_get_pr_diff_summary | read | File list with additions, deletions, and a short overview. This is a summary, not the raw patch. |
github_create_issue | write | Open a GitHub issue. |
github_add_comment | write | Comment on an issue or pull request number. |
Each tool is registered with MCP annotations: read tools set read_only_hint, write tools do not, and open_world_hint is set because a live backend talks to another service.
Every call goes through the same gate. The tool must be on the allowlist if you set one. Write tools fail with read_only when MCP_READ_ONLY=true. Inputs are validated for length and for the shape of issue keys, project keys, and repository names. Then a per-tool rate limit runs. The default is 60 calls a minute. Set MCP_RATE_LIMIT_PER_MINUTE=0 to disable it. The limit is in memory and per process. Restarting the server clears it. It is not a quota system.
Point Cursor or Claude at it
For Cursor, start from examples/cursor_mcp.json. Project config lives in .cursor/mcp.json. The example is mock, read-only, limited to the PLAT project and acme/platform:
{
"mcpServers": {
"jira-github": {
"command": "python",
"args": ["-m", "jira_github_mcp"],
"env": {
"MCP_BACKEND": "mock",
"MCP_READ_ONLY": "true",
"JIRA_PROJECT_ALLOWLIST": "PLAT",
"GITHUB_REPO_ALLOWLIST": "acme/platform",
"LOG_LEVEL": "INFO"
}
}
}
}Use the same virtualenv that has the package installed. If python on Cursor's PATH is not that venv, put the full path to the interpreter in command.
For Claude Desktop, merge examples/claude_desktop_config.json into the app's config. It launches jira-github-mcp with the mock backend and read-only mode. The executable has to be on the app's PATH, or you replace command with the full path inside the virtualenv.
An empty MCP_TOOL_ALLOWLIST means all eight tools. If you set the variable, unknown names fail at startup, which is what you want. A typo should stop the process, not silently hide a tool. The same rule applies to the idea of the list: only names the server actually registers are accepted.
Turn on live Jira and GitHub
Copy .env.example into the environment you launch the server with. Do not commit tokens. Live mode refuses to start until four variables are set:
JIRA_BASE_URLmust behttps://. A URL that is not HTTPS is rejected so the token is not sent in the clear.JIRA_EMAILis the Jira Cloud account email.JIRA_API_TOKENis a Jira Cloud API token. It is sent as Basic auth together with the email. This demo does not use a bearer personal access token for Jira.GITHUB_TOKENis sent asAuthorization: Bearer.
Set MCP_BACKEND=live. Keep MCP_READ_ONLY=true until you have a reason to let the agent create issues. The project allowlist and the repo allowlist still apply. A Jira query that contains =, ~, ORDER BY, or IN ( is sent to Jira as JQL. Anything else is translated from the mock filter syntax. Results are still dropped when they fall outside JIRA_PROJECT_ALLOWLIST. That filter runs after the call. It is not a JQL sandbox. The token's own permissions are the real boundary.
Live HTTP errors return a short status line to the agent. They do not include request headers. Live mode does not page past the first response. A diff summary is aggregated from the pull request files API rather than a review of the patch. Jira transitions are matched by name or destination status, so the caller has to use a name that project actually has. The mock workflow names will not exist on a random company project.
What the guardrails do not do
Read-only mode and the allowlists are for safe demos. They are not SSO, audit storage, secret scanning, or multi-tenant isolation. Tokens are read from the environment. They are not written to logs, and the repo has no sample secrets. Validation limits string length and checks shapes. It will not save you from a JQL query the token is allowed to run.
If you are wiring this into a real workspace, start read-only, with a project allowlist of one key and a repo allowlist of one owner/repo. Watch stderr. When a tool fails, the agent sees a code such as read_only rather than a stack trace full of headers.
Tests
Unit tests talk to the mock store and to live clients with a fake HTTP transport. They do not call Jira or GitHub. GitHub Actions installs the package, runs Ruff, and runs pytest on Python 3.12.
ruff check .
pytest -qThe code lives in src/jira_github_mcp/ (server, guardrails, mock and live clients). examples/ has the Claude config, the Cursor config, and the example client. tests/ is pytest against the mock backends and the fake HTTP layer.
When the example client prints the SSO bug and a summary of pull request 43, the pipe is working. Swap MCP_BACKEND to live only after the four credential variables are set and read-only mode is still on. The mock story is there so you can learn the tool names before any token is involved.