# tix MCP server
`tix mcp` exposes the tix issue tracker to AI coding agents over the [Model
Context Protocol](https://modelcontextprotocol.io) (MCP). It is a **stdio
subprocess server**: the harness spawns `tix mcp`, exchanges newline-delimited
JSON-RPC 2.0 messages over `stdin`/`stdout`, and the agent gains 28 tools to
create, read, update, move, link, delete, search, comment on, export/import and lint
issues — the same operations as the CLI, backed by the same command cores.
- Transport : MCP stdio, JSON-RPC 2.0, one message per line (UTF-8)
- Feature : `tools/*` (tools/list, tools/call), `ping`, `initialize`
- Repo scope : the working directory of the spawned process (see
[Repository resolution](#repository-resolution))
- Current protocol versions: `2025-06-18` (newest), `2025-03-26`, `2024-11-05`
---
## How it works
### Lifecycle
1. The harness spawns `tix mcp` from the repository root.
2. The server performs a strict bootstrap: it walks **upward** from the cwd to
find a `tix.toml` (the same discovery as `tix` with no subcommand). No
`tix.toml` ⇒ the process writes `no tix.toml found — run "tix init" first`
to **stderr** and exits `1` **before reading any protocol bytes**.
3. The client sends `initialize` with its `protocolVersion`. The server echoes
the version if it is supported, otherwise negotiates down to `2025-06-18`.
The response advertises `capabilities.tools.listChanged = false` and
`serverInfo = { name: "tix", version: "<tix version>" }`.
4. The client sends `notifications/initialized` (no response is produced).
5. Normal operation: `tools/list` and `tools/call`.
6. The server runs until `stdin` closes (EOF), then exits `0`.
### Channel discipline
- **stdout**: only protocol messages, one JSON line each. Nothing else is ever
written to stdout (no notices, no logs, no prompts).
- **stderr**: human diagnostics only (e.g. the no-`tix.toml` bootstrap error).
- **stdin**: only protocol messages. The server never prompts on stdin; every
tool call must supply everything it needs as arguments.
### Result shapes
A successful `tools/call` returns:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "<human/agent-readable rendering>" }],
"structuredContent": { "...": "machine-readable data" },
"isError": false
}
}
```
The `content` text mirrors the corresponding CLI command's output exactly
(bit-parity with `tix list`/`tix view`/`tix status`/`tix search`/`tix lint`),
so a human and an agent read identical facts.
**Domain failures** (issue not found, ambiguous selector, unknown column/bad
priority, missing `glab`/`linear-cli`, delete without `confirm`) are **not**
JSON-RPC errors. They return a tool result with `isError: true`, the CLI's exact
error wording in `content`, and no `structuredContent`. The connection stays
alive — a failing tool never kills the server.
### JSON-RPC errors
The five frozen error codes are reserved for protocol-level failures:
| -32700 | Parse error | Undecodable JSON / bad framing line (`id: null`) |
| -32600 | Invalid request | Valid JSON but not a JSON-RPC message/method shape |
| -32601 | Method not found | Unknown method or unknown tool name in `tools/call` |
| -32602 | Invalid params | `tools/call` missing `name`, or type-invalid arguments |
| -32603 | Internal error | Unexpected internal failure (panic guard) |
### Concurrency
The server is single-threaded and sequential: one request → one response, in
order. Mutation results are reported only after the underlying write succeeds
(no "side-effect-then-error" lie). Clients may still pipeline.
---
## The 28 tools
| `create_issue` | `tix create` | Create an issue (`title` required, no prompt) |
| `list_issues` | `tix list` | Board table, optional `status` filter |
| `get_issue` | `tix view` | Full issue (frontmatter + body) for a selector |
| `board_status` | `tix status` | Column counts + total |
| `update_issue` | `tix edit` (flags) | Merge fields; at least one field required |
| `move_issue` | `tix move` | Move to a configured column; never prompts |
| `link_issue` | `tix link --type` | Create a **typed** relationship; `kind` required |
| `list_relationships` | `tix link list` | Relationships for one issue or the whole board |
| `edit_relationship` | `tix link edit` | Retarget/re-kind by id; the id survives the edit |
| `remove_relationship`| `tix link remove`| Remove by id; an absent id is a no-op, not an error|
| `issue_chain` | `tix chain` | Transitive `blocks` chain, service order + truncated|
| `issue_blocked_state` | (derived read) | `is_blocked`, the blockers, and the exact reason |
| `list_children` | `tix parent children` | An issue's **direct** children, in issue order |
| `get_parent` | `tix parent show` (parent line) | The resolved parent, or the retained reference plus the service's reason |
| `get_rollup` | `tix parent show` (figures) | Direct-children count, completions, and the progress value, as numbers |
| `bulk_preview` | `tix bulk …` (no `--apply`) | Plan a batch and return it **without writing**. Read-only. |
| `bulk_apply` | `tix bulk … --apply` | Confirm and apply a batch; **requires `confirm: true`** |
| `bulk_capabilities`| (none — metadata) | What a batch can do in this build: operations, the patch whitelist, the outcome vocabulary, the selection grammar |
| `triage_list` | `tix triage list` | The inbox queue, with the queue age, or its counts with `count: true`. Read-only |
| `triage_status` | `tix triage status` | The counts, the accepted/declined/duplicated figures, and the age histogram. Read-only |
| `triage_preview` | `tix triage batch` (no `--apply`) | Plan one decision over one issue or a selection **without writing** |
| `triage_apply` | `tix triage batch --apply` | Apply it; **requires `confirm: true`** |
| `delete_issue` | `tix delete --yes` | Delete; **requires `confirm: true`** |
| `search_issues` | `tix search` | Case-insensitive substring over full markdown |
| `comment_on_issue`| (model writer) | Append a comment to an issue |
| `export_issues` | `tix export` | Mirror to GitLab/Linear via `glab`/`linear-cli` |
| `import_issues` | `tix import` | Pull open remote issues (idempotent by remote id) |
| `lint_issues` | `tix lint` | Validate frontmatter in every issue file |
Conventions:
- Every `inputSchema` is JSON-Schema draft-07, `additionalProperties: false`.
- Issue selectors accept an integer (`#123`) or a filename/title fragment,
matching the CLI's precedence: number → unique filename fragment → unique
title fragment.
- `labels` and `files` are comma-separated strings (parsed like the CLI).
**`links` is gone from `create_issue` and `update_issue`** — see the breaking
change below. Create relationships with `link_issue`.
- `link_issue` takes two selectors `a`/`b` and a **required `kind`**, one of
`blocks | blocked_by | duplicates | related`. The vocabulary is closed: an
unknown kind is rejected, never coerced.
- `move_issue` also accepts `yes`, mirroring `tix move --yes`. Moving toward the
board's configured completion column with open blockers is **refused** without
it, and records the acknowledgement in the issue's History.
- `move_issue` and `update_issue` both accept `reason`, mirroring
`tix move --reason` and `tix edit --reason`. It is required to move an issue
**out of** a terminal category (`completed` or `canceled`) on a board that
declares a `category_map`, and the reason is recorded in the issue's History.
A blank value is not a reason and is refused. A board with no `category_map` has
no transition policy to override. `reason` is not an editable field: on
`update_issue` it qualifies a `status` transition and nothing else, so a call
carrying only a `reason` is "no fields to update".
- `status` on `update_issue` is a **move**, not a field write: it is validated by
the same transition service as `tix move` and recorded as the same History
decision, so it is not a route around the policy. `""` clears the field and is a
plain field write, since there is no target column to judge.
- Every issue object returned — from `list_issues`, `get_issue`, `search_issues`,
and the result of a write — carries `category` (the stable key) and
`category_label` (the service's display string). `category` is `null` for a
column the board does not configure, and `category_label` is then `"(unmapped)"`:
a configuration error the client can see, not a silent default.
- `priority` accepts only `low | medium | high`.
- `move_issue` records a commit only when supply `commit`; moving out of the
done column clears it. There is **no TTY prompt** anywhere.
- `update_issue`/`delete_issue`/`create_issue` never open `$EDITOR`, the TUI,
or read stdin.
### Breaking change: relationships are typed
`link_issue` previously linked two issues **symmetrically** with no kind. Both
spellings of an untype link are gone:
| `link_issue { a, b }` | `link_issue { a, b, kind }` — `kind` is **required** |
| `create_issue { links: "1,2" }` | *(argument removed)* one `link_issue { a, b, kind }` per entry |
| `update_issue { links: "3" }` | *(argument removed)* one `remove_relationship { id }` per existing edge, then one `link_issue` per new one |
The argument is now **rejected**, not ignored: passing it returns
`isError: true` naming `link_issue` and `remove_relationship`. It was silently
dropped for one release, which meant a caller got `isError: false`, believed the
relationships were stored, and found an empty `tix link list` with nothing in
between to explain it.
Note that `update_issue { links }` did **not** mean "add this one relationship".
It *replaced* the whole set, so the migration is a `remove_relationship` per
existing edge plus a `link_issue` per new one — `edit_relationship` is the wrong
tool here, because it retargets or re-kinds one *existing* relationship by id and
cannot create one.
There is **no default kind and no fallback**, so an untyped relationship is not
reachable by omission. Migrate by adding an explicit `kind`:
- an association that was merely "related" → `kind: "related"`
- a dependency where A must finish before B → `kind: "blocks"` on `a: A, b: B`
(or `kind: "blocked_by"` on `a: B, b: A`; both store the same edge)
`blocked_by` is an **input-only** spelling that normalizes into the canonical
`blocks` edge, so no stored relationship ever carries it. A reciprocal pair
(`A blocks B` then `B blocks A`) is rejected as a cycle.
The same change applies to the CLI: `tix link <a> <b>` without `--type` is
refused, and `tix create`/`tix edit` no longer take `--links`.
### Reading relationships
`list_relationships` returns **every relationship touching an issue**, including
ones stored on the other endpoint — a relationship is stored once, in its
canonical source's frontmatter, so an issue's own `relationships` field is
partial. Rows are ordered by `(kind, target)`, so the output is byte-comparable
across runs and against `tix link list`.
`--kind blocked_by` (the MCP `kind` argument) filters on the **canonical**
blocking edge, i.e. edges whose stored form is `blocks`.
`issue_blocked_state` returns the same `reason` string the CLI move warning, the
TUI confirmation prompt, the board card and the detail view all print — it is
produced by one shared function, so the surfaces cannot word it differently.
### Reading and writing the issue hierarchy
`create_issue` and `update_issue` take a `parent` argument, a **selector** — a
number, a filename fragment, or a unique title fragment, as everywhere a selector
is taken. It goes through the same hierarchy service the CLI does, so the
validation and the resulting History entry are identical:
- `parent: "3"` sets the parent to `#3`.
- `parent: ""` **clears** it, naming no issue. Omitting the argument leaves it
alone, which is a different thing: an absent argument is not "set none".
- A self-parent, a cycle, or a selector that names nothing is refused, with the
service's own diagnostic, and the file is left byte-identical.
`list_children`, `get_parent` and `get_rollup` read the same three derived
values `tix parent children` and `tix parent show` print. Each returns **both**
the CLI's exact text and the structured values:
- `list_children` — direct children only, never grandchildren, ordered by issue
number, each as `(number, display)`.
- `get_parent` — the resolved parent's number, or `null`. When the recorded
parent no longer exists, `recorded_parent` keeps the reference (it survives so
it can be repaired) and `unresolved.reason` carries the service's wording, so
a client cannot spell that condition its own way.
- `get_rollup` — `direct_children`, `completed_children` and `progress` **as
numbers**, alongside `completion_basis`, which says what population the figures
describe. The counts are numbers rather than only rendered text so a client can
compare them without parsing prose; "3/5 over direct children" and "3/5 over
the subtree" are different numbers wearing the same clothes.
---
## Triage: the inbox and its decisions
The triage inbox is the review queue that unreviewed work sits in before it
joins the normal workflow. It is **opt-in**: a `[triage]` section names the
normal board columns that make up the queue.
```toml
[triage]
entry_status = "triage" # required: the column that places an issue in the inbox
declined_status = "wontfix" # required: the column a decline moves to (must map to `canceled`)
# accepted_status = "todo" # optional: the default target for an accept
# deferred_status = "triage" # optional: defaults to entry_status
```
A project with no `[triage]` section has no inbox, and every triage tool refuses
with the CLI's own sentence for the command it corresponds to — `triage_list` and
`triage_status` with `tix triage list`/`status`'s, a decision with `tix triage
batch`'s. Both are compared against the CLI's output in the test suite rather than
intended, and an *invalid* `[triage]` section reports the validator's message
naming the offending column rather than being flattened into the missing one.
**Reading.** `triage_list` and `triage_status` return the CLI's rendered text
verbatim in `content[0].text` (`tix triage list`, `tix triage status`) plus the
same snapshot as structured values: `counts` (`total`, `awaiting`, `deferred`,
`accepted`, `declined`, `duplicated`, `unknown_age`, and the `age` histogram) and
`entries` (per issue: `number`, `title`, `status`, `age_days`, `entered_at`,
`source`, `last_decision`). `age: true` sorts oldest first and an entry with no
readable entry stamp sorts **last** and reports `age_days: null` — an unknown age
is never reported as `0` or `today`. `count: true` returns the counts without the
rows, in both the text and the structure. Both tools are read-only: no file, no
History entry.
**Deciding.** `triage_preview` and `triage_apply` take the same arguments and go
through the same plan:
| `issue` | One issue: a number, a filename fragment, or a unique title fragment. |
| `select` | Comma-separated selectors. Exactly one of `issue`, `select`, `filter` may be named. |
| `filter` | A `tix list --filter` query, e.g. `status:triage`. |
| `decision` | `accept` \| `decline` \| `duplicate` \| `defer`. Required. |
| `reason` | Recorded on the History event. Required for `decline`, `duplicate` and `defer`; a missing one is a **refused item** naming `missing_reason`, not an error. |
| `actor` | Who decided. Defaults to the git identity. An empty string is refused as `missing_actor` rather than silently replaced. |
| `to` | The accept target column, validated by the board's own transition policy. With no `to` the configured `accepted_status` is used; with neither, the item is refused rather than guessed. Naming `to` on a decision that has no target column is **refused, not ignored** — as `tix triage batch --decision decline --to …` refuses it — so a client cannot believe it named a column that was silently dropped. |
| `of` | The duplicate target selector. Creates a typed `duplicates` relation; **files are never merged**. Sending `of` on a decision that has no duplicate target is refused the same way `to` is. |
| `confirm` | Required on `triage_apply` and must be `true`. Never present on `triage_preview`. |
A single issue and a selection are the same tool because they are the same plan:
one issue is a selection of one, and both report the same per-item statuses
(`applied`, `no-op`, `rejected`, `failed`) that `bulk_preview`/`bulk_apply`
report. `triage_preview` writes nothing at all — the plan, the proposed field
changes and the `history` event it would append are the whole response.
`triage_apply` processes each selected issue independently and does **not** roll
back the ones that succeeded: the response's `applied`, `no_op`, `rejected` and
`failed` counts, and one `outcomes` row per selected issue, are the report. A
decline moves the issue to the configured declined column and records the reason;
it never deletes or archives anything.
**Errors.** A malformed argument is a JSON-RPC `-32602` (as everywhere else on
this surface). A well-formed request that cannot be carried out is a tool result
with `isError: true` and the CLI's own wording — an unknown decision, a selector
that matches nothing or matches twice, `of` on a decision that has no target, a
`[triage]` project with no section. A refusal that belongs to *one* selected item
is not an error at all: it is that item's `rejected` status and its `diagnostic`,
so one bad item leaves the rest previewable and applicable.
**Boundaries.** The remote tools are unchanged: `import_issues` and
`export_issues` keep their meanings, and the local triage policy for imported
issues (`tix import --triage-policy`) is a CLI flag, not an MCP argument. There is
no MCP tool that deletes, archives or merges anything.
---
## Prerequisites
- The `tix` binary. Build it (`cargo build --release`) and either install it on
`PATH` or reference the absolute binary path in the harness config.
- A repository with a `tix.toml` (create one with `tix init`). The server fails
fast if the cwd is not inside a repository.
- Map to the **right repository**: the server operates on the worktree it is
launched in. With harnesses that spawn from the project root (Claude Code,
Codex, opencode with `cwd` set), the server manages that project's issues.
## Smoke test (no harness)
```bash
cd /path/to/repo # must contain tix.toml
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
tix mcp
```
---
## Integration by harness
### Claude Code
Add to the project's `.mcp.json` (checked into the repo) or run
`claude mcp add tix -- tix mcp` for a personal, machine-scoped entry. Claude
Code spawns server processes with the project directory as cwd, so the server
picks up the repo automatically.
```json
{
"mcpServers": {
"tix": {
"command": "/path/to/tix",
"args": ["mcp"]
}
}
}
```
Restart (or `/mcp` reconnect) after adding. The agent can then call the 28 tools;
`create_issue`, `list_issues`, `get_issue` and `board_status` are the natural
first four an agent will use when asked to "track this as an issue".
### OpenAI Codex CLI
Two equivalent options:
```bash
# 1. CLI (writes the table for you)
codex mcp add tix -- /path/to/tix mcp
```
```toml
# 2. ~/.codex/config.toml (or project-scoped .codex/config.toml)
[mcp_servers.tix]
command = "/path/to/tix"
args = ["mcp"]
```
Notes specific to Codex:
- The table key is `mcp_servers` (underscore) — TOML, not JSON. A wrong key is
silently ignored.
- The server launches from Codex's working directory. If you want a different
repo, set `cwd = "/path/to/repo"`. For trusted project configs, add
`cwd = "${workspace}"` semantics are not built in; pin the absolute path.
- Raise `startup_timeout_sec` only if needed — `tix` is a native binary, so the
default 10 s is ample.
- Check with `codex mcp list`; inside the TUI, `/mcp` shows connected tools.
### opencode
Add a **local** MCP server to `opencode.json(c)` (project) or the global
`~/.config/opencode/opencode.json`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"tix": {
"type": "local",
"command": ["/path/to/tix", "mcp"],
"enabled": true
}
}
}
```
Optional keys: `cwd` (working directory for the server process; relative paths
resolve from the workspace) and `environment` (extra env vars). If `tix` is on
`PATH`, `["tix", "mcp"]` suffices. Use it in prompts by referencing the server
name, e.g. "use the tix tool to file this as an issue". MCP tools are prefixed
`tix_*`; disable/enable via the `tools` config if desired.
### Pi (pi.dev / pi agent)
Pi reads the standard MCP shapes in addition to its own override files, so the
universal `.mcp.json` also works:
```json
{
"mcpServers": {
"tix": {
"command": "/path/to/tix",
"args": ["mcp"]
}
}
}
```
Where to put it (highest precedence first):
- **Project**: `.pi/mcp.json` — Pi-specific project override.
- **Project shared**: `.mcp.json` at the repo root — used immediately.
- **User global**: `~/.pi/agent/mcp.json`, or `~/.config/mcp/mcp.json`.
A stdio server entry may set `"transport": "stdio"`, `"lifecycle": "eager"`
(auto-start) or `"lazy"` (start via `/mcp:start`), and `"env"` for extra
environment variables. Prefer `"lifecycle": "eager"` if you always want the
tools available. Check connectivity with `/mcp`.
### Generic / spec-compliant clients
`tix mcp` is a standard MCP stdio server (newline-delimited JSON-RPC 2.0), so
any conforming client works. Use the same registration pattern:
```
server name : tix
command : <tix binary> mcp
```
---
## Repository resolution
`tix mcp` resolves the repository **once**, at startup, using upward
`tix.toml` discovery from the process cwd (`cli::repo_config` → `Config::project_root`):
- A `tix.toml` is found in a parent directory of cwd → that is the issue store.
- No `tix.toml` is reachable → exit `1` with `no tix.toml found — run "tix init"
first` on stderr, and **no** bytes on stdout.
Implication: the server manages the repo it is spawned in. Harnesses that run
from the project root give each project its own issue store automatically; a
single global server config only makes sense for one repo at a time.
---
## Troubleshooting
| Server exits `1`, stderr says `no tix.toml found` | cwd is not inside a repo, or the harness set a different `cwd`. Run `tix init` (or point `cwd` at the repo root). |
| No tools appear in the agent | Wrong config file/shape — e.g. Codex wants TOML `[mcp_servers.tix]`, not JSON `mcpServers`; opencode wants `"type": "local"` under its own `mcp` key. |
| `-32602` error on a call | Argument type mismatch (e.g. a boolean where a string is expected) or a missing `name` in `tools/call`. Consult the tool's `inputSchema`. |
| `isError: true` with CLI-like text | A domain failure (unknown selector/column/priority, missing external CLI, delete without `confirm`). The text tells you exactly what is wrong. |
| stdout looks polluted / non-JSON | Some other process is writing to the server's stdout. tix itself never does. |
## Reference
- Wire protocol: `kitty-specs/tix-mcp-server-01M2G3FX/contracts/mcp-protocol.md`
- Tool definitions: `kitty-specs/tix-mcp-server-01M2G3FX/contracts/mcp-tools.md`
- Source: `src/mcp/{mod,protocol,tools}.rs`, bootstrap in `src/cli.rs` (`run_mcp`)
- Integration tests: `tests/mcp.rs`
- Frame the MCP board work in its tracked workflow: `docs/spec-kitty-integration.md`
and `docs/openspec-integration.md` cover the `tix link`/`--files` mirroring convention
and the `tix init-agents` stdout detection lines.