Skip to main content
Glama
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.