# 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 118 tools to
create, read, update, move, link, delete, search, comment on, export/import and
lint issues, manage saved views, projects, epics, initiatives and the roadmap,
and run batch/triage operations, plus the stable-ID template registry, which it
can list, manage and create from, the repeating-cycle schedules and their
lifecycle, the shared capacity calculation, snapshots and metadata, the
per-issue time entries, totals and audit view, and the burndown/scope analytics
series, forecast and chart document — 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 118 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 (`--json` = the same object) |
| `update_issue` | `tix edit` (flags) | Merge fields; at least one field required |
| `move_issue` | `tix move` | Move to a configured column; never prompts; judged by the same transition **and WIP-limit** policy as the CLI |
| `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 |
| `mentions_list` | `tix mentions list` | The `@mentions` pending for the local user, across issues |
| `mentions_mark_read` | `tix mentions read` | Mark one mention read by id; the only mutating mention answer |
| `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 |
| `view_list` | `tix view list` | Saved views in declaration order; plan, scope, diagnostics |
| `view_show` | `tix view show` | One saved view: stable id, scope, normalized plan, diagnostics |
| `view_create` | `tix view create` | Save a new view; the stable id is generated |
| `view_update` | `tix view update` | Replace the named fields, keeping the stable id |
| `view_duplicate` | `tix view duplicate` | Copy a view under a **new** stable id |
| `view_delete` | `tix view delete` | Remove the **definition** only; requires `confirm: true` |
| `view_share` | `tix view share` | Replace scope metadata (repository/project/user) |
| `view_apply` | `tix view apply` | Apply read-only: plan, issues, board summary, active marker |
| `view_status` | `tix view status` | Saved vs live plan divergence, field by field |
| `list_templates` | `tix template list` | One entry per stored template: stable ID, name, scope, defaults, prompts, references, preserved extensions. Read-only |
| `get_template` | `tix template show` | One template's full metadata, by stable ID or unique display name. Read-only |
| `create_template`| `tix template create`| Create a template under a new stable ID; the whole definition is validated before the atomic save |
| `update_template`| `tix template update`| Replace the supplied parts; the stable ID never changes, and a same-content update is a `no-op` |
| `duplicate_template` | `tix template duplicate` | Copy to a new stable ID (`<source>-copy` by default), leaving the source untouched |
| `delete_template`| `tix template delete`| Delete a **definition**; **requires `confirm: true`**, and never touches issues created from it |
| `list_recurring_series` | `tix recurring list` | Every series in stable-ID order: id, name, state, cursor, skipped count, rule. Read-only |
| `get_recurring_series` | `tix recurring show` | One series' full state: rule, anchor, cursor, skipped, occurrences, diagnostics. Read-only |
| `detect_recurring_due` | `tix recurring detect` | The due warnings (with their stable occurrence keys) and why a series is *not* due. Read-only |
| `generate_recurring_occurrence` | `tix recurring generate --yes` | Create the due occurrence; **requires `confirm: true`**; idempotent |
| `pause_recurring_series` | `tix recurring pause --yes` | Suspend generation; **requires `confirm: true`**; idempotent |
| `resume_recurring_series` | `tix recurring resume --yes` | Resume a paused series; **requires `confirm: true`**; idempotent |
| `end_recurring_series` | `tix recurring end --yes` | End a series; terminal for generation; **requires `confirm: true`** |
| `skip_recurring_occurrence` | `tix recurring skip --yes` | Record a date as skipped, so it is never generated; **requires `confirm: true`** |
| `defer_recurring_occurrence` | `tix recurring defer --yes` | Move the cursor past a date without skipping it; **requires `confirm: true`** |
| `ignore_recurring_occurrence` | `tix recurring ignore --yes` | Stop offering a date (same schedule effect as `skip`); **requires `confirm: true`** |
| `cycle_schedule_list` | `tix cycle schedule list` | Every schedule in declaration order: id, name, state, cadence, duration, anchor, look-ahead, derived next sequence, instance count. Read-only |
| `cycle_schedule_show` | `tix cycle schedule show` | One schedule's full state: rule, cursor, instances, diagnostics, by stable id or unique name. Read-only |
| `cycle_schedule_create` | `tix cycle schedule create --confirm` | Define a schedule; validated before the atomic save. **Requires `confirm: true`**; an id is never reused — a live id is `exists`, and one a stored record **or** a snapshot still claims is `id_spent`, naming the holder |
| `cycle_schedule_edit` | `tix cycle schedule edit --confirm` | Change the named fields, keeping the stable id and the cursor. **Requires `confirm: true`** |
| `cycle_schedule_delete` | `tix cycle schedule delete --confirm` | Remove the **registry entry only**; instances and snapshots are kept and then read as a missing schedule. **Requires `confirm: true`** |
| `cycle_schedule_detect` | `tix cycle schedule detect` | The `ended`/`due`/`look-ahead` warnings with their instance ids, dates and accepted actions, plus field-attributed `failure` warnings. Read-only |
| `cycle_schedule_generate` | `tix cycle schedule generate --confirm` | Create the future instances; **requires `confirm: true`**; idempotent (a taken key is a `no-op`) |
| `cycle_schedule_close` | `tix cycle schedule close --confirm` | Capture the immutable snapshot and close an instance; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_reopen` | `tix cycle schedule reopen --confirm` | Reopen a closed instance: restore `active` and append one History event naming the prior snapshot; the snapshot's bytes are untouched. **Requires `confirm: true`** and a `reason`; idempotent (an open instance is a `no-op`) |
| `cycle_schedule_extend` | `tix cycle schedule extend --confirm` | Push an instance's due date out by one cadence period; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_skip` | `tix cycle schedule skip --confirm` | Record a sequence as skipped, so it is never regenerated; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_pause` | `tix cycle schedule pause --confirm` | Pause the schedule and its non-terminal instances; **requires `confirm: true`**; idempotent |
| `cycle_schedule_resume` | `tix cycle schedule resume --confirm` | Resume a paused schedule and its instances; **requires `confirm: true`**; idempotent |
| `cycle_schedule_adjust_future` | `tix cycle schedule adjust-future --confirm` | Re-derive every future instance's dates and the cursor; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_ignore` | `tix cycle schedule ignore --confirm` | Close a warning without deciding anything — nothing is written; **requires `confirm: true`** |
| `cycle_snapshot_list` | (snapshot view) | The immutable closed-cycle snapshots under `docs/cycle_snapshots/`: identity, historical dates, audit context, preserved metadata. Read-only |
| `capacity_list` | `tix capacity list` | Every configured capacity entry: the handle id, scope kind, reference, resolved display, ceiling, unit and enabled flag. Read-only; an absent ceiling/unit is `null`, never `0` |
| `capacity_show` | `tix capacity show` | One configured entry, by a cycle name **or** its stable id, a team label, or an assignee identity — the two spellings fold to one scope. Read-only |
| `capacity_calculate` | `tix capacity calculate` | The shared calculation for one scope: state, ceiling/unit, committed/completed/remaining/overflow, utilization, the issue counts, and every diagnostic with its reason and remedy. Read-only; absent values are `null`, never `0` |
| `capacity_snapshot` | `tix capacity snapshot` | A closed cycle's immutable capacity evidence (identity, values, findings), plus the cycle record's close/reopen History summaries. Read-only; a snapshot with no capacity payload is `evidence: none` — a value, never an error |
| `health_scope` | `tix health show` | One scope's health: state, layer, policy version, derived rollup with its contributors, audit entries, and every diagnostic with its remedy. Name exactly one of `issue` or `scope` |
| `health_rollup` | `tix health rollup` | A scope's derived health with its contributors and policy version. Read-only |
| `health_history` | `tix health history` | The append-only health History entries about a scope. Read-only |
| `health_record` | `tix health set` / `tix health override` | Record an issue's state, or layer an override over its derived value. One History entry carries actor/date/rationale, written with the value or not at all. **`override: true` requires `confirm: true`** |
| `list_projects` | `tix project list` | Projects in declaration order: id, name and derived member count. Read-only |
| `get_project` | `tix project show` | One project's record and its ordered members, by stable id (a name resolves to the same project). Read-only |
| `create_project` | `tix project create` | Create a project; the registry allocates the **stable id**, which is returned. Validates the name and the dates |
| `update_project` | `tix project edit` | Edit a project's fields; a `name` change is a label change only, so the id and every association survive. A blank field clears it |
| `delete_project` | `tix project delete --yes` | Remove the **registry entry only**, returning the affected issues; never deletes an issue. **Requires `confirm: true`** |
| `project_membership` | (derived read) | A project's ordered members and `member_count`, by value. Read-only |
| `project_health` | (derived read) | A project's raw figures — `done`, `total`, `overdue`, `blocked`, `unassigned`, `missing_references`. Read-only |
| `project_associate_issues` | `tix project add` / `bulk-add` | Associate each of several issues with a project, each independently |
| `project_dissociate_issues` | `tix project remove` | Dissociate each of several issues from a project. Drops only the association; never deletes an issue |
| `list_epics` | `tix epic list` | Epics in declaration order: id, name, derived member count and child count. Read-only |
| `get_epic` | `tix epic show` | One epic's record, its raw typed figures, its children and its ordered members, by stable id (a name resolves to the same epic). Read-only |
| `create_epic` | `tix epic create` | Create an epic; the registry allocates the **stable id**, which is returned. Validates the name, the dates and the weight |
| `update_epic` | `tix epic edit` | Edit an epic's fields; a `name` change is a label change only, so the id and every reference survive. A blank field clears it; `parent` is cycle-validated before any write |
| `delete_epic` | `tix epic delete --yes` | Remove the **registry entry only**, returning the affected issues **and** child epics; never deletes either. **Requires `confirm: true`** |
| `epic_hierarchy` | `tix epic navigate` | An epic's raw hierarchy — parent, children, ancestors, depth, and the unresolved record. Read-only |
| `epic_membership` | (derived read) | An epic's ordered members and `member_count`, by value. Read-only |
| `epic_health` | (derived read) | An epic's raw figures — `done`, `total`, `child_count`, `weighted_progress`, `blocked`, `missing_references`. Read-only |
| `epic_associate_issues` | `tix epic add` / `bulk-add` | Reference each of several issues from an epic, each independently |
| `epic_dissociate_issues` | `tix epic remove` | Remove an epic reference from each of several issues. Drops only the reference; never deletes an issue |
| `list_initiatives` | `tix initiative list` | Initiatives in declaration order: id, name, target date and status. The id is a column of its own. Read-only |
| `get_initiative` | `tix initiative show` | One initiative's record, its references with their resolution, its source-attributed summary and its diagnostics, by stable id (a name resolves to the same initiative). Read-only |
| `create_initiative` | `tix initiative create` | Create an initiative; the registry allocates the **stable id**, which is returned. Validates the name, priority and reference shapes through the atomic boundary |
| `update_initiative` | `tix initiative edit` | Edit an initiative's fields; a `name` change is a label change only, so the id and every association survive. A blank field clears it |
| `initiative_associate` | `tix initiative associate` | Associate an initiative with exactly one project, epic or milestone; the kind is explicit. An already-recorded association is an idempotent `changed: false` |
| `initiative_dereference` | `tix initiative dereference` | Drop one explicit association; an absent association is an idempotent `changed: false`. Never touches a source entity |
| `delete_initiative` | `tix initiative delete --yes` | Remove the **registry entry only**, returning the references it held; nothing cascades and no project, epic, milestone or issue is deleted or modified. **Requires `confirm: true`** |
| `roadmap_list` | `tix roadmap list` | The roadmap in order (target date ascending, then stable id) over the normalized `RoadmapFilter`; each row carries metadata, references with resolution, the source-attributed summary and diagnostics. Undated is explicit. Read-only |
| `roadmap_show` | `tix roadmap show` | One initiative's roadmap row: metadata, references with resolution, source-attributed summary and diagnostics, by stable id or name. Read-only |
| `roadmap_summary` | `tix roadmap show` (summary) | One initiative's raw typed summary: the contributions (provenance, the owning service's own value, risk, shared policy version), the explicit state, the source entities and policy versions, and the diagnostics. No blended figure. Read-only |
| `time_show` | `tix time show` | One issue's current time entries and its total: each entry's stable `id`, minutes, date, author, note and preserved `extensions` (nested, never flattened), plus the total's explicit state. Read-only; an absent value is `null`/`-`, never `0` |
| `time_history` | `tix time history` | What History retains for one issue: the current-entry count, every prior value a deletion left behind with who recorded it and when, and the correction count. Read-only |
| `time_total` | `tix time total` | The shared total for one scope — issue, assignee (the entry's author), inclusive `from`/`to`, `audit`, and an optional `by` breakdown — with the counts that say whether the sum is the whole story. Read-only |
| `time_add` | `tix time add` | Record effort in whole minutes, with an optional `YYYY-MM-DD` date, the required author and an optional note; the stable entry id is minted by the service. One attributable History event |
| `time_edit` | `tix time edit` | Correct one entry, naming only the fields that change (`date`/`clear_date` and `note`/`clear_note` are alternatives). Requires a `reason`; the prior value stays in History |
| `time_delete` | `tix time delete` | Remove one entry from the **current** entries only, leaving its value and its event in History. Requires a `reason` and **`confirm: true`** |
| `analytics_metadata` | (none — metadata) | The closed vocabularies, formulas and binding rules: scope kinds, modes with their formula version/precision/unit rule, the default board's declared units, coverage markers, projection statuses, the export format/schema, and every diagnostic kind with its remedy. Read-only |
| `analytics_burndown` | `tix analytics burndown` | The daily count/estimate series: scope, period, snapshot binding, mode, unit, formula version, the ordered daily points (`scope_added` vs `completions` kept apart) with each day's coverage and a `null` for every value the record cannot support, the coverage tally, and the findings. Read-only; an unenumerable period is refused, never zero-filled |
| `analytics_projection` | `tix analytics projection` | The read-only end-date forecast at `as_of`: its typed `status`, the planning `end_date`, the `remaining` work and `observed_rate`, and `projected_completion`/`meets_end_date` or `null`. An unanswerable forecast is an answer, not a refusal. Read-only |
| `analytics_export` | `tix analytics export` | The deterministic chart document for one scope, as its own pretty JSON: the format marker and schema version, the scope with its period and snapshot provenance, mode/unit/precision, the series with coverage, the optional projection, the unknown/coverage-limited day counts and the findings. `mode` is **required**. Read-only; `--output` is the CLI's own writer and is not reachable here |
Conventions:
- Every `inputSchema` is JSON-Schema draft-07, `additionalProperties: false`.
- Issue selectors accept an integer (`#123`), an 8-character issue id
(`A1B2C3D4`), or a filename/title fragment, matching the CLI's precedence:
number → issue id → unique filename fragment → unique title fragment.
- `labels` and `files` are comma-separated strings (parsed like the CLI).
`create_issue` and `update_issue` also take `related_issues` — a
comma-separated list of free-form references (a local number/selector, a
relative/absolute path to another issue file, or a repository URL
`host/path#id`), stored verbatim and returned as a `related_issues` array on
every issue payload. `lint_issues` takes `foreign` (default `true`) to also
lint the related repositories reachable by path, mirroring `tix lint
--no-foreign`. `create_issue`/`update_issue` also take `type` (a single issue
type, checked by lint against `custom_types`).
**`links` is gone from `create_issue` and `update_issue`** — see the breaking
change below. Create relationships with `link_issue`.
- Project arguments: `projects` (on `create_issue`, and on `update_issue` to clear)
and `project` (on `list_issues` and the project tools) name a project by its
**stable id**, with a name accepted as a convenience that resolves to the same
project. `projects` is a comma-separated list (the `--projects` shape);
`add_project` / `remove_project` on `update_issue` are **arrays**, one entry per
CLI occurrence, taken verbatim. `projects` **sets** the full set on
`create_issue` and **only clears** (blank) on `update_issue`; `add_project` and
`remove_project` are how an update changes the set. See
[Projects](#projects-association-membership-and-health).
- Epic arguments: `epics` (on `create_issue`, and on `update_issue` to clear) and
`epic` (on `list_issues` and the epic tools) name an epic by its **stable id**,
with a name accepted as a convenience that resolves to the same epic. `epics` is a
comma-separated list (the `--epics` shape); `add_epic` / `remove_epic` on
`update_issue` are **arrays**, one entry per CLI occurrence, taken verbatim.
`epics` **sets** the full reference set on `create_issue` and **only clears**
(blank) on `update_issue`; `add_epic` and `remove_epic` are how an update changes
the set. See [Epics](#epics-references-hierarchy-and-progress).
- There is **no task entity** (C-001): a task-labelled issue is an ordinary issue
that references an epic. No `task`, `work_item` or `epic_task` argument, tool or
key exists.
- `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.
- `create_issue` also takes `template` and `scope` (only with `template`): the
stable template ID and its explicit scope. The template's defaults and prompts
are merged with the fields by the same pure resolver the CLI uses, and the
resolved request goes through the same shared create core, so both surfaces
create the same issue and refuse with the same wording. `title` is required
unless a `template` is named, and the selected ID and scope come back on the
created issue as `template: { id, scope }`.
### 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`.
Structured relationship rows expose `from` and `to` as canonical issue ids.
Their `display` field and the tools' rendered text retain the human `#number`
aliases, so machine consumers compare identity while people read aliases.
`--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 canonical id, 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: "A1B2C3D4"` selects the same issue directly when number aliases
collide. The stored `parent:` value is the canonical id.
- `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 `(id, number, display)`.
- `get_parent` — the resolved parent's canonical id in `parent`, its number alias
in `parent_number`, or `null`. When the recorded parent no longer exists,
`recorded_parent` keeps the canonical reference (it survives so it can be
repaired), `recorded_parent_number` is its legacy alias when known, 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.
`last_decision` has three values: the decision object when the newest triage
`## History` line parses, `null` when the issue has no triage decision, and the
string `"unreadable"` when a decision line exists but its detail does not parse.
The third is a diagnostic, not a synonym for the second — "nobody has decided
this" and "this file carries a decision I cannot read" are different answers.
**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.
---
## Saved views
A saved view is a **named, repository-owned query definition** with a stable
identity: a filter/sort/group/board-visibility plan, explicit scope metadata, and
preserved unknown fields. It lives in the repository's own `tix.toml`, under
`[views.<id>]`. There is no database, no hosted view service, and no second query
language: `filter` is the same token query `tix list --filter` accepts, resolved
by the same grammar, and a plan is stored in the same shape the CLI's `tix view`
writes.
Nine tools mirror `tix view`:
- `view_list` — the registry in declaration order, each entry with its normalized
plan, scope, resolution, preserved extensions, and diagnostics. Read-only.
- `view_show` — one view by stable id, or by display name when it is unambiguous.
- `view_create` — save a new view from the existing grammar; the stable id
(`v-…`) is generated and never reused, and `name` is only a display label.
- `view_update` — replace the named fields, keeping the stable id (identity
survives a rename). Merge semantics: an unnamed field is carried over, so a
rename cannot drop the plan. A same-content update is a `no-op` and issues no
commit.
- `view_duplicate` — copy under a new stable id, carrying scope, plan, and
preserved extensions verbatim.
- `view_delete` — remove the **definition only**, and only with `confirm: true`.
It never deletes an issue, comment, or relationship.
- `view_share` — replace scope metadata (repository, project, or user).
- `view_apply` — apply read-only: the normalized plan, the applied issue numbers
in board order, a board summary, the active marker, and diagnostics.
- `view_status` — the saved plan against a live plan assembled from the plan
arguments, field by field.
**Structured outcomes.** A mutation answers with a `status` — `accepted`,
`no-op`, `rejected`, or `failed` — an `operation`, the resulting record, and the
one `audit` event when a change was committed. A rejection is `isError: true`
with the CLI's exact wording *and* a structured `diagnostics` array
(`kind`, `field`, `message`, `remedy`, `view_id`), so a client can act on the
field rather than parse prose: an unsupported `sort` is `invalid_sort` at
`plan.sort`, an inconsistent scope is `scope_conflict` at `scope.project_id`. A
rejected operation writes no `tix.toml` and stores no view.
**Strict arguments.** Every view tool declares a closed schema
(`additionalProperties: false`) and enforces it: an undeclared key is refused
with a field diagnostic naming it, rather than silently ignored. Host identity,
permissions, hosted sharing, and executable query payloads are not saved-view
fields — a request carrying them is rejected (a view is repository data, C-004).
**Read-only apply.** `view_apply` borrows the corpus, the config, and the board
and returns derived copies; no issue, comment, relationship, or `tix.toml` byte
changes. The active marker it reports is informational and never overwrites live
filter state, and `view_status` reports divergence without reconciling anything.
**Scope is metadata.** `repository` is the default; `project` names a project id
this repository knows; `user` stores owner/shared-with labels. None of them is an
account or a permission, and no tool infers sharing from who is calling.
**Session freshness.** The server bootstraps one config at startup, but every
view call reloads `tix.toml` from the repository. That is what makes a
`view_create` visible to the next `view_list` in the same session, and it means
the MCP surface and `tix view` always read and write the one registry on disk.
---
## Templates: the stable-ID registry
Templates live in `tix.toml` under `[issue_templates.<id>]`, keyed by an
**immutable ID** with a mutable display `name`. The ID is the handle; a rename
changes only the label, so defaults, prompts, references and any preserved unknown
metadata survive it (C-002). These tools are the MCP spelling of `tix template …`
and read and write the same registry.
**Reading.** `list_templates` returns one entry per stored template, and
`get_template` returns one by stable ID (or by a display name that names exactly
one template — an ambiguous name is refused rather than guessed). Each entry is
`{ id, name, scope, defaults, prompts, references, extensions }`, and the
`content` text is the CLI's own `tix template list`/`show` output, byte for byte.
**Managing.** `create_template`, `update_template`, `duplicate_template` and
`delete_template` map one-for-one to the `tix template` subcommands. Every result
carries a structured outcome:
| `accepted` | The registry changed and `tix.toml` was saved atomically. |
| `no-op` | A same-content update: nothing was written and no audit event exists. |
| `rejected` | The registry declined the change; nothing was written. `diagnostics` names the field. |
| `failed` | The definition was valid but the atomic save failed; `tix.toml` is unchanged. |
`accepted` and `no-op` are successful tool results; `rejected` and `failed` are
`isError: true` **and** carry the same structured outcome, so a client can branch
on `status` rather than parse the sentence. `diagnostics` is a list of
`{ kind, field, message }`: a malformed payload is `invalid-payload` with the exact
field (`defaults`, `prompts[1]`, `scope`), and a service refusal carries the
registry key it names (`issue_templates.<id>.<field>`).
**Definitions are data.** `defaults` accepts normal create fields only; `prompts`
are `{ key, kind, required?, default?, options_ref? }` with a closed `kind`
vocabulary (`text | select | multi-select | date`) and a closed `options_ref`
vocabulary; `references` are typed
(`label | assignee | file | cycle | milestone | relationship`). None of it is
executable, and an unknown key is refused rather than stored. Unknown metadata
already present in a template file round-trips untouched and is reported under
`extensions` — never interpreted as a command, prompt or issue field.
**Creating from a template.** `create_issue` with a `template` argument (and an
optional `scope`) resolves the template with the same pure resolver the CLI uses
and creates through the same shared core — there is no second writer. A resolution
failure is `isError: true` with `status: "rejected"`, one diagnostic per missing
required prompt or unknown reference, the CLI's own wording as the text, and **no
issue file**: a failed prompt never creates a partial issue. On success the created
issue carries `template: { id, scope }` as provenance. Without a `template`,
`create_issue` behaves exactly as it did before.
**Boundary.** No template tool adds a hosted service, a database, implicit scope
inheritance, executable template logic, or a second issue-creation path.
---
## Recurring: series, due warnings and lifecycle
A **series** is an issue whose `series` frontmatter states a rule; its
**occurrences** are issues generated from it. Identity is the immutable
`series.id`, never the display name, and every date is a canonical
`YYYY-MM-DD` key: an occurrence is `<series-id>@<YYYY-MM-DD>`. The ten tools
below are the MCP spelling of `tix recurring …`, over the same
resolver/detector/generator/lifecycle the CLI and the board use — MCP owns no
scheduler, no rule parser, no series writer and no second clock (C-008).
**Reading.** `list_recurring_series`, `get_recurring_series` and
`detect_recurring_due` are read-only, and detection is non-mutating by contract:
it creates no issue, appends no History entry and changes no series, so the
board's start/refresh warning and a `detect_recurring_due` call are the same
detector's answer. `series` is the stable ID, or a display name that names
exactly one series — an ambiguous name is refused rather than guessed.
**Dates are explicit.** `detect_recurring_due` takes `date` (`YYYY-MM-DD`) and an
optional `timezone`; omitted, the boundary resolves today, which is why passing
`date` is what makes an answer reproducible. `timezone` is carried on the context
(fixed offsets like `+01:00` and `UTC` are the useful values), exactly as
`tix recurring detect --timezone` carries it. A series **rule** that stores a named
IANA zone is the other case and is refused by the shared validator
(`invalid_series_rule`: only fixed UTC offsets are supported, because a
daylight-saving transition needs a tzdb to resolve). The mutation
tools that are about one date (`generate`, `skip`, `defer`, `ignore`) **require**
`date`; the series-level tools (`pause`, `resume`, `end`) refuse one instead of
ignoring it, so a caller that meant something else finds out.
**Mutations require `confirm: true`**, exactly as the CLI requires `--yes`. There
is one gate and it is the service's: an unconfirmed mutation is refused with the
`not_confirmed` diagnostic before the store is touched — the same refusal, word
for word, that `tix recurring …` without `--yes` prints — so adding a second
check here could only drift from it. `actor` is recorded in the History entry and
defaults to the boundary's identity (the git identity); `reason` is recorded
verbatim where one is given.
**Outcomes are shared**, so the same words mean the same thing on every surface:
| `created` | The occurrence was created, linked and recorded in one transaction; `issue_number` and `filename` name it. |
| `applied` | The lifecycle action took effect and one History entry was appended; `history.kind` is `transition_accepted`. |
| `no-op` | The key was already taken, or the action was already in effect: nothing was written and no History entry was appended. |
| `rejected` | The service declined; nothing was written. `diagnostics` carries `{ kind, message }` per reason. |
`created`, `applied` and `no-op` are successful tool results. `rejected` is
`isError: true` **and** carries the same structured outcome beside the CLI's
rendered refusal as its text, so a client can branch on `status` rather than
parse the sentence. Repeated generation is idempotent: a second call for the same
key is a `no-op`, never a duplicate issue.
---
## Cycle schedules: repeating cycles and lifecycle
A **cycle schedule** is a `[cycle_schedules.<id>]` entry in `tix.toml`: a stable
`id` (the registry key, never reused) with a mutable display `name`, a cadence, a
duration, an anchor date, a fixed UTC offset and a look-ahead. The cycle instances
it generates are ordinary cycle records (`docs/cycles/*.md`) carrying
`schedule_id`/`sequence`/`state`, so they are visible to `tix cycle list` and to
every reference that reads a cycle name. The sixteen tools below are the MCP
spelling of `tix cycle schedule …`, over the same record, the same read model
(`ScheduleIndex`), the same detector, the same generator and the same lifecycle
the CLI and the TUI use — MCP owns no cadence arithmetic, no cursor, no snapshot
writer and no History grammar (C-008).
**Reading.** `cycle_schedule_list` and `cycle_schedule_show` are read-only, and
`cycle_schedule_detect` is non-mutating by contract: it creates no cycle, moves no
cursor, writes no file and appends no History, so the board's refresh warning and
a `cycle_schedule_detect` call are the same evaluator's answer. `schedule` is the
stable id, or a display name that names exactly one schedule — an ambiguous name is
refused rather than guessed. `cycle_snapshot_list` reads the immutable snapshots a
close captured; they are evidence, never recomputed from the live schedule.
**Dates are explicit.** `cycle_schedule_detect` takes `date` (`YYYY-MM-DD`) and an
optional `timezone`; omitted, the boundary resolves today (there is no clock inside
the domain), which is why passing `date` is what makes an answer reproducible. The
`timezone` rides on the shared evaluation context, and the evaluator's answer is
offset-independent — a schedule's own validated offset is the calendar its cadence
is expressed in — which is why the CLI's namespace exposes `--date` alone.
A lifecycle action records an explicit instant: `timestamp` defaults to the same
boundary clock the CLI resolves it from, and an explicit value makes the record
reproducible. Generation is anchored at the schedule's own dates, so `date` is the
as-of context it records, never something that derives an instance.
**Mutations require `confirm: true`**, exactly as `tix cycle schedule …` requires
`--confirm`: an unconfirmed mutation is refused with the `not_confirmed` diagnostic
and writes nothing (C-004). The `create`/`edit`/`delete` registry verbs are
validated as a whole before the atomic save, so a refused change leaves `tix.toml`
exactly as it was; a schedule `id` is never reused (`exists` for a live id, and
`id_spent` when a stored cycle record **or** a closed-cycle snapshot still claims
it — both holders are deliberately left on disk by a delete, so the refusal names
whichever one remains and the way out is to delete it too, C-003/C-007).
**Instance selectors.** The lifecycle tools name an instance with `cycle`
(`<schedule-id>:<sequence>`, e.g. `cs-sprint:000001`) or with `from` (its start
date); a `from` that matches no instance is a refusal, not a no-op. `reason` is
required by the actions that change what a cycle *is* (`close`, `reopen`, `extend`,
`skip`, `adjust-future`) and the service refuses a missing one by name. `reopen` is
`close`'s inverse but deliberately not its mirror: it restores the record to
`active` and appends one `cycle_reopened` event naming the snapshot the close
captured, and it writes **no** snapshot — a closed cycle's capacity evidence is
immutable (C-008/NFR-002), so the prior snapshot's bytes and its `snapshot_id` are
untouched and stay readable.
**Outcomes are shared**, so the same words mean the same thing on every surface:
| `created` | A generation candidate became an instance; `cycle` names it, and the cursor advanced. |
| `applied` | The lifecycle action took effect and one History entry was appended; `snapshot_path` is present on a close, `dates` on a window move. |
| `no-op` | The postcondition already held — the instance was already closed, the key already taken, the state already current — so nothing was written. |
| `rejected` | The service declined; nothing was written. `diagnostics` carries `{ kind, field, message }` per reason, in the service's own vocabulary. |
`created`, `applied` and `no-op` are successful tool results. `rejected` is
`isError: true` **and** carries the same structured outcome beside the CLI's
rendered refusal as its text. Detection reports the evaluator's own warning kinds
(`ended`, `due`, `look-ahead`, `failure`) and, for each warning, the explicit
actions it accepts — the decisions a client may then take with a lifecycle tool.
---
## Capacity: scopes, calculations, and close evidence
Capacity is what a cycle, a team or an assignee is allowed to commit to, counted
in an estimate unit the repository already declares. The four tools below are the
MCP spelling of `tix capacity …`, over the **same shared library** the CLI adapter
calls — `CapacityScope` folds a scope reference, the one calculation entry point
derives the values, and the immutable snapshot reader reads the close evidence.
MCP owns no formula, no rounding policy, no conversion and no snapshot writer, so
a number here is a field of the same DTO the CLI prints (C-007/NFR-003).
**A scope is a name *or* its stable id.** `capacity_show` and `capacity_calculate`
fold the two spellings through the same adapter the CLI uses (`sprint:000001` and
`Weekly sprint 2026-01-05 000001` select one scope), which is the property a
generated instance's capacity depends on (C-003).
**Values, and what "absent" means.** Every derived field is present only when the
calculation could run. `state` says which case applies — `configured`,
`uncountable` (a ceiling is in force but the snapshot resolved no conversion for
its unit) or `unconfigured` (no ceiling is in force). A fresh CLI or MCP read always
builds the snapshot with every configured ceiling unit, so `uncountable` is the
defensive result only when a caller pairs an older snapshot with a later
configuration the snapshot did not see. In the text a missing value
is `-`; in the structure it is `null`. It is **never** `0`: an unconfigured scope
is not a scope with zero capacity, and a missing conversion is not a zero total
(C-004). `capacity_snapshot`'s `evidence` is `present` or `none`, the latter for a
snapshot written before this mission — a value, never an error.
**Diagnostics are shared and actionable.** Each finding carries a machine-readable
`kind` (`capacity_overflow`, `missing_conversion`, `unknown_scope`, …), the scope,
the numbers, the unit, the issues, and both a `reason` and a `remedy` (C-006/#1643).
The overflow report never moves, resizes or rewrites an issue or a cycle — it is a
report. The text mirrors the CLI's whole output for the same call: its stdout joined
with the findings it writes to stderr, where a finding about an estimate or a
reference always renders and `warnings: true` adds the overflow report exactly as
`--warnings` does. The structure always carries every diagnostic, so nothing is lost
without the flag.
**Snapshots are immutable evidence, and the History is the audit trail.** A close
captures the capacity payload once; `capacity_snapshot` returns that stored payload
(never a recomputation from the live schedule) together with the cycle record's
`history` summaries — the `cycle_closed` and `cycle_reopened` entries the lifecycle
appends, oldest first. A reopen appends one History event and leaves the prior
snapshot byte-identical (C-008/NFR-002). Those are the complete capacity-affecting
History kinds in this build.
**Boundary: the configuration tools are read-only.** `capacity_list` and
`capacity_show` read the `[capacity]` section; they never write it. Capacity
configuration is a validated `tix.toml` change with exactly one writer — the CLI's
`tix capacity set|delete` — so an MCP write spelling would be the second writer
C-008 prohibits. An agent configures through the same command a human does and
reads the result here. No capacity tool on any read path saves a config, writes a
record, or appends a History event.
---
## Work-in-progress limits
A board may cap what each column holds. The limits live in `tix.toml` under
`[kanban.<board>.wip]` (units, then per-column ceilings and a `warn`/`block`
policy) and are edited with `tix wip set|unset|unit`. Nothing here is
MCP-specific: `move_issue` asks the **one** policy service the CLI `tix move` and
the TUI's keyboard, context menu and drag all ask, so a limit cannot be walked
around by calling a different tool (C-006).
**Reading.** `board_status` renders the same table `tix status` does, including
each column's usage (`4/3 8/10d!`) when the board configures a limit. Its
`structuredContent` **is** the document `tix status --json` prints — both come
from one evaluation, so a script reading the CLI or a client reading the tool
sees the same columns, order and total.
**Moving.** Every `move_issue` result carries a `wip` object — `kind`, `from`,
`to`, the limit, before/after state, diagnostics, and the `history` entry when an
override was applied:
| `allow` | within every limit, or the column has none | yes |
| `warn` | over a limit configured to warn | yes — the decision is reported, not enforced |
| `block` | over a limit configured to block, no override | **no** — nothing was written |
| `override` | over a blocking limit, carried by an authorized override | yes — one History entry was appended |
| `rejected` | the estimate could not be counted, or the override was incomplete or stale | **no** — nothing was written |
A `block` or a `rejected` decision is an `isError: true` result that **still**
carries the structured `wip` object, so a client can branch on `wip.kind` instead
of parsing the sentence. An override is not a flag: `override: true` alone leaves
the move refused, and so does `override` with a `reason` but no `yes` — the
confirmation is the caller's (`unconfirmed_override`). The override also binds the
limit it was authorized against, so a limit that changed in between is
`stale_override_limit` rather than a silent authorization.
An issue whose `estimate` cannot be counted in the limit's unit (not
`<number><unit>`, an undeclared unit, or a conversion that does not divide evenly)
**refuses** the move rather than counting as zero — `missing_conversion`,
`malformed_estimate` or `ambiguous_conversion`, naming the issue and the value.
**Without limits.** A board that configures none behaves exactly as before: the
move succeeds, `wip.kind` is `allow`, `wip.limit` is `null`, and no refusal, event
or message that mentions a limit can occur.
---
## Projects: association, membership and health
A project is a **delivery container**: a record in `tix.toml`'s `[projects.<id>]`
registry with a mutable display name, and an issue may belong to several. The
**identity is the stable id**; a name is a display label, so every project tool
resolves by id first and accepts a name as a convenience that resolves to the same
project — a rename therefore breaks no association. Every listing carries the id
beside the name, because without it a rename is indistinguishable from a new
project.
**Associating an issue.** `create_issue` takes `projects`, and `update_issue` takes
`projects`, `add_project` and `remove_project`, all comma-separated lists of ids or
names. The semantics are the CLI's, exactly:
| `create_issue { projects }` | **Sets the full association set** for the new issue. Every reference is resolved through the project service *before* the issue is written, so an unknown one refuses the whole create with `unknown_project` and nothing is written; duplicates (including a name and its id) collapse to one association | `tix create --projects` |
| `update_issue { add_project }` | An **array**: each entry is one `--add-project` occurrence, taken **verbatim**. **Adds** that association. Idempotent: an association already present writes nothing and appends no History entry. A padded or blank entry is a reference that does not resolve — exactly as `--add-project " p-a "` / `--add-project ""` is refused by the CLI | `tix edit --add-project` |
| `update_issue { remove_project }` | An **array**, each entry taken verbatim. **Drops** each association, and only the association — never the issue. An entry that names a live project resolves to its id; one that resolves to nothing must be an id the issue already carries, which is the repair path for a stale reference | `tix edit --remove-project` |
| `update_issue { projects: "" }` | **Clears** every association. `projects` on an update *only clears*: a non-blank value is refused, because setting associations has one spelling (`add_project`), so a typo cannot silently replace the set | `tix edit --projects ""` |
`projects` is deliberately **not accepted beside `template`** on `create_issue`
(and is refused with the CLI's own wording), for the same reason the CLI refuses
the combination. Project references are resolved through `src/projects.rs`, so
identity, uniqueness and membership have one owner; the MCP layer re-derives none
of them. `projects` (creating or clearing) is a comma-separated list, matching the
`--projects` flag, while `add_project`/`remove_project` are arrays whose entries
stand for occurrences of the CLI's repeatable flags and reach the service
**verbatim** — so a padded or blank entry is refused on both surfaces rather than
quietly cleaned up here.
**The project tools.** `list_projects`, `get_project`, `create_project`,
`update_project` and `delete_project` mirror the `tix project` lifecycle verbs.
`create_project` returns the **allocated stable id**; `update_project` renames the
label without re-keying anything, and re-derives the member list so a registry-only
edit cannot report a member count of zero. `delete_project` removes the registry
entry **only** and returns the affected issues (the CLI's impact report) so an agent
can warn a user instead of orphaning work: the issues keep their references, which
then read as diagnosable `missing_project_reference` findings until repaired. No
issue is ever deleted, and there is no cascade. It requires `confirm: true`.
**Reading membership and health.** `project_membership` and `project_health` are
read-only: no file is written, no History entry is appended, and the registry is
untouched. Both return the **raw values** rather than a formatted summary, so two
surfaces can be compared by value:
- `project_membership`: `project_id`, `member_count`, and `members` — an ordered
list (by issue number) of `{ issue, display }`, where `display` is the shared
`#<number> <title>` label computed on read.
- `project_health`: `done`, `total`, `overdue`, `blocked`, `unassigned`, and
`missing_references` (each with its issue, the recorded id and the service's
reason). `done` counts members in the board's **configured** completion column;
`overdue` and `blocked` are **consumed** from the derived reads that own them,
never recomputed. The text mirror is the same line `tix show` and
`tix list --show progress` render, from the one shared renderer.
The figures come from `crate::projects` — the one implementation — so CLI, TUI and
MCP report the same value for the same snapshot.
**Which snapshot each read uses**, because the CLI itself distinguishes two and a
read that picked the other one would report different numbers for the same
repository:
| the registry verbs — `list_projects`, `get_project` | every issue file, archived included | `tix project list` / `tix project show` |
| the figure reads — `project_membership`, `project_health`, `list_issues { project }` | the **active** set (archived issues excluded, as everywhere else in the listing and the detail view) | `tix show`'s project block and `tix list --show progress` |
So with an archived member, `get_project` lists it (as `tix project show` does)
while `project_membership` and `project_health` do not count it (as the figures
`tix show` prints do not).
`list_issues` takes `project` to restrict the listing to one project's members:
that matching set is the service's derived membership for the same active set, so
the filter and the membership read cannot disagree. Both resolve the project
against the registry **on disk**, re-read per call, so a project created (or
deleted) earlier in the same session resolves exactly as it does through
`list_projects` / `project_membership`.
`get_issue`'s text mirrors `tix view` including the `## Projects` block, so an
associated issue's document carries the same project lines (id, name, `done/total`,
overdue/blocked/unassigned, or the dangling reference's reason) that the CLI prints.
**Bulk association.** `project_associate_issues` and `project_dissociate_issues`
take a project and a comma-separated `issues` list, and report one outcome per
selector in input order. Each selector is processed independently: one rejection is
that entry's error and never aborts the rest, each accepted change appends exactly
one History entry, and an already-present (or already-absent) association is an
idempotent no-op. `project_associate_issues` resolves the project once up front, so
an unknown project refuses before any write, while `project_dissociate_issues`
falls back to the reference **verbatim** when it resolves to no live project — the
repair path for a stale stored id.
---
## Epics: references, hierarchy and progress
An epic is a **stable registry record**: an entry in `tix.toml`'s `[epics.<id>]`
table with a mutable display name, and an issue may reference several. The
**identity is the stable id**; a name is a display label, so every epic tool
resolves by id first and accepts a name as a convenience that resolves to the same
epic — a rename therefore breaks no reference. Every listing carries the id beside
the name, because without it a rename is indistinguishable from a new epic.
There is **no task entity** (C-001). A "task" is an ordinary issue that references
an epic, possibly carrying a `task` label. Referencing an epic does not change how
the issue itself is navigated, and no tool, argument or key named `task`,
`work_item` or `epic_task` exists anywhere in the surface.
**Referencing an epic from an issue.** `create_issue` takes `epics`, and
`update_issue` takes `epics`, `add_epic` and `remove_epic`. The semantics are the
CLI's (`tix create --epics`, `tix edit --add-epic/--remove-epic/--epics ""`),
exactly:
| `create_issue { epics }` | **Sets the full reference set** for the new issue. Every reference is resolved through the epic service *before* the issue is written, so an unknown one refuses the whole create with `unknown_epic` and nothing is written; duplicates (including a name and its id) collapse to one reference | `tix create --epics` |
| `update_issue { add_epic }` | An **array**: each entry is one `--add-epic` occurrence, taken **verbatim**. **Adds** that reference. Idempotent: a reference already present writes nothing and appends no History entry. A padded or blank entry that resolves to nothing is refused with `unknown_epic`, exactly as the CLI refuses it | `tix edit --add-epic` |
| `update_issue { remove_epic }` | An **array**, each entry taken verbatim. **Drops** each reference, and only the reference — never the issue. An entry that names a live epic resolves to its id; one that resolves to nothing must be an id the issue already carries, which is the repair path for a stale reference | `tix edit --remove-epic` |
| `update_issue { epics: "" }` | **Clears** every reference. `epics` on an update *only clears*: a non-blank value is refused, because setting references has one spelling (`add_epic`), so a typo cannot silently replace the set | `tix edit --epics ""` |
`epics` is deliberately **not accepted beside `template`** on `create_issue` (and
is refused with the CLI's own wording). References are resolved through
`src/epics.rs` and written through the store's service-backed `reference_epic` /
`dereference_epic`, so identity, uniqueness and the History entry have one owner;
the MCP layer re-derives none of them.
**The epic tools.** `list_epics`, `get_epic`, `create_epic`, `update_epic` and
`delete_epic` mirror the `tix epic` lifecycle verbs. `create_epic` returns the
**allocated stable id**; `update_epic` renames the label without re-keying anything
and re-derives the members and children so a registry-only edit cannot report a
count of zero. A `parent` change is validated by the service's **iterative** epic
chain walk (self-parent and cycle) before any write, so a refusal leaves the
registry byte-unchanged. `delete_epic` removes the registry entry **only** and
returns the affected issues **and** the child epics (the CLI's impact report): the
issues keep their references, which then read as diagnosable `missing_epic_reference`
findings, and the child epics keep their recorded parent, which then reads as an
unresolved parent. Nothing is deleted or modified, and there is no cascade. It
requires `confirm: true`.
**Reading hierarchy, membership and progress.** `epic_hierarchy`, `epic_membership`
and `epic_health` are read-only: no file is written, no History entry is appended,
and the registry is untouched. All three return the **raw values** rather than a
formatted summary, so two surfaces can be compared by value (NFR-003):
- `epic_hierarchy`: `parent`, `children`, `ancestors` (root-first), `depth`, and
the explicit `unresolved` record when a recorded parent no longer exists, in the
service's order — plus the ordered `members` so an agent can open an ordinary
issue directly.
- `epic_membership`: `epic_id`, `member_count`, and `members` — an ordered list (by
issue number) of `{ issue, display }`, where `display` is the shared
`#<number> <title>` label computed on read.
- `epic_health`: `done`, `total`, `child_count`, `progress`, `weighted_progress`
(`own_weight`, `weighted_children`, `unweighted_children`, `done_weight`,
`total_weight`, `percent`), `blocked` with its `blocked_members`, and
`missing_references` (each with its holder, its reference **kind**, the `tix lint`
diagnostic kind, the recorded target and the service's reason). `done` counts
members in the board's **configured** completion column; `blocked` is
**consumed** from the relationship service's derived read, never recomputed.
`get_epic` returns the record plus the same typed figures under `rollup`, and its
text is exactly `tix epic show`'s (`record_lines`), so an agent and a person read
the same characters.
**Which snapshot each read uses**, because the epic surface reads every issue file:
| the epic verbs — `list_epics`, `get_epic`, `epic_hierarchy`, `epic_membership`, `epic_health` | every issue file, archived included (the index), with the blocked state read over the active set | `tix epic list` / `show` / `navigate` |
| `list_issues { epic }` | the **active** set, as the listing itself is | the listing's own snapshot |
`list_issues` takes `epic` to restrict the listing to one epic's members: that
matching set is the service's derived membership restricted to the **active** set
the listing itself reads. `epic_membership` returns that same service membership
over **every** issue file (the epic verbs' snapshot, per the table above), so the
two agree except for archived members: an issue archived while it still references
an epic appears in the membership read but not in the filter, because the listing
never reads an archived issue. Both resolve the epic against the registry **on
disk**, re-read per call, so an epic created (or deleted) earlier in the same
session resolves exactly as it does through `list_epics`.
`list_epics` also returns the snapshot's **issue-held** unresolvable references in
`missing_references` (a reference to an epic that no longer exists, or an entry
that is not a well-formed epic id) as raw `{ holder, kind, diagnostic_kind,
target, reason }` entries. Together with the epic-held entries `epic_health`
returns, that covers all five reference kinds `resolve_references` diagnoses — the
same single pass every reference diagnostic is derived from, so no diagnostic is
re-derived here.
`get_issue`'s text mirrors `tix view` including the `## Epics` block, so an issue
that references an epic carries the same epic lines (id, name, progress, weighted
figure, parent, blocked members, or the dangling reference's reason) that the CLI
prints.
**Bulk reference.** `epic_associate_issues` and `epic_dissociate_issues` take an
epic and a comma-separated `issues` list, and report one outcome per selector in
input order. Each selector is processed independently: one rejection is that
entry's error and never aborts the rest, each accepted change appends exactly one
History entry, and an already-present (or already-absent) reference is an
idempotent no-op. `epic_associate_issues` resolves the epic once up front, so an
unknown epic refuses before any write, while `epic_dissociate_issues` falls back to
the reference **verbatim** when it resolves to no live epic — the repair path for a
stale stored id.
---
## Initiatives and the roadmap
An **initiative** is a registry record in `tix.toml` (`[initiatives.<id>]`): a
**stable id**, a mutable display name, optional outcome/owner/status/priority/
labels/target-date metadata, and explicit project/epic/milestone references. It is
a *projection over* the existing project, epic, milestone, planning and health
services — never a second metric authority, and never an implicit issue-ownership
change.
**Lifecycle.** `list_initiatives` and `get_initiative` are read-only (the text
mirrors `tix initiative list` / `tix initiative show`, and the id is always a
column of its own so a rename can never look like a new initiative);
`create_initiative` and `update_initiative` are the two field mutations,
`initiative_associate` and `initiative_dereference` record and drop one explicit
reference, and the confirm-guarded `delete_initiative` removes the registry entry
(and nothing else). Every mutation goes through the registry's **atomic
boundary**: a refusal leaves `tix.toml` byte-identical and appends no History.
`update_initiative { name }` changes the label only — the id, every association
and every derived value survive — and a blank optional field (`""`) clears it.
**References are shape-checked, never resolved.** A project id, an epic id or a
milestone name that is well-formed but names nothing is **stored** and stays
diagnosable; it is never silently dropped and never inferred from an owner or a
label. `initiative_associate` takes an explicit `kind`
(`project`/`epic`/`milestone`) and a `target`, and reports `changed`: an
already-recorded association (and a dereference of one that is not recorded) is an
idempotent no-op that writes nothing.
**Roadmap reads are a join, not a blend.** `roadmap_list` orders initiatives by
target date ascending then stable id (an **undated** initiative is the explicit
`undated` state and sorts last, never a blank or an invented period) and applies
the normalized filter. The filter arguments are the **same `RoadmapFilter` DTO**
the CLI builds, with the same field names — `period_from`, `period_to`, `owner`,
`status`, `labels`, `query` — so the two surfaces cannot drift into two query
languages. A malformed value (a non-`YYYY-MM-DD` date, an inverted period, a blank
owner/status/label) is refused with the filter's own named diagnostic *before* any
work is done; a well-formed value that names no initiative selects nothing (an
empty list, not an error). `roadmap_show` and `roadmap_summary` are the same
projection for one initiative; the text is the CLI's own `roadmap_row_lines`.
Each row (and each `roadmap_summary`) carries its metadata, its references with
their `resolution` (`resolved` / `missing` / `ambiguous`), the source-attributed
`summary`, and the ordered `diagnostics`. The summary is one **contribution** per
referenced source entity: the `<kind>:<id>` provenance, the owning service's own
typed value (`progress.value` is a project report or an epic rollup, exactly as
that service returned it), the shared health policy version, and the risk value.
There is **no blended percentage, score or average** anywhere — the only aggregate
is `knowable_contributions`, a count of contributions that report anything, and
`0` progress is a value, not an absence. A reference that does not resolve is
still a contribution (`resolution: "missing"`, no value, with the diagnostic that
says why), an initiative that references nothing has the explicit `empty` state,
and a state is never conflated with another. All roadmap and initiative reads are
**read-only**: no write and no History entry.
---
## Project and issue health
Health is **metadata**: the current value lives in an issue's frontmatter, every change
is an append-only History entry carrying an actor, a date and a rationale, and a scope's
derived value comes from what contributes to it under a versioned policy. It never
replaces a status category and never means "done".
**Reading.** `health_scope`, `health_rollup` and `health_history` all answer from the one
portable health document the CLI renders, and return the CLI's text verbatim beside the
structured fields — so an agent and a person reading `tix health show` see the same
characters. Each response carries the policy version and the vocabulary, because two
numbers are only comparable when they were derived under the same formula.
The four states are `unknown`, `on_track`, `needs_attention` and `at_risk`, and the
version is `health-v1`; "worst wins" over the last three, an empty scope is `unknown`,
and an **unresolvable reference** contributes `unknown` rather than a healthy value. A
legacy issue with no health history is an explicit `unknown` — never absent, never
healthy by default.
**Writing.** `health_record` goes through the same writer the CLI and the TUI use, so a
value recorded from any surface is the same value, in the same History entry. A missing
rationale, a state outside the vocabulary, or an override without `confirm: true` is
refused **having written nothing**. `board_status` takes an opt-in `health` flag and then
appends the same summary line `tix status --health` prints, with the same `health`
object in `structuredContent` that `tix status --json --health` prints; without it,
the response is byte-identical to what it was before health existed and no `health`
key is emitted.
**Remote targets.** Neither GitLab nor Linear is driven to carry a health field in this
build (no CLI verb is verified), so an export reports `unsupported_remote_field` naming
the field and the target — and the local value is never cleared by a remote that cannot
hold it.
## Time tracking
Time entries are **actual effort**, recorded against an issue. One entry is a
duration in whole minutes, an optional `YYYY-MM-DD` calendar date, an author and an
optional note, with a stable id; the current entries live in the issue's own
frontmatter and every correction and deletion appends one attributable History
event. That model is closed, and the schemas enforce it: there is **no timer**, no
start/stop pair, no timezone conversion, no overlap rule and no billing, payroll or
invoicing field. An argument that tries to be one (`timer`, `timezone`, `rate`, …)
is refused **by name** rather than ignored, because a client that believes it
started a timer is worse than a client that is told it cannot.
The six tools are the six `tix time` verbs, over the same shared DTOs the CLI
renders — so a number here and a number there are one number. (The tool *table* is
above, once, beside the CLI counterpart of each name; this is what each argument
means.)
- `time_show` (read) — `issue`, any selector. The current entries, then the issue's
total.
- `time_history` (read) — `issue`. The current count, every deletion History
retains, and the correction count.
- `time_total` (read) — `issue`, `assignee`, `from`, `to`, `audit`, `by`. The scope's
total, and one block per group when `by` names a dimension.
- `time_add` (write) — `issue`, `minutes`, `author` required; `date`, `note`,
`actor`, `timestamp`, `reason`.
- `time_edit` (write) — `issue`, `entry` required; `minutes`, `date` **or**
`clear_date`, `note` **or** `clear_note`, `author`, `reason`, `actor`, `timestamp`.
- `time_delete` (write) — `issue`, `entry`, `confirm: true` required; `reason`,
`actor`, `timestamp`.
**Absent is never zero.** A total carries an explicit `state`:
| `recorded` | at least one entry was counted; `total_minutes` is its sum, and a genuine `0` is a recorded value |
| `nothing` | the scope selected no entry at all — a real answer, not an absence |
| `unknown` | the scope selected entries and no usable number came back (all malformed, or the period cannot place the valid ones because they carry no date) |
In the text a value the answer does not have is `-`, and in `structuredContent` it
is `null`; a recorded `0` is `0`. `malformed_count` and `undated_count` (with
`is_partial`) say whether the sum is the whole story, and every finding appears in
`diagnostics` with its `kind`, the `field`/`issue`/`entry_id` it belongs to, a
`reason` and a `remedy` — malformed entries are reported, never hidden, and never
block another issue's read.
Two states are **refusals, not answers**: an invalid filter (`invalid_filter`) and an
issue the snapshot does not hold (`unknown_issue`). They return `isError: true` with
the CLI's own rendered block — the same bytes `tix time total` writes to stderr, exit
code and all — because no entry was evaluated, so there is no total to report. An
unresolvable selector, an unknown `--by` dimension and a delete without
`confirm: true` refuse the same way, in the CLI's words.
**Writes.** Every mutation goes through the same shared service the CLI and the TUI
call: `time_add`/`time_edit`/`time_delete` own no writer, validate nothing
themselves, and touch no field but the entry and `updated` — never ownership, status
or estimate. A correction requires a `reason` and a deletion requires a `reason`
**and** `confirm: true`; both are enforced by the service (missing one is refused
with `missing_reason`, having written nothing) and the confirmation is checked before
the store is read. Each applied mutation appends **exactly one** History event, which
the response returns as `history`; a request that asks for exactly what is already
there is an explicit `unchanged` with `history: null` and no write.
## Burndown and scope analytics
The four tools mirror `tix analytics`, over the same shared series, coverage,
projection and chart-document services the CLI and the TUI read — so a number
here and a number there are one number, and MCP folds nothing of its own.
- `analytics_metadata` (read, **no CLI counterpart**) — no arguments. The closed
vocabularies and rules a caller needs *before* it folds anything: `scope`
kinds, every mode with its formula version, precision and unit rule, the units
the project's **default board** declares a conversion for, the coverage
markers, the projection statuses, the export format marker and schema version,
and every diagnostic kind with its remedy. Each entry is the shared
vocabulary's own, so the answer cannot list a name the services do not
produce.
- `analytics_burndown` (read) — `scope`, `id`; `mode`, `unit`, `from`, `to`.
The daily series.
- `analytics_projection` (read) — `scope`, `id`, `as_of`; `mode`, `unit`,
`from`, `to`.
- `analytics_export` (read) — `scope`, `id`, `mode`; `unit`, `from`, `to`,
`as_of`.
**The text is the CLI's stdout, byte for byte.** `content` is what
`tix analytics burndown` / `projection` / `export` prints on stdout, and
`structuredContent` is the same chart document the text was rendered from — the
one typed shape every surface consumes. `analytics_export`'s text is that
document's own pretty JSON. The CLI's findings go to **stderr** in the CLI and
are carried in the document's `diagnostics` here, with the text's
`diagnostics: N` line counting the same findings.
**A value the record cannot support is never a `0`.** Every number is nullable:
an unobserved day has no `scope_added` and no `completions`, an unanswerable
level has no `remaining`, and an unprojected completion date and an undecided
`meets_end_date` are `null` (the text renders `-`). Beside them the day's
`coverage` says `known`, `unknown` or `coverage_limited`, the document's
`coverage` tally counts the days in each state, and the projection's `status`
says whether it could answer at all. A genuine `0` — an observed day with no
completion — is a real value and stays `0`; the coverage marker is what tells the
two apart.
**A refusal is not an answer.** A request that cannot be carried out at all —
an unknown `scope` kind or `mode`, a malformed date, `from` after `to`, estimate
mode with no `unit`, a forecast with no planning end date, `analytics_export`
without `mode` — returns `isError: true` with the CLI's own rendered block: the
same bytes `tix analytics …` writes to stderr, exit code and all, because no
report was produced. So does a fold that answers no day at all (an unenumerable
period, or a unit the project declares no conversion for) — an unreadable series
is refused rather than printed empty, while `analytics_export` still returns the
same document with its findings in band, exactly as `tix analytics export` does.
**Local only.** There is no hosted analytics service: a call naming one —
`source`, `target` or `remote` — is refused **by name** rather than ignored,
because a configured `gitlab`/`linear` target is a remote *export mapping* that
cannot carry a chart document (the document reports it itself, as an
`unsupported_remote_field` finding), not a source of burndown data. Nothing here
writes: no issue, cycle, milestone or snapshot is opened for writing, and the
CLI's own `--output` writer is not reachable as an argument.
## 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 118 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.