Jira Intelligence Hub
An MCP and RAG to add Jira integration into AI LLMs such as Claude and ChatGPT
Problem - Powering the Jira - AI integration Safely and Efficiently
I started this out with a question: how can I plug Jira directly into an AI engine? Having done support for Jira Cloud for four years, handling alot of API, integration, and automation tickets over the years, I was curious to learn how to do this. I already knew how to work with the API quite well, so I knew I could build an application that could make calls to the API and handle a variety of tasks. If I could build these tools out, I could connect an AI like Claude directly to my Jira site.
But simply giving it the ability to pull straight from the Jira site presented a few issues:
Rate limiting can cause issues. If, for some reason, the AI needed to make multiple calls, it’s possible the site could get throttled. This would also lead to difficulty in scaling the tool
Performance bottlenecks and redundancy: If the AI has to constantly make calls to the API for information, then the connection between the AI and the Jira Site can become a bottleneck. Bringing a vector DB local to cache information puts control of that bottleneck internally. It also acts as a failsafe should there be an outage at Atlassian that prevents access from the Jira site
Security: pulling in data from an exposed webhook requires securing it to prevent replay or spoof attacks. Adding signature verification addresses this.
So with that, I set out and created my goals:
Goals
Deterministic ingestion: Ensure every event is parsed correctly and stored reliably.
Secure webhook endpoint: Protect the system from forged requests.
Persistent, vector‑ready storage: Enable fast semantic search over issue text.
LLM‑augmented retrieval: Provide context‑aware answers to natural‑language queries without manual search.
Composable MCP tools: Keep the logic modular and reusable in other MCP deployments. Give the AI tools to work with a Jira site and directly query it as well as cache incoming information from the Jira site for quick access.
Solution Overview
Flask + MCP – A lightweight web server that exposes a single
/jiraroute for incoming webhook data. This was made accessible using ngrok, which I added as a second route on top of an existing Docker container for n8n.Webhook validation – HMAC‑SHA256 signature verification using the
X-Hub-Signatureheader. This would verify any incoming request on the/jiraroute was valid in order to avoid replay and spoof attacksDynamic schema handling – A dispatcher that normalises payloads based on the
webhookEventfield. This was particularly important as different payload requests could contain different field dataChromaDB persistence – A
PersistentClientbacked by a local directory; cosine‑based HNSW index for text embeddings.PersistentClientused for local testing.RAG tool class – Encapsulates embedding generation (SentenceTransformer), relevance scoring, and context formatting. Used to determine if retrieved RAG data would be of value before handing it to LLM.
MCP tool functions –
query_jira_rag,search_jira, and helper hints that hand off to the LLM when vector search falls short. Used to provide agentic RAG routing.Docker + Nginx – Reverse‑proxy the Flask app behind Nginx inside Docker, adding an extra layer of isolation and SSL termination.
Technical Deep Dive
Architecture
[Jira] → Webhook → Flask + MCP → Signature Verify → ChromaDB (persistent) → LLM (via MCP) → Response
Front‑end – Flask handles incoming HTTP POSTs and routes them to MCP tools.
Middleware – Constant‑time HMAC verification guards against timing attacks.
Data layer –
PersistentClient+get_or_create_collectionensures the vector store survives restarts. Gives vector database for retrieval of cached information from Jira siteLLM layer – MCP tools expose high‑level actions (
query_jira_rag,search_jira) that the LLM can call via the MCP protocol.
What I learned
Below is a quick writeup of the notes I took while making this project. While I wanted to keep this structured in the start, I feel like it’s best to see a natural flow of the different lessons I learned as I worked on this project.
Jira MCP
Building an MCP server is pretty simple and is not too dissimilar to loading a web server with something like Flask. You need to load the library, initialize the server, identify functions that will be tools used by the AI, and identify prompts to explain how to behave/use the tools.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(”<SERVER_NAME>”)
@mcp.tool
def tool_funct(args):
“”“
Detailed tool descriptor explaining the purpose and usage of this tool to the AI. Should be clear and explain proper usage, including arguments and return values
“”“
...
return (value)
@mcp.prompt
def prompt_funct():
“”“Quick explanation of the prompts’ purpose. I believe for human use”“”
return f”“”
Prompt with detailed instructions. # This is an area I can use some more work on Understanding
“”“
if __name__ == “__main__”:
# print(asyncio.run(main()))
mcp.run(transport=”stdio”)As these are tools, it’s important to get the behavior of the tool itself set up and working before handing it over to AI. An AI is only as good as the tools it has access to, and if the tool is not working/unreliable, then the AI won’t be of any use.
It’s also good practice to build decision paths into the program itself rather than leave it to AI. Programming is deterministic, meaning it can have a single input for a given path, whereas AI/LLMs can have multiple possible outputs. This is a paradigm shift in programming, and I think something people are ultimately taking for granted with AI. AI like this is better for adding a second level of reasoning and work assistance. If you need predictable results, though, you need deterministic programming and humans in the loop to review the AI’s work in order to verify its accuracy or not.
One of the best benefits of AI in IDEs is the inline suggestions feature, where you can tab to insert suggested code. I’m not sure I’d say I like it for a majority of my coding, though some suggestions are useful. The better use, I’m finding, is to help explain the code. It’s great to structure and fill out function descriptors, and for adding notes about what code is going to do at this stage
There are some areas where it is better to have a higher-end coding AI develop the code for you, particularly in areas where the code might be repetitive or might be a bit dull to structure. For example, the JQL builder where JSON JSON-structured request from the AI needs to be converted into a JQL query that can be run to perform a search in Jira. This was a dull and mind-numbing process that had low security risk to it, so I fed it to Anthropic and just made sure the in and out of the code worked before connecting it to the rest of the code. Also, it’s important to build in tests for this kind of code so you can see how they are working. I like to set them up as `__main__` in order to run directly in a .py file.
Jira Data Pipeline
Overall
making the app accessible to the webhook takes a bit of work. It’s easy if you can fire a ngrok dedicated to that port, but if you’re on the free plan and only have one endpoint, you need to set up a reverse proxy (nginx) to direct traffic to the correct port
This is also a bit difficult if you’re routing to a docker container already
the best solution for this appears to be to set up nginx through the docker container and routing between the original docker app and the target port for the application. In the docker-compose.yml:
nginx:
image: nginx:latest
container_name: <CONTAINER NAME-NGINX> # This can be renamed
volumes:
- ./n8n.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/nginx/snippets:/etc/nginx/snippets:ro
- /etc/nginx/dhparam.pem:/etc/nginx/dhparam.pem:ro
- /etc/ssl/certs:/etc/ssl/certs:ro
- /etc/ssl/private:/etc/ssl/private:ro
ports:
- “80:80” # http
- “443:443” # https
extra_hosts:
- “host.docker.internal:host-gateway”
depends_on:
- n8nThen for the nginx config
events {
worker_connections 1024;
}
http {
server {
listen 80;
# Route traffic for /n8n to the n8n service
location /n8n {
proxy_pass http://n8n:5678;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Optional: removes /n8n path prefix before forwarding if your app needs the root path
rewrite ^/n8n(/.*)$ $1 break;
}
# Route traffic for /secondapp to the second_app service
location /secondapp {
proxy_pass http://Jira_Pipeline:5052;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Optional: removes /secondapp path prefix before forwarding
rewrite ^/secondapp(/.*)$ $1 break;
}
# Route traffic for the root path / to n8n (or whatever you prefer)
location / {
proxy_pass http://n8n:5678;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}Then, the n8n.conf file (in this case. Could be different for other apps)
# This resolver directive can remain at the top level (inside the main http{} context)
resolver 127.0.0.11 valid=30s;
server {
listen 80;
server_name dereck-X470-AORUS-ULTRA-GAMING;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name dereck-X470-AORUS-ULTRA-GAMING; # Replace with your IP or hostname. Keep the “server_name” part before the IP or hostname
ssl_certificate /etc/ssl/certs/n8n-selfsigned.crt;
ssl_certificate_key /etc/ssl/private/n8n-selfsigned.key;
include snippets/self-signed.conf;
include snippets/ssl-params.conf;
# Route to Python application on host machine <== This is the important part for routing the path to the app’s port.
location = /jira_webhook {
# Use host.docker.internal to reach the host machine from Docker
# Or use the actual IP of your host machine
proxy_pass http://host.docker.internal:5052/jira_webhook; # Change port as needed
proxy_http_version 1.1;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Important for webhooks
proxy_set_header Content-Type $content_type;
proxy_set_header Content-Length $content_length;This, of course, matters less when you don’t have a docker container to route to. You can just connect ngrok directly to the app’s port. I think, though, this covers what I did to set it up with nginx and docker
Jira Webhooks and data intake
The data structure changes depending on what the action sent by the webhook is. Knowing how to structure the data for each action is importan
With the jira webhooks, the best field to identify the action type is the “webhookEvent” field.
Also use a webhook tester to identify the fields coming from the webhook.
To prevent attacks on the exposed path, verify the signature from the incoming webhook. This can be found in the “X-Hub-Signature” field.
To verify the signature, used this function:
def verify_signature(secret, payload, signature):
“”“
Compute HMAC‑SHA256 of the raw payload and compare it with the
signature sent in the header. The comparison is performed
in constant time to avoid timing attacks.
:param secret: The shared secret (from Jira)
:param payload: Raw request body (bytes)
:param signature: The value after the ‘=’ in the header
:return: True if the signatures match, False otherwise
“”“
mac = hmac.new(secret.encode(), msg=payload, digestmod=hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), signature)This checks the signature from the request body to verify that it came from the correct source (Jira site)
Takes in the secre from the webhook, the request body payload, and the signature from the payload
creates an HMAC‑SHA256 hash of the payload using the secret key (the fingerprint connecting the payload to the key)
turns the hash into a hex string
Compares the hash string to the signature using `hmac.compare_digest`, which does this measurement in a consistent time to avoid time attacks (where the attacker could guess the signature by measuring how long the comparison takes)
returns true if signature matches, false if it does not
ChromaDB
Built this with a persistent database, which is recommended only for testing. Will need to adjust this at some point to one more for production
from chromadb import PersistentClient
# ---- Config ----
CHROMADB_PATH = “./chroma”
COLLECTION_NAME = “jira_issues”
def connect_chromadb():
“”“
Connect to a ChromaDB persistent client at the given path.
:param path: Filesystem path to the ChromaDB directory
:return: PersistentClient instance
“”“
client = PersistentClient(path=CHROMADB_PATH)
collection = client.get_or_create_collection(
name=COLLECTION_NAME,
metadata={”hnsw:space”: “cosine”}) # This part is important to identify distance measurement. You want to specify cosine for text based collections
return collection
def add_issue_to_chromadb(col, id:str, docs:str, metadata:dict, action_type:str) -> None:
“”“
Add a Jira issue to the ChromaDB collection.
:param col: ChromaDB collection
:param issue_data: Dictionary containing issue fields
“”“
if action_type == “create”:
col.add(
ids=[id],
documents=[docs],
metadatas=[metadata])
elif action_type == “update”:
col.update(
ids=[id],
documents=[docs],
metadatas=[metadata])
returnWill skip the data structure loading for the create and update actions in chromadb. For those, see main.py 101:242.
Main thing learned with this is demonstrated above. Add the chromadb library, build a connection to the persistent database, pull a collection and, in the case of text, make sure you set it to cosine
Adding to the database simply uses the ‘add’ function. id requires unique identifiers and you need to use different identifiers for different actions. Documents is plain text that can be searched on. Metadata adds descriptive information that can be searched on. Not sure though if there’s a way to utilize it with RAG searching. Haven’t explored in this case
Jira RAG
This was a bit of a struggle, mostly as getting everything set up to work together and verifying everything was working and accessing information from the Chroma DB.
Similar to the Chroma setup, have to add the following to set up the connection:
import chromadb
from chromadb.config import Settings
try:
from sentence_transformers import SentenceTransformer
except Exception:
SentenceTransformer = NoneThe code for this is pretty long, but basically the structure for this is as follows:
Create a class for the RAG tool. Initialize feeding in the collection name, the embedding model to use, details for the chroma host port and host or the persistent model path. ==Check if a sentence transformer was provided, if not set a fall back.==%%This part seems to be redundant. Unsure that this is necessary as it only seems to be when there is an exception in importing sentenceTransformers which is set to load with a try:except. Unsure why that was originally added there%% Connect the database via http if host/port provided, or SQL if persistent database is provided, then build the collection connection.
Functions in this class
Calculate relevance score: calculates the relevance score based on distance and semantic similarity using Cosine. If result embeddings or distances are missing, returns 0. Otherwise determines similarity scores by iterating over each distances and calculating as 1 - d, then averages all of the similarity scores out before returning.
Determines if the documents pulled are relevant. Encodes the query using an embedding model, then puts that embedding through a numpy vector function as well as running the documents pulled through the embedding function. Afterwords, feed both the query and the results embeddings as well as the distances through to the calculate relevant score function to process relevancy, then compare the averaged results to the threshold. If higher, is relevent is true, otherwise is false. Returns whether or not there’s relevancy, the relevancy score, and the explanation.
Query Jira RAG: Main MCP tool function: Query Jira data using RAG with AI-directed fallback. First reaches out to the ChromaDB for documents, then feeds into the is context relevant function to determine if these documents are relevant. If true, returns documents and details explaining score. If false, returns score and note, directing AI to use search tool
Format Context for AI: Format retrieved documents into a clear context string for the AI. Includes metadata if available. Returns a structured string with issue keys, statuses, priorities, and descriptions. First, adds a header indicating these are retrieved Jira issues. Then, for each document, it appends the issue key, status, priority from metadata (if available), and the document text itself.
Generate Search Hint: Generate a helpful hint for the AI about what to search for. Analyzes the query for Jira-specific entities like issue keys, statuses, priorities, and types. Constructs a hint string summarizing the findings. Is used when the RAG search fails to produce relevant data and suggests the AI run a search using the search tool
A few additional functions were used for testing, such as building a sample MCP tool to pull this all together, an add issue function to load data into a database to test with, and a main function to run it all together.


I resonnate with this. Vector DB caching details?