ticket-writer-mcp
by AlanAAG
README.md
# ticket-writer MCP
An MCP server for MagOneAI. A reporter drops a feature request in as free
text; the workflow asks the few questions needed to make it actionable, then
files a ticket that states the problem, what needs to be done, the acceptance
criteria and any architecture decisions.
Jira, GitHub, GitLab and Linear come out of the same code path.
**The server never calls a tracker.** It renders the create-issue request and
hands it back; the workflow's own HTTP node sends it.
---
## How the workflow runs
```
free text ──▶ check_request ──"needs_clarification"──▶ ask the reporter ──┐
│ │
│◀──────────────────── answers ─────────────────────────┘
"ready"
│
▼
render_ticket ──"possible_duplicate"──▶ human confirms ──┐
│ │
│◀────────── confirm_not_duplicate ───────────────────┘
"ready_to_send"
│
▼
HTTP node: POST request.url ◀── the only write in the workflow
│
▼
issue key + url back to the reporter
```
`WORKFLOW.md` has the node-by-node contract: exact input and output JSON, and
every field that ends up on the ticket.
---
## Why it does not connect to Jira itself
The first draft had a Jira REST client in it. That version needed credentials
on this box, gained a failure mode where the ticket rendered fine and the POST
failed, and locked the workflow to one tracker.
Rendering a request instead means:
- **no credentials here** — headers carry `{{PLACEHOLDER}}` names the API node
substitutes from MagOne's secret store
- **any tracker** — a new one is a dict entry in `src/targets.py`
- **reuse what MagOne already has** — if a tracker MCP is connected, use
`target="generic"` and pass the fields to its create tool
- **an approval step fits** — nothing is written until after the render call
- **testable with pytest and nothing else** — no network in the whole repo
It does not write the ticket prose either. The workflow agent is a language
model and is better at turning a rambling Slack message into a problem
statement than any rubric in this repo would be. What this server owns is the
part that must behave identically on every run: the checklist, the question
wording, the body format, and the duplicate guard.
---
## Tools
| Tool | Purpose |
|---|---|
| `check_request` | Is this enough to file? If not, what to ask. |
| `render_ticket` | The create-issue request, formatted for one tracker. |
| `list_targets` | Trackers, config keys, which credential the API node needs. |
| `duplicate_search_query` | Optional. Builds the search query for the dup check. |
All four are read-only. Every response carries a `status` the canvas switches
on:
| `status` | Workflow action |
|---|---|
| `ready` | proceed to `render_ticket` |
| `needs_clarification` | ask `questions`, loop |
| `possible_duplicate` | show `candidates`, get a human answer |
| `ready_to_send` | pass `request` to the API node |
| `error` | read `hint` — usually a missing `config` key |
---
## What makes a ticket complete
`check_request` blocks on four fields, asked in this order, at most three per
round:
1. **problem** — what hurts today, who is affected
2. **goal** — what should exist when it is done, as behaviour
3. **acceptance_criteria** — how a reviewer accepts or rejects it
4. **architecture_notes** — decisions made, constraints to respect
(`"none known"` is valid when the design is still open)
`affected_users` and `out_of_scope` are collected when offered but never
block. An answer under 25 characters, or one that echoes the question back,
does not count as answered.
To change the checklist, edit `SLOTS` in `src/ticket.py` — the questions, the
ranking and the cap all follow from that list.
---
## Targets
| `target` | `config` | Credential the API node supplies |
|---|---|---|
| `jira` | `base_url`, `project_key` | `{{JIRA_BASIC_AUTH}}` — base64 `email:api_token` |
| `github` | `owner`, `repo` | `{{GITHUB_TOKEN}}` — PAT with Issues read/write |
| `gitlab` | `project_id`, `host?` | `{{GITLAB_TOKEN}}` — token with `api` scope |
| `linear` | `team_id`, `project_id?` | `{{LINEAR_API_KEY}}` |
| `generic` | — | none — hand `fields` to that tracker's own MCP |
Differences the renderer absorbs so the agent never has to:
- Jira wants **ADF**, not markdown, in `description`. Passing a markdown
string is the most common way a hand-wired Jira node fails.
- GitLab wants labels as a **comma string**; GitHub and Jira want a list.
- GitHub has no priority field, so priority becomes a `priority-*` label.
- Linear is **GraphQL** — one endpoint, mutation in the body.
Adding a tracker: one `TARGETS` entry with a `build()` returning
`(url, headers, body)`. That is the whole change.
---
## Duplicate guard
This server cannot search, so the workflow feeds it: `duplicate_search_query`
builds the query, a search node runs it, the hits go back into
`render_ticket` as `existing_issues=[{key, summary, url}]`. Summaries scoring
≥ 0.6 token overlap come back as `possible_duplicate`.
No search node, no check — the ticket still renders. That is the trade for not
owning a search client per tracker.
---
## Run it
```bash
python -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/python -m pytest -q # 47 tests, no network, no account
```
Local MCP over stdio, for Claude Desktop:
```bash
MCP_TRANSPORT=stdio .venv/bin/python -m src.server
```
Deploy:
```bash
docker build -t ticket-writer .
docker run -p 8000:8000 -e TICKET_WRITER_TOKEN=$(openssl rand -hex 32) ticket-writer
curl localhost:8000/health
```
Register `https://your-host/mcp` in MagOneAI with header
`Authorization: Bearer $TICKET_WRITER_TOKEN`, exactly as the Outlook MCP is
configured. `TICKET_WRITER_TOKEN` is the only environment variable the server
requires; `MCP_TRANSPORT`, `HOST`, `PORT` and `LOG_LEVEL` have working
defaults. Set `max_iterations` around 12.
---
## File a real ticket to check it
`scripts/send.py` does exactly what the API node does — render, substitute the
placeholder from the environment variable of the same name, POST:
```bash
.venv/bin/python scripts/send.py --target jira \
--config base_url=https://you.atlassian.net project_key=KAN --dry # payload only
export JIRA_BASIC_AUTH=$(printf '%s' 'you@mail.com:API_TOKEN' | base64)
.venv/bin/python scripts/send.py --target jira \
--config base_url=https://you.atlassian.net project_key=KAN # 201 + issue key
```
`TESTING.md` covers what each suite proves, the live run against Jira Cloud,
and the error table.
---
## Security
- Bearer token from an env var, never through a workflow node — a token that
transits the canvas ends up in run logs.
- **No tracker credential ever reaches this server.** Headers are
placeholders; `config` takes locations, not secrets. A test asserts it.
- `/health` is unauthenticated for platform probes; everything else is not.
The server refuses to start over HTTP with no token set.
- Non-root container user.
- **Reporter text is data, not instructions.** It is stored verbatim in a
blockquote and never interpreted. The tool docstrings say so, because that
is what the agent reads.
- The duplicate guard and the completeness check are what stand between a
chatty Slack channel and a hundred junk tickets. Do not add a flag that
skips both.
---
## Layout
```
src/server.py MCP surface: tool defs, transport, auth
src/ticket.py pure: rubric, questions, body in markdown + ADF, dup scoring
src/targets.py pure: what each tracker's API wants — one entry per tracker
tests/ 47 tests: the rules, the tool surface, HTTP and auth
scripts/send.py stands in for the API node, for end-to-end checks
WORKFLOW.md node-by-node input/output contract
TESTING.md what is covered, what is not
```
`ticket.py` knows nothing about any tracker; `targets.py` knows nothing about
what makes a ticket good. If adding a required field means editing
`targets.py`, the split has leaked.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues