//! The MCP tool surface (WP04; WP08 added the triage tools): the static registry,
//! strict argument extraction, the FR-014 structured result shape, and the
//! handlers wired to the WP02/WP03 command cores (plus `Issue::add_comment` for
//! `comment_on_issue`, which has no CLI counterpart — C-003).
//!
//! Two layers stay strictly separate (`contracts/mcp-tools.md` §4, T021):
//! - *protocol* errors (`-32601` unknown tool, `-32602` type-invalid
//! arguments) are returned as JSON-RPC errors by [`call`];
//! - *domain* failures (bad selector, unknown column, missing external CLI…)
//! are tool-level results with `isError: true` and the CLI's exact error
//! string verbatim (`C-006`) — never a JSON-RPC error (`NFR-002`), so a
//! failing tool never kills the server.
use std::sync::OnceLock;
use serde_json::{Value, json};
use crate::bulk;
// The **data** module keeps the plain name `bulk`; the CLI surface is aliased, because
// both are called `bulk` and importing both plainly is a shadowing error rather than
// a choice. Same split as `ui::bulk`.
use crate::commands::bulk as cmd_bulk;
use crate::commands::{
create, delete, edit, export, import, lint, list, mv, parse_priority, parse_selector, search,
status, view,
};
// WP08: the triage surface. `cmd_triage` is the CLI's own module, aliased for the
// same reason `cmd_bulk` is — and *used*, not re-implemented: `triage_list` and
// `triage_status` return its rendered text verbatim (NFR-003).
use crate::commands::triage as cmd_triage;
use crate::config::Config;
use crate::error::Result as TixResult;
use crate::model::{Issue, Status};
use crate::search::SearchHit;
use crate::storage::Store;
use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch, inbox};
use super::protocol;
// ---------------------------------------------------------------------------
// T019 – registry + argument helpers
// ---------------------------------------------------------------------------
/// A tool's static metadata. `input_schema` mirrors `contracts/mcp-tools.md`
/// §3 exactly (property names, types, `required`, `additionalProperties`).
pub(crate) struct ToolDescriptor {
pub(crate) name: &'static str,
description: &'static str,
input_schema: Value,
}
/// A handler's argument type errors are protocol failures (`-32602 Invalid params`);
/// handlers use [`ToolOutcome`] so a failed core call becomes an `isError` result
/// rather than propagating as a protocol error.
type ToolOutcome = std::result::Result<Value, ()>;
/// A handler's **inner** step: either a value, or a finished outcome (either a
/// protocol error or a tool result).
///
/// Split from [`ToolOutcome`] so a helper that can produce *either* kind of refusal
/// has a return type that carries both, and the handler's final return type stays the
/// one the dispatch table expects.
type ToolOutcome2<T> = std::result::Result<T, ToolOutcome>;
/// The inner-step form of a **protocol** error: a `-32602`.
///
/// Named because `Err(Err(()))` is unreadable and the two ways an inner step can fail
/// are the exact thing this alias exists to keep apart. A malformed argument is a
/// protocol error; a well-formed request the batch refuses is a tool result.
fn proto() -> ToolOutcome {
Err(())
}
/// The inner-step form of a **refusal**: a finished tool result carrying the reason.
fn refuse(text: String) -> ToolOutcome {
Ok(error_result(text))
}
pub(crate) fn registry() -> &'static [ToolDescriptor] {
static REGISTRY: OnceLock<Vec<ToolDescriptor>> = OnceLock::new();
REGISTRY.get_or_init(|| vec![
ToolDescriptor {
name: "create_issue",
description: "Create a new issue. Returns the created issue (its number, filename, and fields) exactly as `tix create` would write it.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string", "description": "Non-empty title (required; no TTY prompt fallback)." },
"status": { "type": "string", "description": "Column; default the first configured column." },
"priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Default medium." },
"assignee": { "type": "string", "description": "Handle (`alice`/`@alice`/`@me`) or full identity; default git author." },
"labels": { "type": "string", "description": "Comma-separated labels." },
"files": { "type": "string", "description": "Comma-separated spec file paths." },
"related_issues": { "type": "string", "description": "Comma-separated related-issue references: a local number/selector, a relative or absolute path to another issue file, or a repository URL (`host/path#id`). Stored verbatim." },
"type": { "type": "string", "description": "Issue type (e.g. `bug`, `feature`); `tix lint` checks it against `custom_types`." },
"estimate": { "type": "string" },
"cycles": { "type": "string", "description": "Comma-separated cycle names." },
"milestones": { "type": "string", "description": "Comma-separated milestone names." },
"reviewer": { "type": "string", "description": "Free-form reviewer name/identity." },
"start_date": { "type": "string", "description": "`YYYY-MM-DD`." },
"due_date": { "type": "string", "description": "`YYYY-MM-DD`; must be `>= start_date`." },
"parent": { "type": "string", "description": "Parent issue selector — number, filename, or unique title fragment (as everywhere a selector is taken). An empty string clears an existing parent; omitted leaves it alone." },
"body": { "type": "string", "description": "Markdown body." }
},
"required": ["title"]
}),
},
ToolDescriptor {
name: "list_issues",
description: "List issues as the board table. Filter by column.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"status": { "type": "string", "description": "Column to filter by." },
"filter": { "type": "string", "description": "Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)." },
"sort": { "type": "string", "description": "Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)." },
"group": { "type": "string", "description": "Group key (`cycle` | `milestone`)." }
}
}),
},
ToolDescriptor {
name: "get_issue",
description: "Return the full issue (frontmatter + body) for a selector.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "board_status",
description: "Column counts plus the total (`tix status`).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"filter": { "type": "string", "description": "Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)." },
"sort": { "type": "string", "description": "Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)." },
"group": { "type": "string", "description": "Group key (`cycle` | `milestone`)." }
}
}),
},
ToolDescriptor {
name: "update_issue",
description: "Merge fields into an issue (CLI `tix edit` flag path only; `$EDITOR` is never opened). At least one optional field must be provided.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"title": { "type": "string" },
"body": { "type": "string" },
"status": { "type": "string", "description": "Set the status. This 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 way around the policy. `\"\"` clears the field (matching every other field here) and is a plain field write, not a transition, because there is no target column to judge." },
"priority": { "type": "string", "enum": ["low", "medium", "high"] },
"assignee": { "type": "string" },
"labels": { "type": "string", "description": "Comma-separated." },
"files": { "type": "string", "description": "Comma-separated spec file paths." },
"related_issues": { "type": "string", "description": "Comma-separated related-issue references: a local number/selector, a relative or absolute path to another issue file, or a repository URL (`host/path#id`). Stored verbatim." },
"type": { "type": "string", "description": "Issue type (e.g. `bug`, `feature`); `tix lint` checks it against `custom_types`." },
"estimate": { "type": "string" },
"cycles": { "type": "string", "description": "Comma-separated cycle names (`\"\"` clears)." },
"milestones": { "type": "string", "description": "Comma-separated milestone names (`\"\"` clears)." },
"reviewer": { "type": "string", "description": "Free-form reviewer name/identity (`\"\"` clears)." },
"start_date": { "type": "string", "description": "`YYYY-MM-DD` (`\"\"` clears)." },
"due_date": { "type": "string", "description": "`YYYY-MM-DD`; must be `>= start_date` (`\"\"` clears)." },
"parent": { "type": "string", "description": "Parent issue selector — number, filename, or unique title fragment (as everywhere a selector is taken). `\"\"` clears the parent, naming no issue; omitted leaves it unchanged." },
"commit": { "type": "string", "description": "Record a commit on the issue; `\"clear\"` removes it." },
"reason": { "type": "string", "description": "Why a transition policy is being overridden by `status`. Required to move an issue OUT of a terminal category (`completed` or `canceled`) on a board that declares a `category_map`; without it the update is refused with `invalid_transition`. A blank value is not a reason and is refused. The reason is recorded in the same History decision the CLI records. Mirrors the CLI's `tix edit --reason`." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "move_issue",
description: "Move an issue to a configured column (CLI `tix move`). A commit is recorded only when passed; never prompts. Moving toward the board's done column with open blockers is refused unless `yes` is set, and records the acknowledgement in the issue History. Validated by the same transition service as the CLI, and a refusal returns the same named diagnostic.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"column": { "type": "string", "description": "A configured column." },
"commit": { "type": "string", "description": "Optional commit to record when the move reaches the board's completion column. `auto` resolves it from git history, as the CLI does." },
"reason": { "type": "string", "description": "Why a transition policy is being overridden. Required to move an issue OUT of a terminal category (`completed` or `canceled`) on a board that declares a `category_map`; without it the move is refused with `invalid_transition`. A blank value is not a reason and is refused. The reason is recorded in the issue's History. Mirrors the CLI's `tix move --reason`. A board with no `category_map` has no transition policy to override." },
"yes": { "type": "boolean", "description": "Acknowledge open blockers and proceed. Without it a move toward the done column with open blockers is refused. Mirrors the CLI's `tix move --yes`." }
},
"required": ["issue", "column"]
}),
},
ToolDescriptor {
name: "delete_issue",
description: "Delete an issue file. Irreversible; requires confirm: true.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"confirm": { "type": "boolean", "description": "Must be true to delete." }
},
"required": ["issue", "confirm"]
}),
},
ToolDescriptor {
name: "search_issues",
description: "Case-insensitive substring search over the full issue markdown (frontmatter + body + comments), same corpus and matcher as `tix search`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}),
},
ToolDescriptor {
name: "comment_on_issue",
description: "Append a comment to an issue's `## Comments` section via the model writer. Whitespace-only text is a no-op.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"author": { "type": "string" },
"text": { "type": "string" }
},
"required": ["issue", "author", "text"]
}),
},
ToolDescriptor {
name: "export_issues",
description: "Mirror local issues to GitLab or Linear via the CLI tool (`glab`/`linear-cli`); idempotent.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string", "enum": ["gitlab", "linear"] },
"project": { "type": "string", "description": "Optional `--project` override." },
"dry_run": { "type": "boolean", "description": "Do not invoke the external CLI." }
},
"required": ["target"]
}),
},
ToolDescriptor {
name: "import_issues",
description: "Pull open remote issues from GitLab or Linear via the CLI tool, or mirror a local GitHub Spec Kit project (`--target speckit`); idempotent.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string", "enum": ["gitlab", "linear", "speckit"] }
},
"required": ["target"]
}),
},
ToolDescriptor {
name: "link_issue",
description: "Create a TYPED relationship between two issues (`tix link --type`). **Breaking change (C-001): the untype symmetric form is gone and `kind` is REQUIRED** -- there is no default kind, so an untyped relationship is never created by accident. Both issues must exist; linking an issue to itself errors. `blocks` is the stored form of a blocking relationship: `blocked_by` is an accepted input spelling that normalizes into the equivalent `blocks` edge, and a reciprocal pair is rejected as a cycle.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"a": { "type": ["string", "integer"], "description": "Source issue: number, or a filename/title fragment." },
"b": { "type": ["string", "integer"], "description": "Target issue: number, or a filename/title fragment." },
"kind": {
"type": "string",
"enum": ["blocks", "blocked_by", "duplicates", "related"],
"description": "REQUIRED relation kind. The vocabulary is closed -- an unknown kind is rejected, never coerced. `blocks`: the source blocks the target (the stored form of a blocking relationship). `blocked_by`: input-only spelling, normalizes into the equivalent `blocks` edge with the endpoints swapped. `duplicates`: directional, not reordered. `related`: explicit non-directional association, stored once and never mirrored."
}
},
"required": ["a", "b", "kind"]
}),
},
ToolDescriptor {
name: "list_relationships",
description: "List typed relationships for one issue, or for the whole board (`tix link list`). Ordered by (kind, target), so the output is byte-comparable across runs and surfaces. A relationship is stored once in its canonical source's frontmatter, so this reports every relationship *touching* an issue -- including ones stored on the other endpoint.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Restrict to relationships touching this issue. Omit for every issue on the board." },
"kind": {
"type": "string",
"enum": ["blocks", "blocked_by", "duplicates", "related"],
"description": "Only relationships of this kind. `blocked_by` filters on the CANONICAL blocking edge, i.e. edges whose stored form is `blocks` -- no stored edge ever carries `blocked_by`, so filtering on it literally would match nothing."
}
}
}),
},
ToolDescriptor {
name: "edit_relationship",
description: "Retarget and/or re-kind an existing relationship by its stable id (`tix link edit`). The id SURVIVES the edit, so a relationship referenced elsewhere does not change identity. An edit naming its own current target and kind is the identity and writes nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Relationship id, as returned by list_relationships." },
"to": { "type": ["string", "integer"], "description": "New target issue. Omit to keep the current one." },
"kind": {
"type": "string",
"enum": ["blocks", "blocked_by", "duplicates", "related"],
"description": "New relation kind. Omit to keep the current one. At least one of `to` or `kind` is required."
}
},
"required": ["id"]
}),
},
ToolDescriptor {
name: "remove_relationship",
description: "Remove one relationship by its stable id (`tix link remove`). An id that is not stored is an IDEMPOTENT NO-OP: no file is written and no History entry is appended, and the call still succeeds, so it is safe to retry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Relationship id, as returned by list_relationships." }
},
"required": ["id"]
}),
},
ToolDescriptor {
name: "issue_chain",
description: "The transitive dependency chain an issue holds up, walking OUTBOUND `blocks` edges (`tix chain`). Nodes carry their issue number and display name so an agent can address them, and the order is the service's, unmodified. `truncated` is set when a traversal bound cut the result.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Root issue: number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "issue_blocked_state",
description: "Whether an issue has open blockers, which ones, and why (`tix list --show relationships`). Derived on read from the relationship service and never stored. An inbound `blocks` edge counts only if the blocker exists, is a different issue, and its status is not the board's configured done_column. An issue that is merely `related` to a blocker is NOT blocked, and neither is one whose blocker has reached the completion column. `reason` is the exact text the move warning and the detail view print.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue: number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "lint_issues",
description: "Validate frontmatter values in every issue file (same checks as `tix lint`); never crashes on malformed files. Also lints the foreign repositories reachable from `related_issues` unless `foreign` is false.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"foreign": { "type": "boolean", "description": "Also lint related repositories and merge their findings. Default true; `false` mirrors `tix lint --no-foreign`." }
}
}),
},
// ---- WP07 / T029: the hierarchy read tools -------------------------
//
// Three tools for the three derived reads in the parity contract
// (surface-parity.md §1): direct children, the parent of an issue, and
// the rollup. Named to match the existing `list_*` / `get_*` convention
// rather than the CLI's `tix parent <subcommand>`, because the registry
// is flat and one tool per subcommand would have to invent a namespace.
ToolDescriptor {
name: "list_children",
description: "List an issue's direct children, in the same order `tix parent children` prints them. Direct children only — never grandchildren. Returns the count, the ordered `(number, display)` pairs, and the exact text the CLI prints.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "get_parent",
description: "Read an issue's parent. Returns the resolved parent's number and display, or — when the recorded parent no longer exists — the retained reference plus the service's reason, which is the same text the CLI shows. An issue with no parent at all reports `parent: null` and no `unresolved` block.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "get_rollup",
description: "An issue's parent rollup: its direct-children count, how many are in the board's completion column, and the typed progress value. The figures describe direct children only (`completion_basis`), and the counts are returned as numbers rather than only as rendered text, so a caller can compare them without parsing prose.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
// ---- WP08 / T035: the batch tools ------------------------------------
//
// Three tools, not one. `bulk_preview` and `bulk_apply` are separate because
// C-004 makes a preview read-only and an apply confirmed, and a single tool
// with a `confirm` boolean makes "did the agent remember?" a question about
// the agent rather than about the protocol. A separate `bulk_capabilities`
// exists because `contracts/surface-parity.md` requires the portable
// projection to report representability, and a client that has to infer it
// from two schemas is doing the parity work this mission exists to remove.
//
// The schemas are **generated from `bulk::metadata`**, not hand-written. A
// hand-written `json!` block is a fourth description of the whitelist, and
// the previous ones already drifted: WP06's `--estimate ""` finding was
// exactly a schema and an implementation disagreeing about a blank value.
ToolDescriptor {
name: "bulk_preview",
description: "Plan a batch and return it WITHOUT writing. Read-only: no file, no History entry, no relationship. Returns the selected issues in order, the proposed before/after values and path changes, the validation diagnostics, and the per-item status every surface shares. An apply needs the same arguments plus `confirm: true`.",
input_schema: bulk_schema(false),
},
ToolDescriptor {
name: "bulk_apply",
description: "Plan a batch, confirm it, and apply it. Requires `confirm: true` — without it nothing is written and the refusal names the missing confirmation. Each selected issue is processed independently: a failed item is reported and does not roll back the successful ones. Returns the same per-item statuses as `bulk_preview`.",
input_schema: bulk_schema(true),
},
ToolDescriptor {
name: "bulk_capabilities",
description: "What a batch can do in this build: the operations, the patch whitelist (with which fields are clearable), the shared outcome vocabulary, and the selection grammar. `archive` is reported as supported-but-not-applicable until the archive service lands, so a client can tell a malformed request from an unavailable one. Takes no arguments.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
// ---- WP08 / T038: the triage tools -----------------------------------
//
// Four tools, and the write one is a **separate tool** with a required
// `confirm`, exactly as `bulk_preview`/`bulk_apply` are split. An optional
// `confirm` on a write tool is a boolean a client can forget, and a
// forgotten boolean on a write is the one thing C-004 exists to prevent.
//
// A single decision and a batch decision are **one** pair of tools rather
// than four: `issue` names one issue, `select`/`filter` name many, and
// every one of them is resolved through the same selection grammar and
// planned by the same `triage::batch::build_plan`. The TUI made the same
// choice (one modal, `ModalScope::One` or `Many`), so a client that
// learns the single case has already learned the batch one.
ToolDescriptor {
name: "triage_list",
description: "Read the triage inbox: one row per awaiting or deferred entry, with the queue age derived from each issue's entry stamp. `count: true` returns the counts without the rows; `age: true` sorts oldest-first with an unknown age last. Read-only: nothing is written, no History entry is appended. An entry with no entry stamp is reported as an unknown age, never as zero.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"age": {
"type": "boolean",
"description": "Sort oldest first. An entry with no readable entry stamp sorts last, because `unknown` is not `today`.",
},
"count": {
"type": "boolean",
"description": "Return the counts line without the rows, exactly as `tix triage list --count` does.",
},
}
}),
},
ToolDescriptor {
name: "triage_status",
description: "Read the inbox summary: the entry/deferred counts, the accepted/declined/duplicated figures, and the age histogram, all derived from one issue snapshot. Read-only. A project with no `[triage]` section has no inbox, and the refusal names the missing section.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "triage_preview",
description: "Plan one triage decision over one issue or a selection, and return it WITHOUT writing. Read-only: no file, no History entry, no relationship. Accept, decline, and defer each name the resulting status and one `history` event; a duplicate names the typed `duplicates` relation and never merges files. A missing required reason is a refused item naming `missing_reason`, not an error. An apply needs the same arguments plus `confirm: true`.",
input_schema: triage_schema(false),
},
ToolDescriptor {
name: "triage_apply",
description: "Plan a triage decision and apply it. Requires `confirm: true` — without it nothing is written and the refusal names the missing confirmation. Each selected issue is processed independently: a rejected item is reported and does not roll back the successful ones. Declining never deletes or archives an issue; the declined column is configured and recorded with the reason.",
input_schema: triage_schema(true),
},
])
}
/// The batch schema, generated from the capability projection (T035).
///
/// `confirm` is `required` on the apply and **absent** on the preview, rather than
/// optional on both. An optional `confirm` on the apply is a boolean a client can
/// forget, and a forgotten boolean on a write tool is the one thing C-004 exists to
/// prevent.
fn bulk_schema(require_confirmation: bool) -> Value {
let caps = bulk::metadata::capabilities();
let mut properties = serde_json::Map::new();
for (key, description) in [
(
"select",
"Comma-separated issue selectors. Mutually exclusive with `filter` and `search` — name exactly one grammar.",
),
(
"filter",
"The existing structured filter grammar, e.g. `status:todo label:bug`. Mutually exclusive with `select` and `search`.",
),
(
"search",
"An existing search token, e.g. `fix crash`. Mutually exclusive with `select` and `filter`.",
),
(
"scope",
"Selection scope. Defaults to the active scope, exactly as the CLI's --scope does.",
),
(
"operation",
"One of the operation names `bulk_capabilities` reports.",
),
(
"target",
"The other end of a pair. Required for `link` and `unlink`; ignored for `edit` and `archive`.",
),
(
"type",
"The relationship kind. Required for `link` and `unlink`, and validated by the relationship service's own vocabulary — not this schema's.",
),
] {
properties.insert(
key.to_string(),
json!({ "type": "string", "description": description }),
);
}
// The patch fields, from the whitelist. `status` is `not clearable` and the rest
// are, and the schema says so rather than leaving a client to try.
for field in &caps.fields {
properties.insert(
field.name.clone(),
json!({
"type": "string",
"description": format!(
"{}{}",
field.summary,
if field.clearable {
" Clearable with `clear`."
} else {
" Not clearable."
}
)
}),
);
}
properties.insert(
"clear".to_string(),
json!({
"type": "string",
"description": format!(
"Name exactly one field to empty. One per call. Clearable: {}.",
caps.fields.iter().filter(|f| f.clearable).map(|f| f.name.as_str()).collect::<Vec<_>>().join(", ")
)
}),
);
// Only `operation` is structurally required. Exactly one of
// `select`/`filter`/`search` is required too, but it is **not** expressible in
// JSON Schema as "exactly one of", and naming `select` in `required` would make a
// filter-only request a `-32602` while the shared request builder already has the
// right refusal — "name exactly one of --select, --filter or --search" — and a
// client deserves to read it rather than a schema violation.
let mut required = vec![Value::from("operation")];
if require_confirmation {
properties.insert(
"confirm".to_string(),
json!({
"type": "boolean",
"description": "Must be true. Without it nothing is written and the refusal names the missing confirmation (C-004)."
}),
);
required.push(Value::from("confirm"));
}
json!({
"type": "object",
"additionalProperties": false,
"properties": properties,
"required": required
})
}
/// The triage schema (T038): the CLI's own argument names and spellings, so a
/// client that has read `tix triage --help` can send the same request without a
/// translation table.
///
/// `confirm` is `required` on the apply and **absent** on the preview, for the
/// reason [`bulk_schema`] gives: a write tool whose confirmation is optional is a
/// write tool a client can call by accident.
///
/// The selection rule (`exactly one of issue | select | filter`) is stated in the
/// descriptions but **enforced by the shared selection builder**, not here — a
/// client that names two gets the CLI's own message rather than a schema
/// complaint, which is the parity `surface-parity.md` §Equivalence asks for.
fn triage_schema(require_confirmation: bool) -> Value {
let mut properties = json!({
"issue": {
"type": ["string", "integer"],
"description": "One issue: a number, a filename fragment, or a unique title fragment. Mutually exclusive with `select` and `filter`.",
},
"select": {
"type": "string",
"description": "Comma-separated issue selectors: numbers, filename fragments, or unique title fragments. Mutually exclusive with `issue` and `filter`.",
},
"filter": {
"type": "string",
"description": "A `tix list --filter` query selecting the issues, e.g. `status:triage`. Mutually exclusive with `issue` and `select`.",
},
"decision": {
"type": "string",
"enum": ["accept", "decline", "duplicate", "defer"],
"description": "Which decision to make. `accept` may name a target column; `decline`, `duplicate` and `defer` require a reason.",
},
"reason": {
"type": "string",
"description": "Recorded on the History event. Required for decline, duplicate and defer — a missing one is a refused item naming `missing_reason`, exactly as the CLI reports it.",
},
"actor": {
"type": "string",
"description": "Who decided. Defaults to the git identity, exactly as the CLI's `--actor` does.",
},
"to": {
"type": "string",
"description": "The accept target column, validated by the board's own transition policy. With no `to`, the configured `[triage].accepted_status` is used, and with neither the item is refused rather than guessed.",
},
"of": {
"type": "string",
"description": "The duplicate target selector, for `decision: \"duplicate\"`. Creates a typed `duplicates` relation; the two files are never merged.",
},
});
let required = if require_confirmation {
properties["confirm"] = json!({
"type": "boolean",
"description": "Required, and must be true. A triage apply writes files and appends History; without it nothing is written.",
});
json!(["decision", "confirm"])
} else {
json!(["decision"])
};
json!({
"type": "object",
"additionalProperties": false,
"properties": properties,
"required": required,
})
}
/// The descriptors serialized for the `tools/list` result (`FR-002`).
pub fn list() -> Vec<Value> {
registry()
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
})
.collect()
}
/// Required string argument. Absent + `null` → protocol error.
fn req_string(args: &Value, key: &str) -> std::result::Result<String, ()> {
let value = args.get(key).ok_or(())?;
value.as_str().map(str::to_string).ok_or(())
}
/// Optional string argument. `null` counts as absent (lenient), any other
/// non-string type is a strict `-32602` protocol error.
fn opt_string(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_str().map(|s| Some(s.to_string())).ok_or(())
}
/// Optional issue selector, accepting a number or a string.
///
/// **Not** [`opt_string`], which is for free text. The schemas for the
/// selector-taking relationship tools declare `{"type": ["string", "integer"]}`
/// and `docs/mcp.md` says so in prose, so an agent following the documented
/// schema and sending `{"issue": 0}` got a JSON-RPC `-32602` while the CLI
/// accepted the same thing — and the other selector-taking tools
/// (`issue_chain`, `issue_blocked_state`) went through [`selector`] and were
/// fine. Two tools, two rules for the same argument, on the same surface.
fn opt_selector(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
match value {
Value::Null => Ok(None),
Value::String(s) => Ok(Some(s.clone())),
Value::Number(n) => n.as_i64().map(|i| Some(i.to_string())).ok_or(()),
_ => Err(()),
}
}
/// Reject the retired `links` argument loudly, naming the typed replacements.
///
/// WP05 removed this argument because the typed model replaced it, and that
/// removal is the user-visible half of C-001. But `tools::call` only checks
/// registry membership — nothing enforces the `additionalProperties: false` every
/// schema declares — so an integration that used to pass `links` got a
/// **success** response and silently lost the relationships, while
/// `tix create --links 0` failed loudly as an unknown flag.
///
/// A silent drop is the worst of the three available behaviours: the caller
/// believes the relationships were stored, `tix link list` disagrees, and nothing
/// in between says why. Rejecting turns a data-loss bug into a message.
///
/// A targeted check, not general unknown-key rejection. General rejection is the
/// better design and is a separate change — it would alter the contract of every
/// tool at once, which is a scope call and not one to make inside a fix for a
/// silent data loss.
fn reject_retired_links(args: &Value) -> Option<Value> {
if !args.as_object().is_some_and(|o| o.contains_key("links")) {
return None;
}
Some(error_result(
"invalid input: `links` was removed \u{2014} the typed model replaces it. \
create each relationship with `link_issue` (`a`, `b`, `kind`) and remove \
an existing one with `remove_relationship` (`id`). The old argument \
*replaced* the whole set, so replacing it means one `remove_relationship` \
per existing edge plus one `link_issue` per new one"
.to_string(),
))
}
/// Optional boolean argument with the same strictness as [`opt_string`].
fn opt_bool(args: &Value, key: &str) -> std::result::Result<Option<bool>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_bool().map(Some).ok_or(())
}
/// Issue selector: accepts an integer (`#123`) or a string fragment, matching
/// `parse_selector` precedence (`contracts/mcp-tools.md` §2).
fn selector(args: &Value, key: &str) -> std::result::Result<String, ()> {
let value = args.get(key).ok_or(())?;
match value {
Value::Null => Err(()),
Value::String(s) => Ok(s.clone()),
Value::Number(n) => n.as_i64().map(|i| i.to_string()).ok_or(()),
_ => Err(()),
}
}
// ---------------------------------------------------------------------------
// T021 – structured results + domain-error mapping
// ---------------------------------------------------------------------------
/// FR-014 success shape: `content` mirror + `structuredContent`, `isError: false`.
fn success_result(text: String, structured: Value) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": structured,
"isError": false,
})
}
/// FR-014 failure shape: `isError: true`, the CLI's exact error string, and
/// **no** `structuredContent` (`contracts/mcp-tools.md` §4).
fn error_result(text: String) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"isError": true,
})
}
/// The issue object used by `create_issue`/`get_issue`/`update_issue`/
/// `move_issue`/`comment_on_issue`: the frontmatter fields plus `filename` and
/// `body` (which the derived serializer skips).
/// WP07: `issue_value` now takes the board's [`CategoryResolution`](crate::workflow::CategoryResolution).
///
/// Every call site already has a `Config` in scope, so threading it is mechanical, and
/// doing it this way means a response can **never** carry a category derived from a
/// different board than the one it was resolved against — the failure mode of a
/// helper that fetched the resolution itself.
fn issue_value(issue: &Issue, resolution: &crate::workflow::CategoryResolution) -> Value {
let mut obj = serde_json::to_value(issue).expect("Issue serialization is infallible");
obj["filename"] = Value::String(issue.filename.clone());
obj["body"] = Value::String(issue.body.clone());
// Always present, even when empty (`skip_serializing_if` omits it from the
// serialized issue; a client reading the payload should not have to tell
// "absent" from "none").
obj["related_issues"] = json!(issue.related_issues);
// The issue's resolved category, from the service (T030 step 4, FR-004). The
// **key** as well as the label: a client filtering or grouping needs the stable
// name, and the label is a display string that may be reworded.
//
// `null` for a column the board does not configure — a configuration error the
// client should be able to see, not a silent default. `tix list --show category`
// spells the same case `(unmapped)`, and a JSON field has no room for a marker
// string, so `null` plus the CLI's spelling is the honest pairing.
match crate::workflow::category_of(issue, resolution) {
Some(c) => {
obj["category"] = json!(c.key());
obj["category_label"] = json!(c.label());
}
None => {
obj["category"] = Value::Null;
obj["category_label"] = json!("(unmapped)");
}
}
obj
}
// ---------------------------------------------------------------------------
// T022/T023 – handlers
// ---------------------------------------------------------------------------
fn create_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
if let Some(value) = reject_retired_links(args) {
return Ok(value);
}
let title = req_string(args, "title")?;
let body = opt_string(args, "body")?;
let status = match opt_string(args, "status")? {
None => None,
Some(s) if s.trim().is_empty() => {
return Ok(error_result(
"invalid input: --status must not be empty".into(),
));
}
Some(s) => Some(Status::new(s)),
};
let priority = match opt_string(args, "priority")? {
None => None,
Some(p) => match parse_priority(&p) {
Ok(priority) => Some(priority),
Err(e) => return Ok(error_result(e.to_string())),
},
};
let assignee = opt_string(args, "assignee")?;
let labels = opt_string(args, "labels")?;
let files = opt_string(args, "files")?;
let related_issues = opt_string(args, "related_issues")?;
let type_ = opt_string(args, "type")?;
let estimate = opt_string(args, "estimate")?;
let cycles = opt_string(args, "cycles")?;
let milestones = opt_string(args, "milestones")?;
let reviewer = opt_string(args, "reviewer")?;
let start_date = opt_string(args, "start_date")?;
let due_date = opt_string(args, "due_date")?;
// T028. `""` clears, exactly as `--parent ""` does on the CLI, because both go
// through `commands::create`/`edit::apply` and those own the semantics. Omitted
// is `None`, which is a different thing: "leave it alone", not "set none".
let parent = opt_string(args, "parent")?;
match create::create(
config,
store,
&title,
body.as_deref(),
status,
priority,
assignee.as_deref(),
labels.as_deref(),
files.as_deref(),
estimate.as_deref(),
cycles.as_deref(),
milestones.as_deref(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
parent.as_deref(),
related_issues.as_deref(),
type_.as_deref(),
) {
Ok(issue) => Ok(success_result(
format!("Created {}", issue.filename),
issue_value(&issue, &resolution),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn list_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let status = opt_string(args, "status")?;
let filter = opt_string(args, "filter")?;
let sort = opt_string(args, "sort")?;
let group = opt_string(args, "group")?;
// MCP carries no archive flag (out of scope for issue #1492), so it lists the
// active set: archived issues are skipped without being read.
match list::query(store, status.as_deref(), filter.as_deref(), false) {
Ok(issues) => {
let mut issues = issues;
let sort_key = sort.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "sort"))
});
let group_key = group.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "group"))
});
sort_issues(&mut issues, sort_key.as_deref());
let values: Vec<Value> = issues.iter().map(|i| issue_value(i, &resolution)).collect();
// WP04 T019: the service's resolution, so MCP's text mirror renders
// `Overdue` and the group rollup through the same categories the CLI
// does. This call passed a completion column *name* while the CLI
// passed categories — the surfaces agreed only because both happened
// to resolve the same board, which is a coincidence, not a contract.
let (_, resolution) = crate::workflow::active_resolution(&store.config);
Ok(success_result(
list_text(&issues, group_key.as_deref(), &resolution),
json!({ "issues": values }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
// WP07: takes a `Config` for the category resolution. It had none, because until
// now it returned only frontmatter — which is a reason the category belongs on the
// *issue object* rather than bolted onto one handler, so that `list_issues`,
// `search_issues` and every write result carry it too.
fn get_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let raw = selector(args, "issue")?;
match view::get(store, &raw) {
Ok(issue) => {
// The **same** hierarchy block `tix view` appends, from the same
// `HierarchyFacts`. WP04 made `tix show` carry the block
// (`contracts/cli-surface.md` §2), and the parity test
// `parity_get_matches_cli_view` immediately reported the two surfaces
// disagreeing — which is what it exists for.
//
// WP07 owns the rest of the hierarchy read tools (`parent show`,
// `children`, `list`) and the `parent` field on `update_issue`. Appending
// the block here is not that work: it is the smallest change that keeps
// an existing parity guarantee true after a documented CLI change, and
// it reuses the one renderer so the two cannot drift again.
let mut contents = issue.to_file_contents();
match crate::commands::parent::source(store) {
Ok((index, issues, done_column)) => {
let facts = crate::commands::parent::facts_for(
&index,
&issues,
issue.number,
&done_column,
);
contents.push_str(&crate::commands::parent::detail_block(&facts));
}
Err(e) => return Ok(error_result(e.to_string())),
}
// The `## Workflow` block, for the same reason and by the same
// mechanism as the hierarchy block above: `tix view` appends it
// unconditionally (WP08 T036), so a client reading this text would
// otherwise see a *shorter* document than the CLI prints. The parity
// test `parity_get_matches_cli_view` reported the divergence the moment
// the block was added on the CLI side — which is what it is for.
//
// The category is *also* on the structured object (`issue_value`, WP07),
// but that is a different channel: the contract requires the text to
// mirror the CLI's stdout, and a field that only one surface carries is
// exactly the drift this keeps closing.
contents.push_str(&crate::commands::view::category_block(&issue, &resolution));
Ok(success_result(contents, issue_value(&issue, &resolution)))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn board_status(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let filter = opt_string(args, "filter")?;
let sort = opt_string(args, "sort")?;
let group = opt_string(args, "group")?;
match store.list() {
Ok(mut issues) => {
if let Some(filter) = filter.as_deref() {
// Same two steps as `tix status --filter` (WP08 T035), because this
// tool is required to mirror the CLI's counts. Going through
// `query_matches` would report **unfiltered** counts for a filter
// the client believes is applied — a summary that looks like an
// answer. A resolution failure is returned as a tool error result
// rather than with `?`, so a rejected filter is a refusal the client
// asked for and not a malfunction of the server (the same
// distinction `move_issue` draws).
let (filter, criteria) = match crate::search::resolve_filter_criteria(store, filter)
{
Ok(pair) => pair,
Err(e) => return Ok(error_result(e.to_string())),
};
let (_, resolution) = crate::workflow::active_resolution(config);
issues.retain(|i| {
crate::search::matches_issue(
&filter,
&criteria,
i,
&i.to_file_contents(),
&resolution,
)
});
}
let sort_key = sort.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "sort"))
});
let group_key = group.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "group"))
});
sort_issues(&mut issues, sort_key.as_deref());
// WP04: the service's own counting, not a second implementation of it.
let summary = status::board_for_issues(config, &issues);
let columns: Vec<Value> = summary
.columns
.iter()
.map(|(column, count)| json!({ "column": column, "count": count }))
.collect();
// WP04: the same `show_controls` / `group_key` decisions the CLI makes,
// resolved from the same control tokens, so the two renderers cannot be
// handed different inputs.
let show_controls = filter.is_some() || sort_key.is_some() || group_key.is_some();
Ok(success_result(
status_text(
config,
store,
&issues,
&summary,
show_controls,
group_key.as_deref(),
),
json!({ "columns": columns, "total": summary.total }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn update_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
if let Some(value) = reject_retired_links(args) {
return Ok(value);
}
let raw = selector(args, "issue")?;
let title = opt_string(args, "title")?;
let body = opt_string(args, "body")?;
let status = opt_string(args, "status")?;
// WP07: read here so the refusal surfaces as an MCP error result rather than a
// `?`. `?` would return a tool *error*, which the protocol treats as a
// malfunction of the server rather than a refusal the client asked for — and
// `move_issue` returns a refusal as `isError: true` with the diagnostic as its
// text, so the two tools would disagree about what a refusal looks like.
let reason = opt_string(args, "reason")?;
let priority = opt_string(args, "priority")?;
let assignee = opt_string(args, "assignee")?;
let labels = opt_string(args, "labels")?;
let files = opt_string(args, "files")?;
let related_issues = opt_string(args, "related_issues")?;
let type_ = opt_string(args, "type")?;
let estimate = opt_string(args, "estimate")?;
let cycles = opt_string(args, "cycles")?;
let milestones = opt_string(args, "milestones")?;
let reviewer = opt_string(args, "reviewer")?;
let start_date = opt_string(args, "start_date")?;
let due_date = opt_string(args, "due_date")?;
// T028. `""` clears, exactly as `--parent ""` does on the CLI, because both go
// through `commands::create`/`edit::apply` and those own the semantics. Omitted
// is `None`, which is a different thing: "leave it alone", not "set none".
let parent = opt_string(args, "parent")?;
let commit = opt_string(args, "commit")?;
// One list, two uses: the emptiness test and the sentence that names what the
// caller may pass. They were separate hand-maintained enumerations, and a
// field added to one and not the other is invisible — a caller told
// "pass at least one of ..." would not learn that a field they just added
// works, and the empty-update guard would reject a valid single-field edit.
// That is not hypothetical: `parent` was reachable in the schema and silently
// absent from both, so `update_issue {parent: "3"}` returned "no fields to
// update" for a field the tool advertised.
// `reason` is deliberately **absent**. It qualifies a `status` transition; it is
// not an editable field, so `update_issue {issue: "1", reason: "because"}` must
// still be "no fields to update" rather than silently succeeding. Adding it
// would also mean a reason alone could satisfy the guard and then be discarded.
const UPDATABLE: [&str; 17] = [
"title",
"body",
"status",
"priority",
"assignee",
"labels",
"type",
"files",
"related_issues",
"estimate",
"cycles",
"milestones",
"reviewer",
"start_date",
"due_date",
"parent",
"commit",
];
// `null` counts as absent, matching `opt_string`, which reads an explicit
// `null` as `None`. Testing only for `is_none()` would let `{"parent": null}`
// past the guard while every field still read as `None` — the call would fall
// through to `edit::apply` with nothing to do instead of being refused here.
let is_absent = |field: &str| match args.get(field) {
None | Some(Value::Null) => true,
Some(_) => false,
};
if UPDATABLE.iter().all(|field| is_absent(field)) {
return Ok(error_result(format!(
"invalid input: no fields to update — pass at least one of {}",
UPDATABLE.join(", ")
)));
}
match edit::apply(
config,
store,
&raw,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
files.as_deref(),
estimate.as_deref(),
cycles.as_deref(),
milestones.as_deref(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
// `commit` **then** `parent` — `edit::apply`'s order. Passing them the
// other way round compiles, and the compiler cannot help: both are
// `Option<&str>`. It put the parent selector into the `commit` slot and
// persisted it as a commit hash (`commit: '99'` in the issue file), so
// the parent was silently never set and no validation ran. Found by
// driving the real binary over JSON-RPC and reading the file it wrote,
// not by the type checker and not by any existing test.
commit.as_deref(),
parent.as_deref(),
// WP07: the `reason` parameter, closing the gap WP04 recorded. A client can
// now leave a terminal column through `update_issue` exactly as it can
// through `move_issue` and the CLI — the same escape on every surface, and
// the reason recorded in the same `TransitionDecision`.
//
// Passed straight through, like `move_issue`'s: no MCP-side validation and
// no default. A permissive default here would hand MCP a silent override
// nobody recorded, which the contract calls an escalation trigger.
reason.as_deref(),
related_issues.as_deref(),
type_.as_deref(),
) {
Ok(issue) => Ok(success_result(
format!("Updated {}", issue.filename),
issue_value(&issue, &resolution),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn move_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let raw = selector(args, "issue")?;
let column = req_string(args, "column")?;
let commit = opt_string(args, "commit")?;
let yes = args.get("yes").and_then(Value::as_bool).unwrap_or(false);
// Through the shared guard, not `mv::apply`. Calling the bare core here was a
// parity defect: MCP could move blocked work to done with no warning, no
// confirmation, and no History entry, while the CLI refused it. Same accept /
// reject decision on every surface is the whole of `surface-parity.md` §2,
// and this tool is a "bulk or import path" in the sense T016 step 6 names.
// WP07: the `reason` argument, closing the gap WP03 recorded and WP04 could
// not close. The seam already took an override reason; the CLI got the flag in
// WP04; this is the MCP spelling of the same thing, so a client is no longer
// refused by a rule it has no way to satisfy.
//
// Passed straight through — no MCP-side validation, no trimming, no default. The
// service trims a blank reason and refuses the move, so a client that sends
// `""` gets the same refusal the CLI gives, with the same message.
let reason = opt_string(args, "reason")?;
match mv::apply_guarded(
config,
store,
&raw,
&column,
commit.as_deref(),
reason.as_deref(),
yes,
) {
Ok(moved) => {
let mut text = format!("Moved {} to {column}", moved.issue.filename);
if moved.acknowledged {
text.push_str(" (blocked move acknowledged)");
}
Ok(success_result(text, issue_value(&moved.issue, &resolution)))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
/// A `kind` argument that must be present, reporting the diagnostic the CLI
/// would produce for the same mistake.
///
/// Three distinct failures, deliberately not collapsed:
///
/// 1. **absent** — the argument is missing, so the message names it and the four
/// accepted values. A protocol `-32602` would say only "invalid params",
/// which tells an agent nothing about the closed vocabulary it must choose
/// from, and the vocabulary *is* the contract.
/// 2. **wrong JSON type** — a type error is a protocol concern, reported as one.
/// 3. **an unknown value** — the service's own text, verbatim. This case was
/// being folded into case 1, which replaced the authoritative diagnostic with
/// a generic "must be one of" and lost the `unknown_kind` kind the CLI
/// reports — the exact drift `surface-parity.md` §2 forbids, introduced here
/// and caught by `t021_an_unknown_kind_rejects_with_the_clis_exact_text`.
///
/// The *field* in the missing case is spelled for the argument the caller used
/// (`kind`, not the CLI's `--type`); the diagnostic kind and field are otherwise
/// identical on both surfaces.
fn require_relation_kind(
args: &Value,
key: &str,
) -> std::result::Result<crate::relationships::RelationKind, Value> {
let advice = format!("pass `{key}` blocks|blocked_by|duplicates|related");
// **Absent and `null` are the same case.** An agent that sends `{"kind": null}`
// means "not provided", and it must get the "relation type required"
// diagnostic — not "must be a string", which describes a type error the caller
// did not make. An earlier version of this function split them and a `null`
// got the worse message.
let Some(value) = args.get(key).filter(|v| !v.is_null()) else {
return Err(error_result(
crate::relationships::Diagnostic::unknown_kind(
"kind",
format!("relation type required: {advice}"),
)
.to_string(),
));
};
// A non-string is a **protocol** error, not a domain one: the schema says
// string, and there is no domain reading of `kind: 7`.
let Some(raw) = value.as_str() else {
return Err(error_result(format!(
"invalid input: `{key}` must be a string naming one of blocks, blocked_by, duplicates, related"
)));
};
// The **whole** CLI rule, not just the parser: `parse_kind` on its own left the
// trim and the blank case to this function, so `" blocks "` was accepted by
// `tix link --type " blocks "` and rejected here.
//
// The advice is this surface's own, so the *decision* is shared while each
// surface names its own argument — §2 asks for "the same named field/selector",
// not for the CLI's flag spelling repeated to an agent.
crate::commands::link::require_kind_named(Some(raw), &advice)
.map_err(|e| error_result(e.to_string()))
}
/// The structured payload for one relationship.
///
/// Carries the stable id, the canonical kind, both endpoints, and the display
/// name, so an agent can act on the result with `edit_relationship` /
/// `remove_relationship` without a second round trip to recover the id.
fn relationship_value(rel: &crate::relationships::Relationship) -> Value {
json!({
"id": rel.id,
"from": rel.from,
"to": rel.to,
"kind": rel.kind.as_str(),
"display": rel.display,
})
}
/// Resolve a selector argument to an issue number, MCP-side.
///
/// The `Store` does the resolving, through the same `parse_selector` grammar the
/// CLI uses, so an ambiguous selector fails identically on both surfaces.
fn resolve_number(store: &Store, raw: &str) -> std::result::Result<u32, ()> {
store
.get(crate::commands::parse_selector(store, raw).map_err(|_| ())?)
.map(|i| i.number)
.map_err(|_| ())
}
/// Run `f` with the relationship graph over the whole store.
///
/// A closure rather than a returned `(Vec<Issue>, RelationshipGraph)` pair
/// because the graph **borrows** the issue set: the two cannot be returned
/// together. Keeping the construction here means the read tools get one graph
/// build and one read pass, and a derived read can never be computed from a
/// different snapshot than the one beside it.
///
/// The closure returns an `isError` result for a domain failure, so a
/// relationship read reports a problem rather than panicking.
fn with_graph<T, F>(store: &Store, f: F) -> std::result::Result<T, Value>
where
F: FnOnce(&crate::relationships::RelationshipGraph<'_>) -> std::result::Result<T, Value>,
{
let (issues, done_column) = store
.graph_source()
.map_err(|e| error_result(e.to_string()))?;
let graph = crate::commands::graph_over(&issues, &done_column);
f(&graph)
}
fn link_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let a = selector(args, "a")?;
let b = selector(args, "b")?;
// `kind` is REQUIRED. There is no default and no fallback to `related` --
// an untype relationship must never be creatable by omission (C-001).
let kind = match require_relation_kind(args, "kind") {
Ok(kind) => kind,
Err(result) => return Ok(result),
};
match store.link(&a, &b, kind) {
Ok(outcome) => {
// Report a real issue, and never panic inside a request handler.
// The endpoint that owns the edge is the canonical `from`, but
// reading it back is a lookup that can legitimately fail, so an
// unresolvable endpoint is an error result rather than an `expect`
// (F21). The old recovery also re-derived which endpoint owns the
// edge by trying both, which is the service's job.
let owner = outcome
.written
.as_ref()
.map(|i| i.number)
.unwrap_or(outcome.relationship.from);
let issue = match store.get(crate::storage::IssueSelector::Number(owner)) {
Ok(issue) => issue,
Err(e) => {
return Ok(error_result(format!(
"linked, but issue #{owner} could not be read back: {e}"
)));
}
};
// The **shared** wording, not a re-derived copy. This used to inline both
// branches of `link::link_message` verbatim, byte-identical — but the
// point of `surface-parity.md` §5 is that the two surfaces *cannot*
// diverge, and two copies of a format string cannot promise that. The
// CLI's own text is what an agent will read in a transcript, so it should
// be the same text rather than a lookalike.
//
// The information is the same as `tix link`'s plus the id, kind, both
// endpoints and the display name, so an agent can act on it.
let text = crate::commands::link::link_message(&outcome);
Ok(success_result(
text,
json!({
"issue": issue_value(&issue, &resolution),
"relationship": relationship_value(&outcome.relationship),
"changed": outcome.changed,
}),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn list_relationships(store: &Store, args: &Value) -> ToolOutcome {
let issue = opt_selector(args, "issue")?;
// An unresolvable selector is the repository's existing invalid-input error,
// not a protocol error -- same as the CLI's `tix link list <bad>`.
let subject = match &issue {
Some(raw) => match resolve_number(store, raw) {
Ok(number) => Some(number),
Err(()) => {
return Ok(with_number_resolution(store, raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
},
None => None,
};
// Optional, but an *invalid* value is still a rejection with the service's
// own text -- silently ignoring an unknown `--kind` would make a filter that
// matched nothing look like a board with no relationships.
let kind = match args.get("kind") {
None | Some(Value::Null) => None,
Some(Value::String(raw)) => match crate::commands::link::parse_kind(raw) {
Ok(kind) => Some(kind),
Err(e) => return Ok(error_result(e.to_string())),
},
Some(_) => {
return Ok(error_result(
"invalid input: `kind` must be a string naming one of blocks, blocked_by, duplicates, related"
.to_string(),
));
}
};
// `blocked_by` is an input spelling, never persisted. Filtering on it
// literally would match nothing and a documented filter would silently
// return an empty list, so it filters on the canonical `blocks` edge.
//
// The **shared** mapping, not a second copy: this used to be an inlined `match`
// identical to `link::canonical_filter`. `surface-parity.md` §3 puts spelling and
// direction rules in the service, and the sibling finding in the same review was
// a format that had genuinely diverged while a comment claimed otherwise (#1345).
let wanted = kind.map(crate::commands::link::canonical_filter);
let result = with_graph(store, |graph| {
let edges: Vec<&crate::relationships::Relationship> = match subject {
Some(number) => graph.relationships_for(number),
None => graph.all_relationships().iter().collect(),
};
let shown: Vec<&crate::relationships::Relationship> = edges
.into_iter()
.filter(|rel| wanted.is_none_or(|w| rel.kind == w))
.collect();
let values: Vec<Value> = shown.iter().map(|rel| relationship_value(rel)).collect();
// The **shared** renderer, not a second copy of the format. It covers both
// the rows and the empty-result wording, which this function used to spell
// out again in a `match (subject, wanted)`.
//
// The rows were already shared (`relationship_line`); the wording was not,
// and the row format had in fact diverged before that — this inlined
// `rel.display`, so a row read `#0 --blocks--> #1 blocks #1 Gamma`, with the
// kind and number twice, while `tix link list` stripped them. The tool's
// `content` was therefore not byte-comparable across surfaces, which its own
// description and `docs/mcp.md` both claimed.
let text = crate::commands::link::listing_text(&shown, subject, wanted);
Ok((text, values))
});
let (text, values) = match result {
Ok(ok) => ok,
Err(result) => return Ok(result),
};
Ok(success_result(text, json!({ "relationships": values })))
}
/// Turn a failed selector resolution into the repository's own message.
///
/// The CLI's `parse_selector` distinguishes "no match" from "ambiguous", and MCP
/// must not collapse the two -- an ambiguous selector reported as not-found
/// sends an agent looking in the wrong place. Reusing the error string keeps the
/// two surfaces saying the same thing about the same selector.
fn with_number_resolution(store: &Store, raw: &str, not_found: impl FnOnce() -> Value) -> Value {
match crate::commands::parse_selector(store, raw) {
Ok(_) => not_found(),
Err(e) => error_result(e.to_string()),
}
}
fn edit_relationship(store: &Store, args: &Value) -> ToolOutcome {
let id = req_string(args, "id")?;
let kind_arg = opt_string(args, "kind")?;
let kind = match &kind_arg {
Some(raw) => match crate::commands::link::parse_kind(raw) {
Ok(kind) => Some(kind),
Err(e) => return Ok(error_result(e.to_string())),
},
None => None,
};
let to_arg = opt_selector(args, "to")?;
let to = match &to_arg {
Some(raw) => match resolve_number(store, raw) {
Ok(number) => Some(number),
Err(()) => {
return Ok(with_number_resolution(store, raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
},
None => None,
};
if to.is_none() && kind.is_none() {
// The identity edit. Refused rather than silently no-op'd, because a
// caller who asked to change something and got "unchanged" back has been
// misled about whether the change happened.
return Ok(error_result(format!(
"invalid input: nothing to change: pass `to` and/or `kind` to edit_relationship {id}"
)));
}
match store.edit_relation(&id, to, kind) {
Ok(outcome) => {
// The **shared** wording (`link::edit_message`), not a re-derived copy.
// The no-op branch matters: it is what tells a caller that an edit
// changed nothing, and a surface that phrases it differently from the CLI
// is a surface an agent has to learn twice.
let text = crate::commands::link::edit_message(&outcome);
Ok(success_result(
text,
json!({ "relationship": relationship_value(&outcome.relationship), "changed": outcome.changed }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn remove_relationship(store: &Store, args: &Value) -> ToolOutcome {
let id = req_string(args, "id")?;
match store.remove_relation(&id) {
Ok(outcome) => {
// An absent id is a success, not an error: the outcome really did
// leave the store in the requested state, and reporting it as a
// failure would make the tool unsafe to retry.
//
// The **shared** wording (`link::remove_message`). Its no-op branch reads
// the id off `outcome.relationship`, not off the request — and
// `Store::remove_relation` populates that field with the requested id for
// the absent case, so the two are byte-identical while only one of them
// stays correct if that ever changes.
let text = crate::commands::link::remove_message(&outcome);
Ok(success_result(
text,
json!({ "relationship": relationship_value(&outcome.relationship), "changed": outcome.changed }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn issue_chain(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let root = match resolve_number(store, &raw) {
Ok(number) => number,
Err(()) => {
return Ok(with_number_resolution(store, &raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
};
let result = with_graph(store, |graph| {
// The service's order, verbatim. Re-sorting here would let MCP and the
// CLI report different chains for one snapshot.
let chain = graph.dependency_chain(root);
let paths: Vec<Value> = chain
.paths
.iter()
.map(|path| {
Value::Array(
path.iter()
.map(|node| json!({ "number": node.number, "title": node.title }))
.collect(),
)
})
.collect();
// Node numbers *and* titles: an agent has to be able to address the next
// node, and a title alone is not a selector.
let nodes: Vec<Value> = chain
.nodes()
.iter()
.map(|node| json!({ "number": node.number, "title": node.title }))
.collect();
let text = crate::commands::link::chain_output(&chain);
Ok((
text,
json!({
"root": root,
"paths": paths,
"nodes": nodes,
"truncated": chain.truncated,
}),
))
});
let (text, structured) = match result {
Ok(ok) => ok,
Err(result) => return Ok(result),
};
Ok(success_result(text, structured))
}
// ---------------------------------------------------------------------------
// WP07 / T029 — the hierarchy read tools
// ---------------------------------------------------------------------------
//
// Three derived reads, matching the three rows of the parity contract's
// operation table that are reads. Every handler goes through
// `commands::parent::source`, the same snapshot the CLI reads, and returns the
// CLI's own rendered text alongside structured values.
//
// Both, deliberately. The contract requires the same accept/reject decision, the
// same *value* (§2.5 — "not merely the same rendered text"), and the same
// derived read. A text-only tool satisfies the first and third; a numbers-only
// tool fails the "same text" half that every other MCP tool in this file keeps by
// returning the CLI's wording as its content. So the text is byte-identical and
// the numbers are present to be compared without parsing prose.
/// One snapshot of the hierarchy, plus the board's completion column.
struct HierarchySnapshot {
index: crate::hierarchy::HierarchyIndex,
issues: Vec<Issue>,
done_column: String,
}
fn hierarchy_snapshot(store: &Store) -> std::result::Result<HierarchySnapshot, ()> {
let (index, issues, done_column) = crate::commands::parent::source(store).map_err(|_| ())?;
Ok(HierarchySnapshot {
index,
issues,
done_column,
})
}
/// Resolve the required `issue` argument to a number, through the shared selector
/// parser, so a number, a filename and a title fragment all work here exactly as
/// they do on the CLI.
fn required_number(store: &Store, args: &Value) -> std::result::Result<u32, ()> {
let raw = req_string(args, "issue")?;
resolve_number(store, &raw)
}
/// The same "not in the current snapshot" refusal the CLI gives, as a tool error.
///
/// Deliberately the shared wording rather than a tool-specific one: a caller
/// comparing the two surfaces must not be able to tell them apart by a missing
/// issue.
fn missing_issue(number: u32) -> Value {
error_result(
crate::error::TixError::NotFound(format!("issue #{number} is not in the current snapshot"))
.to_string(),
)
}
fn list_children(store: &Store, args: &Value) -> ToolOutcome {
let number = match required_number(store, args) {
Ok(number) => number,
Err(()) => {
return Ok(error_result(
"invalid input: an `issue` selector is required".into(),
));
}
};
let snapshot = match hierarchy_snapshot(store) {
Ok(snapshot) => snapshot,
Err(()) => return Ok(error_result("could not read the issue set".into())),
};
if !snapshot.index.contains(number) {
return Ok(missing_issue(number));
}
// `children_text` is the CLI's own renderer, so the ordering rule (issue
// number) has one implementation rather than one per surface.
let text = match crate::commands::parent::children_text(&snapshot.index, number) {
Ok(text) => text,
Err(e) => return Ok(error_result(e.to_string())),
};
let membership = snapshot.index.children_of(number);
let children: Vec<Value> = membership
.children
.iter()
.map(|child| json!({ "number": child.number, "display": child.display }))
.collect();
Ok(success_result(
text.clone(),
json!({
"issue": number,
"direct_children_only": true,
"count": children.len(),
"children": children,
"text": text,
}),
))
}
fn get_parent(store: &Store, args: &Value) -> ToolOutcome {
let number = match required_number(store, args) {
Ok(number) => number,
Err(()) => {
return Ok(error_result(
"invalid input: an `issue` selector is required".into(),
));
}
};
let snapshot = match hierarchy_snapshot(store) {
Ok(snapshot) => snapshot,
Err(()) => return Ok(error_result("could not read the issue set".into())),
};
if !snapshot.index.contains(number) {
return Ok(missing_issue(number));
}
let facts = crate::commands::parent::facts_for(
&snapshot.index,
&snapshot.issues,
number,
&snapshot.done_column,
);
// `parent_text` is the shared single-line wording, so the unresolved case
// cannot be spelled one way here and another way in the TUI or `tix show`.
let text = crate::commands::parent::parent_text(&facts);
let resolved = facts.parent.filter(|p| snapshot.index.contains(*p));
let unresolved = facts
.unresolved_reason
.map(|reason| json!({ "parent": facts.parent, "reason": reason }));
Ok(success_result(
text.clone(),
json!({
"issue": number,
"parent": resolved,
"parent_display": facts.parent_label,
// The retained reference, kept even when the target is gone: the
// reference survives so it can be repaired, and a surface that hid it
// would make the repair invisible.
"recorded_parent": facts.parent,
"unresolved": unresolved,
"text": text,
}),
))
}
fn get_rollup(store: &Store, args: &Value) -> ToolOutcome {
let number = match required_number(store, args) {
Ok(number) => number,
Err(()) => {
return Ok(error_result(
"invalid input: an `issue` selector is required".into(),
));
}
};
let snapshot = match hierarchy_snapshot(store) {
Ok(snapshot) => snapshot,
Err(()) => return Ok(error_result("could not read the issue set".into())),
};
if !snapshot.index.contains(number) {
return Ok(missing_issue(number));
}
let facts = crate::commands::parent::facts_for(
&snapshot.index,
&snapshot.issues,
number,
&snapshot.done_column,
);
// The rendered lines are read before `rollup` is moved out of `facts`.
let text = format!("{} | {}", facts.child_count_line(), facts.completion_line());
let rollup = facts.rollup;
// The numbers, as numbers. `completion_basis` is carried as its own field
// because the figures mean something only alongside the population they
// describe — "3/5" over direct children and over the subtree are different
// numbers wearing the same clothes.
let value = json!({
"issue": number,
"parent": rollup.parent,
"direct_children": rollup.direct_children,
"completed_children": rollup.completed_children,
"progress": { "done": rollup.progress.done, "total": rollup.progress.total },
"completion_basis": rollup.completion_basis.as_str(),
"text": text,
});
Ok(success_result(text, value))
}
fn issue_blocked_state(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let number = match resolve_number(store, &raw) {
Ok(number) => number,
Err(()) => {
return Ok(with_number_resolution(store, &raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
};
let result = with_graph(store, |graph| {
let state = graph.blocked_state(number);
// `blocked_note` is the same function the CLI warning, the TUI prompt,
// the board card and the detail view use, so `reason` here is
// byte-identical to all of them (surface-parity §2).
let note = crate::commands::blocked_note(&state);
let blockers: Vec<Value> = state
.blockers
.iter()
.map(|n| json!({ "number": n.number, "title": n.title }))
.collect();
let text = note.clone().unwrap_or_else(|| "not blocked".to_string());
Ok((
text,
json!({
"issue": number,
"is_blocked": state.is_blocked,
"blockers": blockers,
"reason": state.reason,
"done_column": graph.done_column(),
}),
))
});
let (text, structured) = match result {
Ok(ok) => ok,
Err(result) => return Ok(result),
};
Ok(success_result(text, structured))
}
fn delete_issue(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
if args.get("confirm").and_then(Value::as_bool) != Some(true) {
return Ok(error_result(
"delete requires confirm=true — the issue file is untouched".into(),
));
}
let filename = match resolve_filename(store, &raw) {
Ok(f) => f,
Err(e) => return Ok(error_result(e.to_string())),
};
match delete::remove(store, &raw, true) {
Ok(()) => Ok(success_result(
format!("Deleted {filename}"),
json!({ "deleted": filename }),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn search_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let query = req_string(args, "query")?;
match search::find(store, &query) {
Ok(hits) => {
let values: Vec<Value> = hits
.iter()
.map(|hit| json!({ "issue": issue_value(&hit.issue, &resolution), "matched_lines": hit.matched_lines }))
.collect();
Ok(success_result(
search_text(&hits),
json!({ "hits": values }),
))
}
Err(e) => Ok(error_result(e.to_string())),
}
}
fn comment_on_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let raw = selector(args, "issue")?;
let author = req_string(args, "author")?;
let text = req_string(args, "text")?;
let outcome = (|| -> TixResult<Issue> {
let selector = parse_selector(store, &raw)?;
let mut issue = store.get(selector)?;
issue.add_comment(&author, &text);
store.update(&issue.filename, &issue)?;
Ok(issue)
})();
match outcome {
Ok(issue) => Ok(success_result(
format!("Commented on {}", issue.filename),
issue_value(&issue, &resolution),
)),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn export_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let target = req_string(args, "target")?;
let dry_run = opt_bool(args, "dry_run")?.unwrap_or(false);
let project = opt_string(args, "project")?;
match export::run_core(config, store, Some(&target), dry_run, project.as_deref()) {
Ok(report) => Ok(success_result(report.clone(), json!({ "report": report }))),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn import_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let target = req_string(args, "target")?;
match import::run_core(config, store, Some(&target)) {
Ok(report) => Ok(success_result(report.clone(), json!({ "report": report }))),
Err(e) => Ok(error_result(e.to_string())),
}
}
fn lint_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let foreign = args.get("foreign").and_then(Value::as_bool).unwrap_or(true);
let findings = match lint::collect_with(config, store, foreign) {
Ok(f) => f,
Err(e) => return Ok(error_result(e.to_string())),
};
let count = match store.list() {
Ok(issues) => issues.len(),
Err(e) => return Ok(error_result(e.to_string())),
};
let values: Vec<Value> = findings
.iter()
.map(|f| {
json!({
"file": f.file,
"line": f.line,
"column": f.column,
"kind": f.kind,
"value": f.value,
"message": f.message,
})
})
.collect();
Ok(success_result(
lint_text(count, &findings),
json!({ "findings": values }),
))
}
// ---------------------------------------------------------------------------
// text mirrors (byte-identical to the CLI's stdout rendering, NFR-005)
// ---------------------------------------------------------------------------
/// `tix list` table text.
fn list_text(
issues: &[Issue],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
) -> String {
list::render_default_table(issues, group, resolution, 0)
}
/// `tix status` table text.
/// `tix status` table text.
///
/// WP04: delegates to [`status::render`] instead of keeping a second copy of the
/// table. This function used to reimplement the whole rendering — column widths, the
/// `Overdue` count, the group rollup, the cycle/milestone progress — and compared
/// issues against a `done_col` **string** while the CLI compared categories. The two
/// copies agreed only because both happened to resolve the same board; the moment
/// the CLI's label changed to `complete` and this one did not, the parity test in
/// `tests/mcp.rs` failed. A duplicated renderer does not stay in sync, and this is
/// what that costs.
fn status_text(
config: &Config,
store: &Store,
issues: &[Issue],
summary: &status::BoardSummary,
show_controls: bool,
group: Option<&str>,
) -> String {
status::render(config, store, issues, summary, show_controls, group)
}
const MAX_LINE_WIDTH: usize = 200;
/// Truncate a matched context line (same as `search::run`).
fn clipped(line: &str) -> String {
if line.chars().count() <= MAX_LINE_WIDTH {
return line.to_string();
}
let head: String = line.chars().take(MAX_LINE_WIDTH).collect();
format!("{head}…")
}
/// `tix search` result text (empty query yields `No issues found.`).
fn search_text(hits: &[SearchHit]) -> String {
if hits.is_empty() {
return "No issues found.\n".to_string();
}
let mut out = String::new();
for hit in hits {
out.push_str(&format!(
"#{} {} — {} ({})\n",
hit.issue.number, hit.issue.title, hit.issue.status, hit.issue.filename
));
for line in &hit.matched_lines {
out.push_str(&format!(" {}\n", clipped(line)));
}
}
out
}
fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
match sort.unwrap_or("created") {
"due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
"start" => issues.sort_by(|a, b| {
a.start_date
.cmp(&b.start_date)
.then(a.number.cmp(&b.number))
}),
"reviewer" => {
issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
}
"cycle" => issues.sort_by(|a, b| {
a.cycles
.as_ref()
.and_then(|v| v.first())
.cmp(&b.cycles.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"milestone" => issues.sort_by(|a, b| {
a.milestones
.as_ref()
.and_then(|v| v.first())
.cmp(&b.milestones.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"status" => issues.sort_by(|a, b| {
a.status
.display()
.cmp(b.status.display())
.then(a.number.cmp(&b.number))
}),
"priority" => issues.sort_by(|a, b| {
a.priority
.to_string()
.cmp(&b.priority.to_string())
.then(a.number.cmp(&b.number))
}),
_ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
}
}
/// `tix lint` text: the CLI's clean line, or a count line plus one
/// `file:line:col kind value message` line per finding
/// (`contracts/mcp-tools.md` §3.12).
fn lint_text(n_issues: usize, findings: &[lint::TixIssueFinding]) -> String {
// WP06: the summary line now follows the **CLI's** rule, not "any finding at
// all". `mcp-tools.md` §4 says tool text "mirrors the CLI's stdout rendering …
// so a human reading tool text sees identical facts", and before this change the
// two disagreed: the CLI said `Lint OK with 3 warnings` for a board relying on
// the safe default, while this said `Checked 1 issues; 3 finding(s)` — the same
// store, two different verdicts, one of which implied a problem.
//
// It is only visible now because WP06's `unmapped_column_default` is a
// `Severity::Warning` and is *reported*. Before that, `findings.is_empty()` and
// "no errors" were the same predicate on every real store, so the divergence had
// nothing to show itself on.
let errors = findings
.iter()
.filter(|f| f.severity == lint::Severity::Error)
.count();
if errors == 0 {
let warnings = findings.len();
return if warnings == 0 {
format!("Lint OK: {n_issues} issues checked.\n")
} else {
format!(
"Lint OK with {warnings} warning{}: {n_issues} issues checked.\n",
if warnings == 1 { "" } else { "s" }
)
};
}
// Errors: one line per finding, in the format §3.12 pins. No count line, because
// the CLI does not print one in this case either — the diagnostics speak for
// themselves and a count above them is a second thing to keep in step.
let mut out = String::new();
for f in findings
.iter()
.filter(|f| f.severity == lint::Severity::Error)
{
out.push_str(&format!(
"{}:{}:{} {} {} {}\n",
f.file, f.line, f.column, f.kind, f.value, f.message
));
}
out
}
/// Resolve an issue's filename for a selector (used to report the deleted
/// path before it is gone).
fn resolve_filename(store: &Store, raw: &str) -> TixResult<String> {
let selector = parse_selector(store, raw)?;
let issue = store.get(selector)?;
Ok(issue.filename)
}
// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------
/// Handle a `tools/call` for one of the registered tools.
///
/// - unknown tool name → `-32601 Method not found`;
/// - strict argument type mismatch → `-32602 Invalid params`;
/// - everything else returns the *tool result* (success or `isError`), never
/// a JSON-RPC error.
pub fn call(
config: &Config,
store: &Store,
id: protocol::Id,
name: &str,
args: &Value,
) -> std::result::Result<Value, protocol::ErrorResponse> {
if !registry().iter().any(|t| t.name == name) {
return Err(protocol::method_not_found(id));
}
let arguments = if args.is_null() { &Value::Null } else { args };
// `dispatch` returns `None` for a name that is DECLARED but has no arm --
// the one failure mode a `match` over names can have that the registry
// membership check above cannot catch. That used to be `unreachable!`, which
// turned a registration mistake into a server panic on the first call, and
// made the mistake invisible until an agent happened to try that tool.
match dispatch(config, store, name, arguments) {
Some(outcome) => outcome.map_err(|()| protocol::invalid_params(id)),
None => Ok(error_result(format!(
"internal error: tool `{name}` is declared in the registry but has no dispatch arm"
))),
}
}
/// `bulk_capabilities` (T037): the portable projection, verbatim.
///
/// A tool rather than a field on the other two because `surface-parity.md` says the
/// remote projection "may report representability" — and a client that has to infer
/// it by trial and error is doing the parity work this mission exists to remove. The
/// payload is [`bulk::metadata::capabilities_json`] itself, so a client sees exactly
/// what the schema was generated from and the two cannot disagree.
fn bulk_capabilities(_config: &Config, _store: &Store, _args: &Value) -> ToolOutcome {
let caps = bulk::metadata::capabilities_json();
let mut lines = vec!["Bulk operations available in this build:".to_string()];
for op in bulk::metadata::BulkOperation::ALL {
lines.push(format!(
" {} — {} [{}]",
op.as_str(),
op.summary(),
op.availability().replace('_', " ")
));
}
lines.push("Patch fields:".to_string());
for field in bulk::metadata::PATCH_FIELDS {
lines.push(format!(
" {} — {}{}",
field.name,
field.summary,
if field.clearable { " (clearable)" } else { "" }
));
}
lines.push(format!(
"Outcome statuses: {}",
bulk::metadata::OUTCOME_STATUSES.join(", ")
));
lines.push("Selection grammar:".to_string());
for line in bulk::metadata::SELECTION_GRAMMAR {
lines.push(format!(" {line}"));
}
Ok(success_result(lines.join("\n"), caps))
}
/// The `PatchFlags` a batch request carries, read from the shared field names.
///
/// Reads **only** names the projection lists, and the schema's `additionalProperties:
/// false` is what stops anything else arriving. The check is repeated here anyway,
/// because a schema is documentation a client may ignore and this is the boundary
/// that has to hold (C-008: an unlisted field mutation is an escalation trigger).
///
/// The error type is [`ToolOutcome`], so a malformed argument and a refused request
/// are different **types** rather than two spellings of the same one: `Err(())` is a
/// `-32602` and `Ok(error_result(..))` is a tool result. Getting that wrong is the
/// difference between an agent reading "name exactly one of --select, --filter or
/// --search" and an agent seeing a protocol failure with no explanation.
fn bulk_flags(args: &Value) -> ToolOutcome2<cmd_bulk::PatchFlags> {
let mut flags = cmd_bulk::PatchFlags::default();
for (key, slot) in [
("status", &mut flags.status),
("priority", &mut flags.priority),
("assignee", &mut flags.assignee),
("labels", &mut flags.labels),
("start-date", &mut flags.start_date),
("due-date", &mut flags.due_date),
("estimate", &mut flags.estimate),
("cycles", &mut flags.cycles),
("milestones", &mut flags.milestones),
] {
if let Err(problem) = bulk::metadata::check_field(key) {
return Err(refuse(problem.to_string()));
}
let value = opt_string(args, key).map_err(|()| proto())?;
// A blank value is **absent**, exactly as the CLI treats `--estimate ""`:
// `Unchanged`, never `Clear` and never `Set("")`. WP06 found that rule the
// hard way and it is the one thing an MCP client is most likely to get wrong.
*slot = value.filter(|v| !v.trim().is_empty());
}
if let Some(clear) = opt_string(args, "clear").map_err(|()| proto())? {
let field = clear.trim().to_string();
if !field.is_empty() {
if let Err(problem) = bulk::metadata::check_field(&field) {
return Err(refuse(problem.to_string()));
}
if !cmd_bulk::PatchFlags::is_clearable(&field) {
return Err(refuse(format!(
"cannot clear '{field}' — clearable fields: {}",
bulk::metadata::clearable_field_names().join(", ")
)));
}
flags.clear = vec![field];
}
}
Ok(flags)
}
/// The selection request, built by the **shared** builder so the refusal is the
/// CLI's message rather than this handler's.
fn bulk_request(args: &Value) -> ToolOutcome2<cmd_bulk::SelectionRequest> {
let selectors = opt_string(args, "select").map_err(|()| proto())?;
let filter = opt_string(args, "filter").map_err(|()| proto())?;
let search = opt_string(args, "search").map_err(|()| proto())?;
let scope = match opt_string(args, "scope").map_err(|()| proto())?.as_deref() {
None | Some("") => bulk::SelectionScope::Active,
// The scope names the CLI's `--scope` accepts, spelled the same way. A wrong
// name is refused rather than silently becoming `Active`, because the two
// non-default scopes are how a user reaches archived issues and defaulting
// would quietly narrow their request.
Some("all") => bulk::SelectionScope::ActiveAndArchived,
Some("archived") => bulk::SelectionScope::ArchivedOnly,
Some(other) => {
return Err(refuse(format!(
"unknown scope '{other}' — `active` (default), `all` (active and \
archived), or `archived`"
)));
}
};
let selectors = selectors
.map(|raw| raw.split(',').map(str::to_string).collect::<Vec<_>>())
.unwrap_or_default();
// The **shared** builder, so a client that names zero or two grammars reads the
// CLI's own message rather than a schema's.
cmd_bulk::SelectionRequest::new(&selectors, filter.as_deref(), search.as_deref(), scope)
.map_err(|e| refuse(e.to_string()))
}
/// One selected issue's pair outcome, as a surface reports it.
///
/// A **projection**, not an apply type. A pair writes two files and a field edit
/// writes one, so there is no single `ItemOutcome` that describes both honestly —
/// and the point of the row is that the *status word* is the shared part, not the
/// payload.
struct PairRow {
number: u32,
title: String,
/// From `PairOutcome::status` or one of the plan-side words above; the same
/// vocabulary `ItemOutcome::status` uses.
status: &'static str,
line: String,
/// Both ends for a pair, empty for a refusal or a no-op.
files: Vec<String>,
}
/// The plan as JSON: selection order, proposed changes, paths, diagnostics.
///
/// Every field `surface-parity.md` §1 lists, and nothing invented — a key here that
/// the CLI does not print is a second contract.
fn bulk_plan_value(plan: &bulk::plan::BatchPlan, applied: bool) -> Value {
json!({
"applied": applied,
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"items": plan.items.iter().map(|item| json!({
"number": item.number,
"title": item.title,
"status": item.status(),
"changes": item.changes.iter()
.map(|(field, change)| {
// `as_from_to` rather than a match here, so a `Clear` and a
// `NoChange` project the same way on every surface.
let (from, to) = change.as_from_to();
json!({ "field": field, "from": from, "to": to })
})
.collect::<Vec<_>>(),
"path_change": item.path_change.as_ref().map(|p| json!({
"from": p.from,
"to": p.to,
})),
"history": item.history.iter()
.map(|h| json!({ "kind": h.kind, "detail": h.detail }))
.collect::<Vec<_>>(),
"diagnostic": item.diagnostic.as_ref().map(|d| json!({
"issue": d.issue,
"field": d.field,
"message": d.message,
})),
})).collect::<Vec<_>>(),
})
}
/// The report as JSON: exactly one outcome per selected issue (NFR-002).
fn bulk_report_value(report: &bulk::ApplyReport, plan: &bulk::plan::BatchPlan) -> Value {
let titles: Vec<(u32, String)> = plan
.items
.iter()
.map(|i| (i.number, i.title.clone()))
.collect();
json!({
"applied": report.applied(),
"no_op": report.noop(),
"rejected": report.rejected(),
"failed": report.failed(),
"outcomes": report.outcomes.iter().map(|(number, outcome)| {
let title = titles.iter().find(|(n, _)| n == number)
.map(|(_, t)| t.as_str()).unwrap_or("");
json!({
"number": number,
"title": title,
// The shared vocabulary word, from `ItemOutcome::status` — not a
// re-derived string, or NFR-003 would be a claim about this handler.
"status": outcome.status(),
"line": outcome.as_line(*number, title),
})
}).collect::<Vec<_>>(),
})
}
/// `bulk_preview` / `bulk_apply` (T036): the shared plan and apply, projected to JSON.
fn bulk_run(store: &Store, args: &Value, apply: bool) -> ToolOutcome {
if apply {
// C-004. Checked **before** anything is resolved, so a client that forgot the
// confirmation gets the refusal without having paid for a 10,000-issue plan.
match opt_bool(args, "confirm") {
Ok(Some(true)) => {}
Ok(_) => {
return Ok(error_result(
"confirm is required and must be true — a batch apply writes files \
and appends History, so nothing is written without it (C-004). Call \
bulk_preview first; it is read-only."
.to_string(),
));
}
Err(()) => return Err(()),
}
}
// An absent or non-string `operation` is a **protocol** error (`-32602`), the
// same treatment `req_string` gives every other required argument. A well-formed
// but unknown operation is a **tool** result, because the client sent well-formed
// JSON and deserves a message it can read rather than a protocol failure.
let operation_raw = match req_string(args, "operation") {
Ok(raw) => raw,
Err(()) => return Err(()),
};
let operation_raw = operation_raw.trim().to_string();
if let Err(problem) = bulk::metadata::check_operation(&operation_raw) {
return Ok(error_result(problem.to_string()));
}
let operation = bulk::metadata::BulkOperation::from_name(&operation_raw)
.expect("check_operation accepted it, so from_name did too");
// Whether the build can **perform** it is decided at the apply, not here: the
// refusal has to name every selected issue, and that needs the plan. Refusing at
// the request boundary would produce a message about a mission with no issue in
// it — the one thing WP05's CLI explicitly avoids.
// The helpers return a finished `ToolOutcome` on refusal, so this is one `?`:
// a protocol error stays a protocol error and a refusal stays a tool result,
// because the helper already chose which.
// The helpers return a **finished** `ToolOutcome` on refusal, so this `?` cannot
// flatten the two kinds: `Err(())` stays a protocol error and
// `Ok(error_result(..))` comes back as a tool result, because the helper already
// chose which and the handler returns it untouched.
let (request, flags) = match (bulk_request(args), bulk_flags(args)) {
(Ok(request), Ok(flags)) => (request, flags),
// The **first** refusal wins. Running both and reporting the second would let
// a client see a schema complaint about a field when the real problem is that
// it named two selection grammars.
(Err(outcome), _) | (_, Err(outcome)) => return outcome,
};
let selection = match cmd_bulk::resolve(store, &request) {
Ok(selection) => selection,
Err(e) => return Ok(error_result(e.to_string())),
};
let patch = match operation {
bulk::metadata::BulkOperation::Edit => match flags.to_patch() {
Ok(patch) => patch,
Err(e) => return Ok(error_result(e.to_string())),
},
bulk::metadata::BulkOperation::Link | bulk::metadata::BulkOperation::Unlink => {
let target = opt_string(args, "target")?;
let Some(target) = target.filter(|t| !t.trim().is_empty()) else {
return Ok(error_result(
"a pair needs a target — `target` is required for link and unlink".to_string(),
));
};
// The kind is resolved **once**, through the relationship service's own
// parser, exactly as the CLI's `run_pair` does. A missing or unknown kind
// is therefore the service's message and not this handler's, which is the
// parity `surface-parity.md` asks for. Resolving it per issue would report
// the same refusal ten times for a ten-issue batch.
let kind =
match crate::commands::link::require_kind(opt_string(args, "type")?.as_deref()) {
Ok(kind) => kind,
Err(e) => return Ok(error_result(e.to_string())),
};
// Per selected issue: `from` is a single issue in the relationship
// vocabulary, so a batch is a set of edges rather than one edge with a
// compound endpoint. The same rule the TUI follows.
//
// And each is planned and applied on its own, so a mixed result is a set
// of per-item statuses rather than one verdict for the batch (C-005).
let mut outcomes: Vec<PairRow> = Vec::new();
for issue in selection.issues() {
let op = if operation == bulk::metadata::BulkOperation::Unlink {
bulk::RelationshipOp::unlink(issue.number.to_string(), target.clone(), kind)
} else {
bulk::RelationshipOp::link(issue.number.to_string(), target.clone(), kind)
};
let one = bulk::Selection::from_issues(
vec![issue.clone()],
bulk::SelectionSource::Explicit,
bulk::SelectionScope::Active,
);
let per_item = cmd_bulk::build(
store,
one,
bulk::IssuePatch {
relationship: Some(op),
..bulk::IssuePatch::default()
},
)
.map(|mut plan| {
plan.confirmation = apply.then(|| bulk::Confirmation::issue(&plan));
plan
});
match per_item {
// A pair is planned and applied per item, so a mixed result is a
// set of per-item statuses rather than one verdict for the batch
// (C-005). A preview reports what each pair *would* do, from the
// plan it built and nothing else — which is what keeps a preview
// read-only (C-004).
Ok(plan) if apply => match bulk::apply_pair(store, &plan) {
Ok(outcome) => outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status: outcome.status(),
line: outcome.as_line(),
files: outcome.files().to_vec(),
}),
// A write that failed is a **failed** status with the error
// attached, not a refusal: the attempt happened. The diagnostic
// is built rather than invented — `field: "target"` and
// `issue:` are the plan's own, so a client sees the same shape
// a field apply produces.
Err(e) => outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status: "failed",
line: format!(
"#{} {} -> failed (target): {e}",
issue.number, issue.title
),
files: Vec::new(),
}),
},
Ok(plan) => {
let (status, line) =
match plan.items.first().and_then(|i| i.diagnostic.as_ref()) {
Some(d) => ("rejected", d.message.clone()),
None if plan
.items
.first()
.is_some_and(bulk::plan::ItemPlan::is_actionable) =>
{
(
"applied",
format!("#{} {} -> would apply", issue.number, issue.title),
)
}
None => (
"no-op",
format!(
"#{} {} -> already in that state",
issue.number, issue.title
),
),
};
outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status,
line,
files: Vec::new(),
});
}
Err(e) => outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status: "rejected",
line: format!("#{} {} -> refused: {e}", issue.number, issue.title),
files: Vec::new(),
}),
}
}
// No batch `ApplyReport` is built here, and deliberately: a pair apply has
// its own outcome type because a pair writes **two** files, so forcing it
// into `ApplyReport` would produce a report whose `write_failures` is
// always empty and whose `Applied` carries one filename for two writes.
// `PairRow` is the projection — one row per selected issue, `status` from
// the same vocabulary — and it is what both the text and the JSON read.
let applied = outcomes.iter().filter(|r| r.status == "applied").count();
let no_op = outcomes.iter().filter(|r| r.status == "no-op").count();
let rejected = outcomes.iter().filter(|r| r.status == "rejected").count();
let failed = outcomes.iter().filter(|r| r.status == "failed").count();
let structured = json!({
"applied": applied,
"no_op": no_op,
"rejected": rejected,
"failed": failed,
"operation": operation.as_str(),
"outcomes": outcomes.iter().map(|r| json!({
"number": r.number,
"title": r.title,
"status": r.status,
"line": r.line,
// Both ends, because a pair is two named endpoints and a report
// that names one of them is a flattened write with extra steps.
"files": r.files,
})).collect::<Vec<_>>(),
});
let mut text = format!(
"{} issue(s) selected for {}\n",
outcomes.len(),
operation.as_str()
);
for row in &outcomes {
text.push_str(&format!("{}\n", row.line));
}
text.push_str(&format!(
"\n{applied} applied, {rejected} refused, {no_op} no-op, {failed} failed\n"
));
return Ok(success_result(text, structured));
}
bulk::metadata::BulkOperation::Archive => bulk::IssuePatch {
archive: Some(bulk::ArchiveRequest::archive()),
..bulk::IssuePatch::default()
},
};
let plan = match cmd_bulk::build(store, selection, patch) {
Ok(plan) => plan,
Err(e) => return Ok(error_result(e.to_string())),
};
if !apply {
return Ok(success_result(
cmd_bulk::render_plan(&plan, cmd_bulk::Mode::Preview),
bulk_plan_value(&plan, false),
));
}
// The apply-time refusal, once the plan exists, so it can name what it refused.
if let Err(problem) = bulk::metadata::check_applicable(operation) {
let mut text = cmd_bulk::render_plan(&plan, cmd_bulk::Mode::Apply);
for issue in plan.selection.issues() {
text.push_str(&format!(
"#{} {} — refused: {problem}\n",
issue.number, issue.title
));
}
let structured = json!({
"applied": false,
"operation": operation.as_str(),
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"outcomes": plan.selection.issues().iter().map(|i| json!({
"number": i.number,
"title": i.title,
"status": bulk::metadata::OUTCOME_STATUSES[2],
"line": format!("#{} {} — refused: {problem}", i.number, i.title),
})).collect::<Vec<_>>(),
});
return Ok(success_result(text, structured));
}
let mut confirmed = plan.clone();
confirmed.confirmation = Some(bulk::Confirmation::issue(&plan));
let text = match operation {
bulk::metadata::BulkOperation::Archive => {
// The archive delegation, through the same seam the CLI uses. A refusal is
// a **completed** command that reports non-zero, so it is reported per
// selected issue and the plan is printed first — a client that sent a
// 10-issue batch must be able to tell which 10 it was about.
let mut text = cmd_bulk::render_plan(&plan, cmd_bulk::Mode::Apply);
match bulk::apply_archive(&confirmed) {
Ok(diagnostic) => {
for issue in plan.selection.issues() {
text.push_str(&format!(
"#{} {} — refused: {diagnostic}\n",
issue.number, issue.title
));
}
text
}
Err(e) => {
for issue in plan.selection.issues() {
text.push_str(&format!(
"#{} {} — refused: {e}\n",
issue.number, issue.title
));
}
text
}
}
}
_ => {
let report = match bulk::apply_fields(
store,
&confirmed,
&crate::storage::actor_identity(),
None,
) {
Ok(report) => report,
Err(e) => return Ok(error_result(e.to_string())),
};
let structured = bulk_report_value(&report, &confirmed);
let text = cmd_bulk::render_report(&report, &confirmed, cmd_bulk::Mode::Apply);
return Ok(success_result(text, structured));
}
};
// The archive refusal path: the diagnostics, as a structured list too, so a
// client does not have to parse the prose to learn it failed.
let structured = json!({
"applied": false,
"operation": operation.as_str(),
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"outcomes": plan.selection.issues().iter().map(|i| json!({
"number": i.number,
"title": i.title,
"status": bulk::metadata::OUTCOME_STATUSES[2],
})).collect::<Vec<_>>(),
});
Ok(success_result(text, structured))
}
/// The scope refusal every triage surface shares.
///
/// The CLI's `commands::triage::scope_of` is private to that module, and the TUI
/// carries its own spelling for the same reason (`ui::triage::scope_of`), so this
/// is the third copy of one sentence — which is exactly why a test pins all three
/// together: `mcp_triage_without_a_section_refuses_with_the_clis_own_message`
/// compares this constant with what `tix triage list` prints for the same project,
/// so the copies cannot drift silently.
const NO_INBOX: &str =
"`tix triage` needs a `[triage]` section in tix.toml, and this project has none";
/// Resolve the inbox scope, or the refusal every triage view shares.
///
/// The refusal is rendered through [`crate::error::TixError`] rather than returned
/// as a bare sentence, so it carries the same `invalid input: …` prefix every other
/// refusal on this surface carries — and is then byte-equal to what the CLI's own
/// `scope_of` produces from the same constant, which is what
/// `t041_the_no_inbox_refusal_is_the_clis_own_sentence` asserts.
fn triage_scope(config: &Config) -> std::result::Result<inbox::InboxScope, ToolOutcome> {
match inbox::scope(config) {
Ok(Some(scope)) => Ok(scope),
Ok(None) => Err(Ok(error_result(
crate::error::TixError::InvalidInput(NO_INBOX.to_string()).to_string(),
))),
Err(e) => Err(Ok(error_result(e.to_string()))),
}
}
/// `triage_list` (T038/T041): the queue, with the CLI's own text and a structured
/// mirror of the same one snapshot.
///
/// The text comes from `commands::triage::render_list` — the exact string
/// `tix triage list` prints — so a client that logs MCP output and CLI output sees
/// one document rather than two descriptions of one queue (NFR-003). The
/// structured value is derived from the same [`inbox::collect`] pass the renderer
/// made, over the same corpus and the same `today`.
fn triage_list(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let by_age = match opt_bool(args, "age") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Err(()),
};
let counts_only = match opt_bool(args, "count") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Err(()),
};
// The refusal comes **first**, and from one place. A project with no `[triage]`
// section has no inbox on any triage surface, and
// `mcp_triage_without_a_section_refuses_with_the_clis_own_message` compares this
// sentence with what `tix triage list` prints, byte for byte.
let scope = match triage_scope(config) {
Ok(scope) => scope,
Err(outcome) => return outcome,
};
let text = match cmd_triage::render_list(store, by_age, counts_only) {
Ok(text) => text,
Err(e) => return Ok(error_result(e.to_string())),
};
let corpus = match crate::search::Corpus::build(store) {
Ok(corpus) => corpus,
Err(e) => return Ok(error_result(e.to_string())),
};
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
let mut rows: Vec<&inbox::QueueEntry<'_>> = metrics.entries.iter().collect();
if by_age {
// The CLI's `--age` rule, restated rather than re-invented: `render_list`'s
// comparator is private to `commands::triage`, exactly as `ui::triage`'s copy
// is, so this is the third spelling of one ordering. Oldest first, and an
// unknown age **last** on both ends rather than coerced to `today`.
//
// The copy is safe because it is *checked*:
// `mcp_triage_list_prints_the_clis_own_queue_and_counts` compares this
// structure's row order with the order of the CLI's own rendered rows, so a
// comparator that drifted from the CLI's would fail there rather than quietly
// returning the text in one order and the data in another.
rows.sort_by(|a, b| match (a.age_days, b.age_days) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
}
let structured = json!({
"counts": triage_counts_value(&metrics),
// `count: true` asks for the counts without the rows, and this returns the
// same thing the text does — a client that asked not to receive 10,000 rows
// does not receive them in `structuredContent` either.
"entries": if counts_only {
json!([])
} else {
triage_entries_value(&rows)
},
});
Ok(success_result(text, structured))
}
/// `triage_status` (T038/T041): the summary, CLI text plus its structured mirror.
fn triage_status(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let scope = match triage_scope(config) {
Ok(scope) => scope,
Err(outcome) => return outcome,
};
let text = match cmd_triage::render_status(store) {
Ok(text) => text,
Err(e) => return Ok(error_result(e.to_string())),
};
let corpus = match crate::search::Corpus::build(store) {
Ok(corpus) => corpus,
Err(e) => return Ok(error_result(e.to_string())),
};
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
Ok(success_result(
text,
json!({ "counts": triage_counts_value(&metrics) }),
))
}
/// The counts and histogram, as one object (FR-005).
///
/// Every figure is `inbox::collect`'s — `accepted` is the completion reading and
/// `declined` is the `canceled` category, both through #183 — so this cannot drift
/// from `tix triage status` or from `tix status` (C-002).
fn triage_counts_value(metrics: &inbox::QueueMetrics<'_>) -> Value {
json!({
"total": metrics.total,
"awaiting": metrics.awaiting,
"deferred": metrics.deferred,
"accepted": metrics.accepted,
"declined": metrics.declined,
"duplicated": metrics.duplicated,
"unknown_age": metrics.unknown_age,
"age": metrics.age_distribution.iter()
.map(|bucket| json!({ "bucket": bucket.label, "count": bucket.count }))
.collect::<Vec<_>>(),
})
}
/// The queue rows, in the order the query returned them (FR-001, FR-005).
///
/// `entered_at` is the stamp the age was derived from and is `null` exactly when
/// `age_days` is — an entry nobody can age says so twice rather than reporting an
/// age of zero (data-model §2).
fn triage_entries_value(entries: &[&inbox::QueueEntry<'_>]) -> Value {
Value::Array(
entries
.iter()
.map(|entry| {
let issue = entry.issue;
json!({
"number": issue.number,
"title": issue.title,
"status": issue.status.display(),
"age_days": entry.age_days,
"entered_at": match entry.entered_at {
crate::triage::EntryTime::Recorded(at) => json!(at),
crate::triage::EntryTime::Unknown => Value::Null,
},
"source": crate::triage::entry_source(issue),
// Three answers, not two: no decision (`null`), the decision
// (`{…}`), and a recorded line that will not re-parse
// (`"unreadable"`). The last is the diagnostic a maintainer
// needs — collapsing it into `null` said "nobody has decided
// this" about a file that carries a decision (tix #1494).
"last_decision": match crate::triage::history::last_decision_state(issue) {
crate::triage::history::LastDecision::Readable(record) => json!({
"kind": record.kind.as_str(),
"prior_state": record.prior_state,
"result": record.result,
"reason": record.reason,
"actor": record.actor,
"timestamp": record.timestamp,
"target_issue": record.target_issue,
}),
crate::triage::history::LastDecision::Unreadable => json!("unreadable"),
crate::triage::history::LastDecision::None => Value::Null,
},
})
})
.collect(),
)
}
/// The selection a triage request names: one issue, a selector list, or a filter.
///
/// Three grammars, exactly one allowed — the rule `tix triage batch` enforces
/// through the same builder, so two named grammars produce the CLI's own message
/// rather than a schema complaint.
fn triage_selection(store: &Store, args: &Value) -> ToolOutcome2<bulk::Selection> {
// Blank counts as absent, for all three: a whitespace-only `select` is not a
// grammar a client named, and letting it through hands the refusal to the shared
// builder, whose message names `--search` — a flag this surface does not have.
// `issue` is filtered the same way, and for the same reason: an empty `issue` is
// not a named grammar either (review cycle 2).
let one = opt_selector(args, "issue")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let select = opt_string(args, "select")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let filter = opt_string(args, "filter")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let named =
usize::from(one.is_some()) + usize::from(select.is_some()) + usize::from(filter.is_some());
if named == 0 {
return Err(refuse(
"name the issues to decide: `issue` for one issue, or `select`/`filter` for \
a selection. A decision with no issue in it has nothing to report on."
.to_string(),
));
}
if named > 1 {
return Err(refuse(
"name exactly one of `issue`, `select` or `filter` — they are three grammars \
for the same selection, and this tool does not guess which you meant"
.to_string(),
));
}
// The shared builder splits and trims the comma list
// (`cmd_bulk::SelectionRequest::new` → `split_selectors`), so this does not
// split it a second time: one grammar, one splitter, and a trailing comma is
// dropped there rather than refused here with a message about an empty
// selector.
let mut selectors: Vec<String> = select.into_iter().collect();
if let Some(one) = one {
selectors.push(one);
}
// The **shared** request and resolver, so a selector that matches nothing or
// matches twice is the CLI's diagnostic (`unknown_issue`, ambiguity) and not a
// second implementation of selector resolution.
let request = cmd_bulk::SelectionRequest::new(
&selectors,
filter.as_deref(),
None,
bulk::SelectionScope::Active,
)
.map_err(|e| refuse(e.to_string()))?;
cmd_bulk::resolve(store, &request).map_err(|e| refuse(e.to_string()))
}
/// `triage_preview` / `triage_apply` (T039): the shared plan and apply, projected.
///
/// One pair of tools covers a single issue and a batch, because both go through
/// [`crate::triage::batch::build_plan`] — one preview engine, one refusal
/// vocabulary, one per-item outcome shape, exactly as the TUI's modal does
/// (`ModalScope::One` or `Many`). A single-issue call therefore reports the same
/// per-item status words a 10-issue call reports, which is what makes NFR-003 a
/// structural claim rather than a promise.
fn triage_run(config: &Config, store: &Store, args: &Value, apply: bool) -> ToolOutcome {
if apply {
// C-004. Checked **before** anything is resolved, so a client that forgot the
// confirmation gets the refusal without having paid for a plan.
match opt_bool(args, "confirm") {
Ok(Some(true)) => {}
Ok(_) => {
return Ok(error_result(
"confirm is required and must be true — a triage apply writes files \
and appends History, so nothing is written without it (C-004). Call \
triage_preview first; it is read-only."
.to_string(),
));
}
Err(()) => return Err(()),
}
}
// An absent or non-string `decision` is a **protocol** error (`-32602`); a
// well-formed but unknown decision is a **tool** result carrying the service's
// own message, because the client sent well-formed JSON and deserves to read
// why it was refused.
let raw = match req_string(args, "decision") {
Ok(raw) => raw,
Err(()) => return Err(()),
};
let kind = match raw.trim().parse::<DecisionKind>() {
Ok(kind) => kind,
Err(e) => return Ok(error_result(e.to_string())),
};
let reason = match opt_string(args, "reason") {
Ok(value) => value.unwrap_or_default(),
Err(()) => return Err(()),
};
// A blank actor is passed through rather than replaced by the git identity: the
// CLI refuses `--actor ""` with `missing_actor`, and an MCP client that sent an
// empty string should hear the same thing. Falling back here would make the two
// surfaces disagree about whether a decision was anonymous.
let actor = match opt_string(args, "actor") {
Ok(value) => value.unwrap_or_else(crate::storage::actor_identity),
Err(()) => return Err(()),
};
let mut request = TriageDecision::new(kind, crate::model::utc_now_rfc3339(), actor, reason);
// `to` is read **raw**, and refused whenever the decision cannot use it — blank
// included, because the flag itself is the mistake. That is the CLI's own rule, and
// reading `to` only for an accept (the first cut of this handler) let a client name
// a column that was silently dropped while `tix triage batch --decision decline
// --to …` refused the identical request (review cycle 1). On an *accept* a blank
// `--to` means "no explicit target", so the configured `accepted_status` applies —
// also the CLI's rule.
let to = match opt_string(args, "to") {
Ok(value) => value,
Err(()) => return Err(()),
};
if let Some(target) = to.as_deref() {
if kind != DecisionKind::Accept {
return Ok(error_result(format!(
"`to` names an accept target, and this decision is a {kind} — a {kind} \
decision has no target column, so `to: \"{target}\"` would be silently \
ignored. Name the target on an accept, and use `of` for a duplicate."
)));
}
}
if kind == DecisionKind::Accept {
request.target_status = to.filter(|t| !t.trim().is_empty());
if request.target_status.is_none() {
request.target_status = config
.triage
.as_ref()
.and_then(|triage| triage.accepted_status.clone());
}
}
// `of` is passed through **raw** for the same reason: `build_plan` refuses it with
// the CLI's own sentence when the decision has no duplicate target (blank included)
// and resolves it otherwise, so this handler has no opinion about it at all.
let of = match opt_string(args, "of") {
Ok(value) => value,
Err(()) => return Err(()),
};
let selection = match triage_selection(store, args) {
Ok(selection) => selection,
Err(outcome) => return outcome,
};
// `--of` on a non-duplicate decision, an unresolvable target, and a project with
// no `[triage]` section are all plan-level refusals, detected before the first
// write so a whole selection either starts or does not.
let plan = match batch::build_plan(store, selection, request, of.as_deref()) {
Ok(plan) => plan,
Err(e) => return Ok(error_result(e.to_string())),
};
if !apply {
return Ok(success_result(
cmd_triage::render_plan(&plan),
triage_plan_value(&plan, false),
));
}
let mut confirmed = plan.clone();
confirmed.confirm();
let report = match batch::apply(store, &confirmed) {
Ok(report) => report,
Err(e) => return Ok(error_result(e.to_string())),
};
let text = cmd_triage::render_report(&report, &confirmed);
let structured = triage_report_value(&report, &confirmed);
Ok(success_result(text, structured))
}
/// The plan as JSON: selection order, proposed changes, the History intent and the
/// per-item status — the same shape [`bulk_plan_value`] emits, for the same reason
/// (one projection per surface would be a second contract).
fn triage_plan_value(plan: &TriagePlan, applied: bool) -> Value {
json!({
"applied": applied,
"decision": plan.decision.kind.as_str(),
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"items": plan.items.iter().map(|item| json!({
"number": item.number,
"title": item.title,
"status": item.status(),
"changes": item.changes.iter()
.map(|(field, change)| {
let (from, to) = change.as_from_to();
json!({ "field": field, "from": from, "to": to })
})
.collect::<Vec<_>>(),
"history": item.history.iter()
.map(|h| json!({ "kind": h.kind, "detail": h.detail }))
.collect::<Vec<_>>(),
"diagnostic": item.diagnostic.as_ref().map(|d| json!({
"issue": d.issue,
"field": d.field,
"message": d.message,
})),
})).collect::<Vec<_>>(),
"summary": plan.summary(),
})
}
/// The report as JSON: exactly one outcome per selected issue (NFR-002), with the
/// shared status word rather than a re-derived string.
fn triage_report_value(report: &bulk::ApplyReport, plan: &TriagePlan) -> Value {
let titles: Vec<(u32, String)> = plan
.items
.iter()
.map(|i| (i.number, i.title.clone()))
.collect();
json!({
"applied": report.applied(),
"no_op": report.noop(),
"rejected": report.rejected(),
"failed": report.failed(),
"outcomes": report.outcomes.iter().map(|(number, outcome)| {
let title = titles.iter().find(|(n, _)| n == number)
.map(|(_, t)| t.as_str()).unwrap_or("");
json!({
"number": number,
"title": title,
"status": outcome.status(),
"line": outcome.as_line(*number, title),
})
}).collect::<Vec<_>>(),
})
}
/// Route a declared tool name to its handler.
///
/// `None` means "declared but not dispatched" -- see [`call`]. Split out from
/// [`call`] so the registration sweep can ask this question directly instead of
/// asserting against a hand-maintained list of names, which is what let a
/// declared-but-undispatched tool survive review rounds.
fn dispatch(config: &Config, store: &Store, name: &str, arguments: &Value) -> Option<ToolOutcome> {
let outcome = match name {
"create_issue" => create_issue(config, store, arguments),
"list_issues" => list_issues(config, store, arguments),
"get_issue" => get_issue(config, store, arguments),
"board_status" => board_status(config, store, arguments),
"update_issue" => update_issue(config, store, arguments),
"move_issue" => move_issue(config, store, arguments),
"link_issue" => link_issue(config, store, arguments),
"list_relationships" => list_relationships(store, arguments),
"edit_relationship" => edit_relationship(store, arguments),
"remove_relationship" => remove_relationship(store, arguments),
"issue_chain" => issue_chain(store, arguments),
"issue_blocked_state" => issue_blocked_state(store, arguments),
// WP07 / T030. Registered *and* dispatched in the same change, because a
// descriptor without a dispatch arm is advertised and then fails every
// call — the failure mode `tools_list_returns_every_registered_tool`
// was written to prevent.
"list_children" => list_children(store, arguments),
"get_parent" => get_parent(store, arguments),
"get_rollup" => get_rollup(store, arguments),
// ---- WP08 / T038: the batch tools ------------------------------------
//
// All three arms here, and the registration sweep below is what proved they
// were needed: adding the descriptors without them compiles cleanly and the
// only symptom is a "declared but not dispatched" error on the first call.
"bulk_preview" => bulk_run(store, arguments, false),
"bulk_apply" => bulk_run(store, arguments, true),
"bulk_capabilities" => bulk_capabilities(config, store, arguments),
// ---- WP08 / T040: the triage tools ------------------------------------
//
// All four arms here, not three: the registration sweep
// (`every_declared_tool_name_dispatches`) is what catches a descriptor
// without an arm, and it only catches it because both sides are in this
// one change.
"triage_list" => triage_list(config, store, arguments),
"triage_status" => triage_status(config, store, arguments),
"triage_preview" => triage_run(config, store, arguments, false),
"triage_apply" => triage_run(config, store, arguments, true),
"delete_issue" => delete_issue(store, arguments),
"search_issues" => search_issues(config, store, arguments),
"comment_on_issue" => comment_on_issue(config, store, arguments),
"export_issues" => export_issues(config, store, arguments),
"import_issues" => import_issues(config, store, arguments),
"lint_issues" => lint_issues(config, store, arguments),
_ => return None,
};
Some(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use std::fs;
use tempfile::TempDir;
/// A test config with auto-migration off, so tests that use bare/legacy
/// fixtures stay about their own concern.
fn test_config() -> Config {
let mut config = Config::default();
config.layout.move_on_change = false;
config
}
/// A repo whose board **declares a `category_map`**, so transition policy
/// applies (WP07 T033).
///
/// The default `make_repo` board declares none, and on such a board the seam
/// imposes **no** transition policy at all (WP03's opt-in) — so every parity
/// assertion written against it would pass vacuously. That is not hypothetical:
/// an earlier draft of these cases used it and proved nothing.
fn mapped_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"triage".into(),
"wip".into(),
"review".into(),
"shipped".into(),
"abandoned".into(),
],
done_column: Some("shipped".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("review".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
("abandoned".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
..test_config()
};
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn make_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn write_issue(store: &Store, number: u32, title: &str, status: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
);
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
contents,
)
.unwrap();
}
fn invoke(config: &Config, store: &Store, name: &str, args: Value) -> Value {
call(config, store, protocol::Id::Number(1), name, &args)
.expect("protocol-level calls in unit tests must not fail")
}
/// T033 step 2: **every declared tool name dispatches.**
///
/// `dispatch` answers `None` both for a name it does not know and for a name it
/// knows but does not handle — the registry and the `match` can drift apart, and
/// a tool in that gap is a server that answers every call to it with "unknown
/// tool". An agent discovers that at runtime; this fails at test time.
///
/// A **fresh** store with no issues, so a handler that legitimately needs one
/// reports a missing issue rather than being skipped. The assertion is only on
/// `Some(..)` — what the handler *did* is not this test's business, and
/// demanding success here would make a tool that correctly refuses an unknown
/// selector look unregistered.
#[test]
fn every_declared_tool_name_dispatches() {
let (_tmp, config, store) = make_repo();
for tool in list() {
let name = tool["name"].as_str().expect("a tool name");
let outcome = dispatch(&config, &store, name, &json!({}));
assert!(
outcome.is_some(),
"`{name}` is declared in the registry but `dispatch` returns None for \
it, so every call would answer \"unknown tool\". Either add the arm \
or drop the declaration."
);
}
}
/// The converse: `dispatch` must answer `None` for a name nobody declared. Without
/// this, a sweep above could be satisfied by a `dispatch` that returns `Some`
/// for everything.
#[test]
fn dispatch_rejects_a_name_nobody_declared() {
let (_tmp, config, store) = make_repo();
assert!(
dispatch(&config, &store, "definitely_not_a_tool", &json!({})).is_none(),
"an undeclared name must not dispatch"
);
}
#[test]
fn every_declared_tool_has_a_valid_schema() {
for tool in list() {
let schema = &tool["inputSchema"];
assert_eq!(schema["type"], "object", "{}", tool["name"]);
assert_eq!(schema["additionalProperties"], false, "{}", tool["name"]);
assert!(
tool["description"].as_str().is_some_and(|d| !d.is_empty()),
"{}",
tool["name"]
);
}
}
/// The registration sweep T023 asks for, and the reason this file no longer
/// maintains a hand-written list of tool names.
///
/// A `match` over tool names can silently drift from the registry: a tool
/// declared but not dispatched is a server that answers every call to it
/// with an error, and until now the only signal was an `unreachable!`
/// **panic** on first use — discovered by an agent, not by a test. Asking
/// `dispatch` directly turns that into a build-time-visible failure, and
/// deriving the names from `registry()` means a newly declared tool is
/// covered the moment it is added.
/// `docs/mcp.md` states the tool count in three places, and it was wrong in
/// all three — 13, over a table with 18 rows — for as long as the six
/// relationship tools existed. `tools_list_returns_every_registered_tool` had
/// its count assertion *removed* during that change, so nothing noticed.
///
/// The count is a documentation claim about a registry, so it is checked
/// against the registry. Derived prose cannot be kept in step by hand.
#[test]
fn the_documented_tool_count_matches_the_registry() {
let registered = registry().len();
let doc = include_str!("../../docs/mcp.md");
// Every place the doc states the count, found by pattern rather than by
// hand. The three are: "the agent gains N tools", "## The N tools", and
// "call the N tools".
let mut stated: Vec<(usize, &str)> = Vec::new();
for line in doc.lines() {
let after = |marker: &str| -> Option<usize> {
line.split(marker)
.nth(1)?
.split_whitespace()
.next()?
.parse::<usize>()
.ok()
};
for marker in ["gains ", "## The ", "call the "] {
if let Some(n) = after(marker) {
stated.push((n, line.trim()));
}
}
}
assert_eq!(
stated.len(),
3,
"the doc states the count in three places and all three must be found; \
found {stated:?}"
);
for (n, line) in stated {
assert_eq!(
n, registered,
"docs/mcp.md says {n} tools (`{line}`); the registry has {registered}"
);
}
// And the table beside the count lists exactly the registry's tools.
//
// Compared as *names*, not as a row count: the section also contains the
// migration before/after table, and — more to the point — a count can be
// right while the table is wrong. Listing the same 18 tools under the wrong
// heading is the failure that matters.
let listed: Vec<&str> = doc
.lines()
.filter_map(|l| l.strip_prefix("| `"))
.filter_map(|l| l.split('`').next())
.filter(|name| registry().iter().any(|d| d.name == *name))
.collect();
let mut expected: Vec<&str> = registry().iter().map(|d| d.name).collect();
expected.sort_unstable();
let mut got = listed.clone();
got.sort_unstable();
got.dedup();
assert_eq!(
got, expected,
"the tool table must list every registered tool exactly once; \
missing or extra entries show here"
);
// **Order is deliberately not compared.** The table is grouped by workflow
// — create, read, move, relate, delete, search, sync, lint — which reads
// better than the registry's declaration order, and pinning the latter
// would make a deliberate regrouping look like a defect.
assert_eq!(
listed.len(),
expected.len(),
"one row per registered tool, with no duplicates"
);
}
#[test]
fn every_declared_tool_dispatches() {
let (_tmp, config, store) = make_repo();
let mut undispatched = Vec::new();
for tool in registry() {
if dispatch(&config, &store, tool.name, &json!({})).is_none() {
undispatched.push(tool.name);
}
}
assert!(
undispatched.is_empty(),
"declared but not dispatched: {undispatched:?}"
);
}
/// A name the registry does not declare is rejected by `call` before
/// dispatch, and dispatch answers `None` for anything undeclared too — so
/// the two layers agree and neither panics.
#[test]
fn an_undeclared_tool_name_is_a_clean_error_not_a_panic() {
let (_tmp, config, store) = make_repo();
assert!(
dispatch(&config, &store, "no_such_tool", &json!({})).is_none(),
"an undeclared name must not dispatch"
);
let result = call(
&config,
&store,
protocol::Id::Number(1),
"no_such_tool",
&json!({}),
);
assert!(result.is_err(), "an undeclared name is a protocol error");
}
#[test]
fn arg_helpers_reject_wrong_types() {
let args = json!({ "title": 42, "dry_run": "yes", "issue": true });
assert!(req_string(&args, "title").is_err());
assert!(opt_string(&args, "title").is_err());
assert!(opt_bool(&args, "dry_run").is_err());
assert!(selector(&args, "issue").is_err());
assert_eq!(selector(&json!({ "issue": 123 }), "issue").unwrap(), "123");
assert_eq!(
selector(&json!({ "issue": "n 1" }), "issue").unwrap(),
"n 1"
);
}
#[test]
fn null_optionals_are_treated_as_absent() {
assert_eq!(opt_string(&json!({ "x": null }), "x").unwrap(), None);
assert_eq!(opt_bool(&json!({ "x": null }), "x").unwrap(), None);
assert!(req_string(&json!({ "x": null }), "x").is_err());
}
#[test]
fn call_type_mismatch_is_invalid_params() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(1),
"create_issue",
&json!({ "title": 5 }),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::INVALID_PARAMS);
assert_eq!(err.id, protocol::Id::Number(1));
}
#[test]
fn call_unknown_tool_is_method_not_found() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(2),
"bogus",
&json!({}),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::METHOD_NOT_FOUND);
}
#[test]
fn create_issue_writes_file_and_returns_structured_result() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Build the tool registry", "labels": "mcp, core", "priority": "high" }),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["title"], "Build the tool registry");
assert_eq!(structured["labels"], json!(["mcp", "core"]));
assert_eq!(structured["priority"], "high");
let filename = structured["filename"].as_str().unwrap();
assert!(store.issues_dir.join(filename).exists());
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.starts_with("Created ")
);
}
#[test]
fn delete_issue_requires_confirm_and_files_survive_refusal() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let refused = invoke(&config, &store, "delete_issue", json!({ "issue": "1" }));
assert_eq!(refused["isError"], true);
assert!(store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
let done = invoke(
&config,
&store,
"delete_issue",
json!({ "issue": 1, "confirm": true }),
);
assert_eq!(done["isError"], false);
assert_eq!(
done["structuredContent"]["deleted"],
"alice/2026-01-01-alpha.md"
);
assert!(!store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
}
#[test]
fn update_issue_with_no_fields_is_a_tool_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "update_issue", json!({ "issue": "1" }));
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update")
);
}
#[test]
fn create_issue_accepts_cycles_milestones_reviewer_and_dates() {
let (_tmp, config, store) = make_repo();
let mut config = config;
config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let milestone_dir =
crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new())
.milestones_dir();
std::fs::create_dir_all(&milestone_dir).unwrap();
std::fs::write(
milestone_dir.join("2026-09-21-mcp-create-1.md"),
"---\nname: v1.3\n---\n",
)
.unwrap();
let result = invoke(
&config,
&store,
"create_issue",
json!({
"title": "Planned",
"assignee": "",
"cycles": "Cycle 1",
"milestones": "v1.3",
"reviewer": "Alice",
"start_date": "2026-09-07",
"due_date": "2026-09-18"
}),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["cycles"], json!(["Cycle 1"]));
assert_eq!(structured["milestones"], json!(["v1.3"]));
assert_eq!(structured["reviewer"], "Alice");
assert_eq!(structured["start_date"], "2026-09-07");
assert_eq!(structured["due_date"], "2026-09-18");
}
#[test]
fn update_issue_sets_cycles_milestones_reviewer_and_dates() {
let (_tmp, config, store) = make_repo();
let mut config = config;
config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let milestone_dir =
crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new())
.milestones_dir();
std::fs::create_dir_all(&milestone_dir).unwrap();
std::fs::write(
milestone_dir.join("2026-09-21-mcp-update-1.md"),
"---\nname: v1.2\n---\n",
)
.unwrap();
let created = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Alpha", "assignee": "" }),
);
let num = created["structuredContent"]["number"].as_u64().unwrap();
let result = invoke(
&config,
&store,
"update_issue",
json!({
"issue": num,
"cycles": "Cycle 1",
"milestones": "v1.2",
"reviewer": "Alice",
"start_date": "2026-09-07",
"due_date": "2026-09-18"
}),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["cycles"], json!(["Cycle 1"]));
assert_eq!(structured["milestones"], json!(["v1.2"]));
assert_eq!(structured["reviewer"], "Alice");
assert_eq!(structured["start_date"], "2026-09-07");
assert_eq!(structured["due_date"], "2026-09-18");
}
#[test]
fn create_issue_rejects_unknown_planning_refs_with_cli_text() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Broken", "cycles": "Nope", "milestones": "Missing" }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("invalid input: unknown cycle `Nope` — valid cycles: none configured"),
"got: {text}"
);
}
#[test]
fn move_issue_unknown_column_reports_the_shared_named_diagnostic() {
// WP03: was `move_issue_unknown_column_mirrors_cli_error_text`, and it
// asserted an exact string that was **MCP's own** — so the test's name said
// "mirrors the CLI" while pinning the copy that research R8 identified as
// the bypass, and the CLI's text was a second, separately-maintained
// spelling of one condition. Contract §6 wants one name for one condition.
//
// The two things worth pinning are now: the named kind (which is what every
// surface shares) and the valid-column list (the information the old
// message carried and which a user needs to recover from). The exact prose
// is not the contract.
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "nope" }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("(unknown_column)"),
"the shared diagnostic name must reach the client: {text}"
);
assert!(
text.contains("valid columns: todo, in progress, in review, done"),
"the valid-column list is what the user needs in order to recover: {text}"
);
}
#[test]
fn lint_clean_store_reports_ok() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "lint_issues", json!({}));
assert_eq!(result["isError"], false);
// WP06: the summary line is now "Lint OK with N warnings" on a board that
// relies on the safe default, because `unmapped_column_default` is a
// Warning and is reported (T026 step 3). The assertion that matters is that
// the store is OK and the exit is not an error — so it checks the
// `isError` flag and the "Lint OK" prefix, not the exact wording, which now
// depends on a configuration detail this test is not about.
// WP06: the default board has no `category_map`, so `tix lint` reports
// `unmapped_column_default` for three of its columns — as **warnings**. The
// store is therefore OK and the tool is not an error, and the text says
// exactly what the CLI says for the same store.
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.starts_with("Lint OK with 1 warning: 1 issues checked."),
"the CLI's own wording, so the two surfaces agree: {text}"
);
}
#[test]
fn export_without_gitlab_config_is_a_structured_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"export_issues",
json!({ "target": "gitlab", "dry_run": true }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("no `[export.gitlab]` section"), "got: {text}");
}
// ---- WP05: the typed relationship tools (T021, T022) --------------
//
// One accessor, three shapes. The tests below never destructure a result:
// they read `err(&r)`, `text(&r)` and `sc(&r)`, so adding a field to the
// response shape cannot silently shift a positional assertion.
/// The `isError` flag.
fn err(r: &Value) -> bool {
r["isError"].as_bool().unwrap_or(false)
}
/// The human-readable text — where the diagnostic an agent actually reads is.
fn text(r: &Value) -> String {
r["content"][0]["text"].as_str().unwrap().to_string()
}
/// The structured payload.
fn sc(r: &Value) -> &Value {
&r["structuredContent"]
}
/// Every issue file's bytes. An `isError` flag is not evidence that a
/// rejected call wrote nothing; a directory snapshot is.
fn snap(store: &Store) -> std::collections::BTreeMap<String, String> {
store
.list()
.unwrap()
.into_iter()
.map(|i| {
(
i.filename.clone(),
fs::read_to_string(store.issues_dir.join(&i.filename)).unwrap(),
)
})
.collect()
}
fn three_issues() -> (TempDir, Config, Store) {
let (tmp, config, store) = make_repo();
write_issue(&store, 1, "A", "todo");
write_issue(&store, 2, "B", "todo");
write_issue(&store, 3, "C", "todo");
(tmp, config, store)
}
#[test]
fn t021_a_missing_kind_is_rejected_and_names_the_argument() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(&config, &store, "link_issue", json!({ "a": "1", "b": "2" }));
assert!(err(&r), "a missing kind must be rejected: {}", text(&r));
let t = text(&r);
// The diagnostic *kind* and *field* are the service's, identical to the
// CLI's; only the guidance names the argument this surface uses, which
// is what `surface-parity.md` §2 allows as presentation.
assert!(t.contains("unknown_kind"), "{t}");
assert!(t.contains("`kind`"), "names the argument: {t}");
for spelling in ["blocks", "blocked_by", "duplicates", "related"] {
assert!(t.contains(spelling), "lists `{spelling}`: {t}");
}
assert_eq!(snap(&store), before, "a rejected create writes nothing");
}
#[test]
fn t021_an_unknown_kind_rejects_with_the_clis_exact_text() {
// One parser on both surfaces, so the two cannot drift.
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "depends-on" }),
);
assert!(err(&r));
let expected = crate::commands::link::parse_kind("depends-on")
.unwrap_err()
.to_string();
assert_eq!(
text(&r),
expected,
"MCP and the CLI must say the same thing"
);
assert_eq!(snap(&store), before);
}
#[test]
fn t021_a_create_returns_id_kind_target_and_display() {
let (_tmp, config, store) = three_issues();
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
assert!(!err(&r), "{}", text(&r));
let rel = &sc(&r)["relationship"];
assert!(
rel["id"].as_str().is_some_and(|s| s.starts_with("rel_")),
"{rel}"
);
assert_eq!(rel["kind"], "blocks");
assert_eq!(rel["from"], 1);
assert_eq!(rel["to"], 2);
assert_eq!(rel["display"], "blocks #2 B");
assert_eq!(sc(&r)["changed"], true);
}
#[test]
fn t021_a_self_link_is_rejected_with_the_clis_diagnostic() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "A", "todo");
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "1", "kind": "blocks" }),
);
assert!(err(&r));
assert!(text(&r).contains("self_link"), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_a_missing_target_is_rejected_and_nothing_is_written() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "999", "kind": "blocks" }),
);
assert!(err(&r), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_edit_preserves_the_id_and_remove_is_idempotent() {
let (_tmp, config, store) = three_issues();
let created = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let id = sc(&created)["relationship"]["id"]
.as_str()
.unwrap()
.to_string();
let edited = invoke(
&config,
&store,
"edit_relationship",
json!({ "id": id, "to": "3" }),
);
assert!(!err(&edited), "{}", text(&edited));
assert_eq!(sc(&edited)["relationship"]["id"], id.as_str());
assert_eq!(sc(&edited)["relationship"]["to"], 3);
let removed = invoke(&config, &store, "remove_relationship", json!({ "id": id }));
assert!(!err(&removed), "{}", text(&removed));
assert_eq!(sc(&removed)["changed"], true);
let after_first = snap(&store);
// A second remove is a success that writes nothing — safe to retry.
let again = invoke(&config, &store, "remove_relationship", json!({ "id": id }));
assert!(!err(&again), "an absent id is a no-op, not a failure");
assert_eq!(sc(&again)["changed"], false);
assert_eq!(snap(&store), after_first, "no second write");
}
#[test]
fn t021_an_edit_with_nothing_to_change_is_refused() {
let (_tmp, config, store) = three_issues();
let created = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let id = sc(&created)["relationship"]["id"]
.as_str()
.unwrap()
.to_string();
let before = snap(&store);
let r = invoke(&config, &store, "edit_relationship", json!({ "id": id }));
assert!(err(&r), "the identity edit must be refused: {}", text(&r));
assert!(text(&r).contains("nothing to change"), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_list_is_ordered_by_kind_then_target_and_filters_exactly() {
let (_tmp, config, store) = make_repo();
for (n, t) in [(1, "A"), (2, "B"), (3, "C"), (4, "D")] {
write_issue(&store, n, t, "todo");
}
for (target, kind) in [("2", "blocks"), ("3", "duplicates"), ("4", "related")] {
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": target, "kind": kind }),
);
}
let all = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1" }),
);
let kinds: Vec<&str> = sc(&all)["relationships"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(
kinds,
vec!["blocks", "duplicates", "related"],
"{}",
text(&all)
);
// `blocked_by` filters on the CANONICAL blocking edge; no stored edge
// ever carries that kind, so a literal filter would match nothing.
let by_input = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1", "kind": "blocked_by" }),
);
let kinds: Vec<&str> = sc(&by_input)["relationships"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["blocks"], "must not match nothing");
// Byte-comparable across runs, which is what the parity assertion needs.
let again = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1" }),
);
assert_eq!(text(&all), text(&again));
}
#[test]
fn t022_chain_returns_the_service_order_with_numbers_and_titles() {
let (_tmp, config, store) = three_issues();
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
invoke(
&config,
&store,
"link_issue",
json!({ "a": "2", "b": "3", "kind": "blocks" }),
);
let r = invoke(&config, &store, "issue_chain", json!({ "issue": "1" }));
assert!(!err(&r), "{}", text(&r));
assert_eq!(sc(&r)["truncated"], false);
let path = sc(&r)["paths"][0].as_array().unwrap().clone();
let numbers: Vec<u64> = path.iter().map(|n| n["number"].as_u64().unwrap()).collect();
assert_eq!(numbers, vec![1, 2, 3], "the service's order, unmodified");
// Numbers AND titles: an agent must be able to address the next node.
assert_eq!(path[2]["title"], "C");
}
#[test]
fn t022_blocked_state_reports_the_service_reason_verbatim() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Blocker", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let r = invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": "2" }),
);
assert!(!err(&r), "{}", text(&r));
assert_eq!(sc(&r)["is_blocked"], true);
assert_eq!(sc(&r)["blockers"][0]["number"], 1);
// Byte-identical to the CLI warning, the TUI prompt, the board card and
// the detail view — all of which render `blocked_note`.
let expected = format!(
"blocked: {}",
crate::relationships::blocked_reason(&[crate::relationships::ChainNode::new(
1,
"Blocker".to_string(),
)])
);
assert_eq!(text(&r), expected);
assert_eq!(sc(&r)["reason"], expected.trim_start_matches("blocked: "));
}
#[test]
fn t022_only_related_is_not_blocked() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Other", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "related" }),
);
let r = invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": "2" }),
);
assert_eq!(sc(&r)["is_blocked"], false, "{}", text(&r));
assert!(sc(&r)["blockers"].as_array().unwrap().is_empty());
assert_eq!(text(&r), "not blocked");
}
#[test]
fn t022_the_read_tools_are_genuinely_read_only() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Blocker", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let before = snap(&store);
for issue in ["1", "2"] {
invoke(&config, &store, "issue_chain", json!({ "issue": issue }));
invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": issue }),
);
invoke(
&config,
&store,
"list_relationships",
json!({ "issue": issue }),
);
}
assert_eq!(
snap(&store),
before,
"a read tool must not write a file or append History"
);
}
// ---------------------------------------------------------------------------
// WP07 T033 — MCP parity with the CLI, and the override that was missing
// ---------------------------------------------------------------------------
/// The persisted `status` of issue 1, unquoted.
fn persisted_status(store: &Store) -> String {
let entry = fs::read_dir(store.issues_dir.join("alice"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.to_string_lossy().contains("alpha"))
.expect("issue file");
fs::read_to_string(entry)
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("status: "))
.unwrap()
.trim()
.trim_matches('"')
.to_string()
}
fn whole_store_bytes(store: &Store) -> Vec<(std::path::PathBuf, Vec<u8>)> {
let mut out: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
for entry in fs::read_dir(&store.issues_dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
for child in fs::read_dir(&path).unwrap() {
let child = child.unwrap().path();
out.push((child.clone(), fs::read(&child).unwrap()));
}
}
}
out.sort();
out
}
#[test]
fn t033_move_issue_can_leave_a_terminal_column_with_a_reason() {
// The gap WP03 recorded and WP04 could not close: `move_issue` passed `None` for
// the override, so on a board that declares a `category_map` an MCP client could
// not leave a terminal column **at all**. Now it can, and the reason is recorded
// in the same `TransitionDecision` the CLI writes.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
// Without a reason: refused, by the shared diagnostic, and nothing written.
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip" }),
);
assert_eq!(refused["isError"], true, "a terminal exit needs a reason");
let text = refused["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("invalid_transition"),
"the shared named diagnostic: {text}"
);
assert_eq!(whole_store_bytes(&store), before, "and it wrote nothing");
// With one: applied, and recorded.
let ok = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip", "reason": "reopening: wrong epic" }),
);
assert_eq!(ok["isError"], false, "{}", ok["content"][0]["text"]);
assert_eq!(persisted_status(&store), "wip");
let issue = fs::read_to_string(
fs::read_dir(store.issues_dir.join("alice"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.to_string_lossy().contains("alpha"))
.unwrap(),
)
.unwrap();
assert!(
issue.contains("override: reopening: wrong epic"),
"and the reason is in the audit trail: {issue}"
);
}
#[test]
fn t033_update_issue_status_can_leave_a_terminal_column_with_a_reason() {
// The same through the *other* tool, because `update_issue --status` is a move
// and must offer the same escape (contract §2, `cli-surface.md` §2).
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "status": "wip" }),
);
assert_eq!(refused["isError"], true);
assert!(
refused["content"][0]["text"]
.as_str()
.unwrap()
.contains("invalid_transition"),
"the same diagnostic as `move_issue`"
);
assert_eq!(whole_store_bytes(&store), before);
let ok = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "status": "wip", "reason": "reopening" }),
);
assert_eq!(ok["isError"], false, "{}", ok["content"][0]["text"]);
assert_eq!(persisted_status(&store), "wip");
}
#[test]
fn t033_a_blank_reason_is_not_a_reason_over_mcp_either() {
// The same rule the CLI applies, asserted here so MCP cannot be the one surface
// that accepts a whitespace-only override.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip", "reason": " " }),
);
assert_eq!(result["isError"], true);
assert_eq!(whole_store_bytes(&store), before);
}
#[test]
fn t033_reason_alone_is_not_an_update() {
// `reason` qualifies a transition; it is not an editable field. A call carrying
// only a reason must be "no fields to update", not a silent success that
// discards it.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "triage");
let result = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "reason": "because" }),
);
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update"),
"got: {}",
result["content"][0]["text"]
);
}
#[test]
fn t033_reads_carry_the_category_key_and_label() {
// T030 step 4 / FR-004. The **key** as well as the label: a client filtering or
// grouping needs the stable name.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "triage");
for tool in ["get_issue", "list_issues"] {
let result = invoke(&config, &store, tool, json!({ "issue": "1" }));
assert_eq!(
result["isError"], false,
"{tool}: {}",
result["content"][0]["text"]
);
// `structuredContent` carries the object; `content[0].text` is the issue file
// verbatim for `get_issue` and a human sentence for `list_issues`, so reading
// the text as JSON fails. An earlier draft of this test did exactly that and
// reported `category: Null` for a category that was present.
let structured = &result["structuredContent"];
let issue = if tool == "get_issue" {
structured.clone()
} else {
structured["issues"][0].clone()
};
assert_eq!(
issue["category"], "backlog",
"{tool} must carry the category key"
);
assert_eq!(
issue["category_label"], "Backlog",
"{tool} must carry the service's label"
);
}
}
#[test]
fn t033_an_unmapped_column_reads_as_null_not_a_default() {
// A configuration error the client can *see*, not a silent default. `tix list
// --show category` spells the same case `(unmapped)`.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "not-a-column");
let result = invoke(&config, &store, "get_issue", json!({ "issue": "1" }));
let issue = result["structuredContent"].clone();
assert_eq!(issue["category"], Value::Null);
assert_eq!(issue["category_label"], "(unmapped)");
}
#[test]
fn t033_the_writes_carry_the_category_too() {
// The category rides on the shared `issue_value`, so every write result has it as
// well as every read. Asserted on the result, so a future handler that stops
// using the shared value is caught here rather than by a client noticing a
// missing field.
let (_tmp, config, store) = mapped_repo();
let created = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Beta", "status": "wip" }),
);
let value = created["structuredContent"].clone();
assert_eq!(value["category"], "active", "created: {value}");
assert_eq!(value["category_label"], "In progress");
}
// -----------------------------------------------------------------------
// WP08 / T038–T042 — the triage tools
// -----------------------------------------------------------------------
/// The `[triage]` section, on the same board [`mapped_repo`] declares.
const TRIAGE: &str = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"abandoned\"\n";
/// A repo with an inbox: the board `mapped_repo` declares, plus `[triage]`.
fn triage_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let text = format!(
"issues_dir = \"{}\"\n\n[kanban.default]\ncolumns = [\"triage\", \"wip\", \
\"review\", \"shipped\", \"abandoned\"]\ndone_column = \"shipped\"\n\
category_map = {{ triage = \"backlog\", wip = \"active\", review = \"active\", \
shipped = \"completed\", abandoned = \"canceled\" }}\n{TRIAGE}\n\
[layout]\nmove_on_change = false\n",
dir.display()
);
fs::write(&config_path, text).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
/// One issue in the queue, written the way `tix` writes one.
fn write_triage_issue(store: &Store, number: u32, title: &str, entered_at: Option<&str>) {
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: triage\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n"
);
if let Some(at) = entered_at {
text.push_str(&format!("entered_at: {at}\nsource: test\n"));
}
text.push_str("---\n\nBody.");
fs::write(
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md")),
text,
)
.unwrap();
}
fn triage_issue_path(store: &Store, number: u32) -> std::path::PathBuf {
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md"))
}
#[test]
fn t038_the_triage_schemas_are_strict_and_only_the_apply_requires_confirm() {
let by_name = |name: &str| -> &ToolDescriptor {
registry()
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` is not registered"))
};
for name in [
"triage_list",
"triage_status",
"triage_preview",
"triage_apply",
] {
let tool = by_name(name);
assert_eq!(tool.input_schema["additionalProperties"], json!(false));
assert_eq!(tool.input_schema["type"], json!("object"));
}
assert_eq!(
by_name("triage_apply").input_schema["required"],
json!(["decision", "confirm"]),
"a write tool whose confirmation is optional is a write tool clients call by accident"
);
assert_eq!(
by_name("triage_preview").input_schema["required"],
json!(["decision"])
);
assert!(
by_name("triage_preview").input_schema["properties"]
.get("confirm")
.is_none(),
"the preview does not even advertise a confirmation"
);
assert_eq!(
by_name("triage_list").input_schema["properties"]["age"]["type"],
json!("boolean")
);
}
#[test]
fn t039_a_triage_preview_changes_nothing_and_names_every_item() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
write_triage_issue(&store, 2, "Bravo", None);
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"triage_preview",
json!({"select": "1,2", "decision": "decline", "reason": "no budget", "actor": "tester"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["applied"], json!(false));
assert_eq!(
sc(&out)["items"].as_array().map(Vec::len),
Some(2),
"one entry per selected issue"
);
assert_eq!(
sc(&out)["items"][0]["history"][0]["kind"],
json!("triage_declined")
);
assert_eq!(
before,
whole_store_bytes(&store),
"a preview is read-only: no status, no History, no file"
);
}
#[test]
fn t039_a_missing_reason_is_a_refused_item_naming_the_contracts_kind() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let out = invoke(
&config,
&store,
"triage_preview",
json!({"issue": 1, "decision": "decline", "actor": "tester"}),
);
assert!(!err(&out), "a refusal per item is a plan, not an error");
let item = sc(&out)["items"][0].clone();
assert_eq!(item["status"], "rejected");
assert!(
item["diagnostic"]["message"]
.as_str()
.unwrap_or_default()
.contains("missing_reason"),
"{item}"
);
}
#[test]
fn t039_a_triage_apply_needs_the_confirmation_and_then_writes_one_event() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"triage_apply",
json!({"issue": 1, "decision": "decline", "reason": "no budget", "actor": "tester"}),
);
assert!(err(&refused), "an apply without `confirm` is refused");
assert!(
text(&refused).contains("confirm is required"),
"{}",
text(&refused)
);
assert_eq!(before, whole_store_bytes(&store), "and writes nothing");
let applied = invoke(
&config,
&store,
"triage_apply",
json!({
"issue": 1, "decision": "decline", "reason": "no budget",
"actor": "tester", "confirm": true,
}),
);
assert!(!err(&applied), "{}", text(&applied));
assert_eq!(sc(&applied)["applied"], json!(1));
let written = fs::read_to_string(triage_issue_path(&store, 1)).unwrap();
assert!(
written.lines().any(|l| l.trim() == "status: abandoned"),
"the configured declined column, not a column this handler chose:\n{written}"
);
assert_eq!(
written.matches("triage_declined").count(),
1,
"exactly one History event:\n{written}"
);
}
#[test]
fn t040_every_triage_tool_is_dispatched_with_a_minimal_call() {
// The sweep above proves every *declared* name dispatches; this proves the
// four new ones do something useful with the smallest legal request, which is
// the state a client is in the first time it calls them.
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
for (name, args) in [
("triage_list", json!({})),
("triage_status", json!({})),
(
"triage_preview",
json!({"issue": 1, "decision": "defer", "reason": "later"}),
),
] {
let out = invoke(&config, &store, name, args);
assert!(!err(&out), "`{name}`: {}", text(&out));
}
let applied = invoke(
&config,
&store,
"triage_apply",
json!({"issue": 1, "decision": "defer", "reason": "later", "confirm": true}),
);
assert!(!err(&applied), "triage_apply: {}", text(&applied));
}
#[test]
fn t039_a_target_the_decision_cannot_use_is_refused_before_the_plan() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let before = whole_store_bytes(&store);
for (decision, args) in [
("decline", json!({"to": "abandoned"})),
("defer", json!({"to": "abandoned"})),
("accept", json!({"of": "1"})),
] {
let mut request = json!({
"issue": 1, "decision": decision, "reason": "x", "actor": "tester",
"confirm": true,
});
if let Some(object) = args.as_object() {
for (key, value) in object {
request[key] = value.clone();
}
}
let out = invoke(&config, &store, "triage_apply", request);
assert!(
err(&out),
"`{decision}` with {args} must be refused, not silently ignored: {}",
text(&out)
);
}
assert_eq!(
before,
whole_store_bytes(&store),
"and a refused request writes nothing"
);
}
#[test]
fn t041_an_invalid_section_reports_the_validators_message_not_the_missing_one() {
// `inbox::scope` has two failure modes and they are different answers to
// different problems: "no `[triage]` section" (`Ok(None)`, the case above) and
// "a section the board cannot satisfy" (`Err`, a config error the loader would
// normally have caught). A caller can hand the store a config the loader would
// have refused — `import_with_adapter`'s sibling situation in WP07 — so the MCP
// must report the validator's own message rather than flatten both into the
// missing-section sentence.
let (_tmp, config, store) = triage_repo();
let mut invalid = config.clone();
invalid
.triage
.as_mut()
.expect("the fixture configures a section")
.entry_status = Some("no-such-column".to_string());
let store = Store {
issues_dir: store.issues_dir.clone(),
config: invalid.clone(),
};
let cli_error = cmd_triage::render_list(&store, false, false)
.expect_err("a column the board lacks is refused")
.to_string();
assert!(
cli_error.contains("no-such-column"),
"the validator names the offending column: {cli_error}"
);
assert!(
!cli_error.ends_with(NO_INBOX),
"a different problem, a different sentence"
);
let out = invoke(&invalid, &store, "triage_list", json!({}));
assert!(err(&out), "`triage_list` refuses an invalid section");
assert_eq!(
text(&out),
cli_error,
"the validator's message, not the missing-section one"
);
}
#[test]
fn t041_the_no_inbox_refusal_is_the_clis_own_sentence() {
// The MCP module carries its own copy of the refusal (the CLI's `scope_of` is
// private to `commands::triage`), so this pins the copy to its source: a
// project with no `[triage]` section must produce the CLI's sentence on both
// surfaces, and this is the assertion that notices if one of them is reworded.
let (_tmp, config, store) = make_repo();
let cli_error = cmd_triage::render_list(&store, false, false)
.expect_err("a project with no `[triage]` section has no inbox")
.to_string();
assert!(
cli_error.ends_with(NO_INBOX),
"the CLI says `{cli_error}`, and the MCP constant agrees only if it ends with it"
);
for name in ["triage_list", "triage_status"] {
let out = invoke(&config, &store, name, json!({}));
assert!(err(&out), "`{name}` must refuse");
assert_eq!(
text(&out),
cli_error,
"`{name}` carries the CLI's sentence verbatim, not a paraphrase"
);
}
let decision = invoke(
&config,
&store,
"triage_preview",
json!({"issue": 1, "decision": "defer", "reason": "later"}),
);
assert!(
err(&decision),
"and so does a decision through an inbox that does not exist"
);
}
}