tix-tracker 0.7.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# 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:

| Code   | Meaning               | When                                                   |
|--------|-----------------------|--------------------------------------------------------|
| -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

| Tool             | CLI counterpart      | Purpose                                            |
|------------------|----------------------|----------------------------------------------------|
| `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).
  `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`.
- `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:

| Before | After |
|---|---|
| `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.

`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:

| Argument   | Meaning |
|---|---|
| `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

| Symptom                            | Cause / fix                                                    |
|------------------------------------|----------------------------------------------------------------|
| 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.