Skip to content

Expose the declared work-breakdown and relation MCP tools [AI-1718] - #441

Merged
realtonyyoung merged 3 commits into
mainfrom
tonyyoung/ai-1718-breakdown-mcp-tools
Aug 3, 2026
Merged

Expose the declared work-breakdown and relation MCP tools [AI-1718]#441
realtonyyoung merged 3 commits into
mainfrom
tonyyoung/ai-1718-breakdown-mcp-tools

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

The kcap-server side shipped five routes for declared work breakdown and relations, and no agent could reach any of them. kcap mcp workitems exposed only declare_work_item and get_session_work_items, and the server's design deliberately provides no UI path — so the feature was inert on arrival. This wires it up.

Tool Route
declare_work_breakdown POST /api/work-items/{parentId}/breakdown
retract_work_breakdown POST /api/work-items/{parentId}/breakdown/retract
declare_work_relation POST /api/work-items/{fromId}/relations
retract_work_relation POST /api/work-items/{fromId}/relations/retract
get_work_item_topology GET /api/work-items/{id}/topology

Registration needed no change — kcap-workitems is already Claude-Code-plugin-only.

Design decisions

No tool accepts source or declared_by. The server resolves both from the authenticated caller and rejects a source of "user" outright. Exposing either would be an argument the server ignores at best and a spoofing surface at worst. Two tests: one that no tool advertises them, and — the real guarantee — that the body builders never forward them even when a caller ignores the schema and sends them anyway. (Without additionalProperties: false, a schema cannot forbid the keys, only decline to invite them.)

Ids are required, with no fallback. ResolveSessionId can default to the ambient session because "the session I am running in" is unambiguous. There is no ambient work item, so a default would silently attach the wrong edge of the graph.

Ids are contained in their path segment — and escaping alone is not enough. Uri.EscapeDataString handles /, %, ?, #. But . is unreserved in RFC 3986, so it survives escaping and URI normalization then removes the segment: an id of "." would reach /api/work-items/breakdown and ".." would reach /api/breakdown — a different route whose response would be attributed to the id passed. Exactly those two are rejected; "..." and longer runs are ordinary path segments and are allowed, since refusing them would reject an id the server might accept. Caller-supplied "%2e" is inert: EscapeDataString escapes the % itself and standard URI processing does not decode twice.

Shape is validated locally; semantics are not. The rules the server owns — cross-repo edges, unknown/deleted ids, a parent among its own parts, self-relations, an empty parts list, the relation_kind vocabulary — surface as coded 4xx bodies through the existing error mapping. What is checked locally is shape: a present-but-wrong-typed part_ids throws rather than being dropped, because a silently omitted selector turns a malformed request into a differently-shaped one whose rejection reads as though nothing had been sent. Same reasoning as the existing TryReadInt guard. An explicitly supplied "" is forwarded, so the caller gets the server's invalid-value error rather than a misleading "required".

No client-side interpretation of server error bodies. The 404 is deliberately non-enumerating — identical whether the item doesn't exist, isn't visible, or is in another repo — so elaborating it client-side would undermine that design. The 400 detail is already in the body the existing mapping surfaces.

McpSchemaProperty gains an optional Items. These are the first array-typed properties in any of these MCP servers, and an array with no items is incomplete JSON Schema — a strict client can reject it and a model has to guess the element type. Optional and trailing, so every existing new("string", …) call is unchanged, and omitted from the wire when null.

Verification

Mutation-verified across all three rounds; each mutant fails exactly one test and nothing else:

Mutant Killed by
URL escape dropped Item_url_builds_the_route_and_escapes_the_id
source argument exposed No_tool_advertises_a_server_owned_source_or_declared_by_argument
items schema dropped Array_properties_declare_their_element_type
Dot guard removed Item_url_rejects_dot_segment_ids…
Dot guard made over-broad the same test — it fails in both directions
Empty-string drop restored Relation_body_forwards_an_explicitly_empty_string…
Null-as-absence restored Breakdown_body_rejects_an_explicit_null_part_ids
Relations route paired with the breakdown body Dispatch_declare_work_relation_posts_the_relation_body_not_the_breakdown_body

That last one matters: the helper tests verify URL and body construction independently, so none of them would catch a switch entry using the wrong method, suffix, or body builder. Six dispatch tests now drive HandleToolCallAsync through a capturing HttpMessageHandler and assert the method, URL and body per tool, plus that a missing id fails before any request is built.

McpWorkItemsServerTests 36/36; 254 green across 13 MCP-adjacent classes, confirming the shared-record change disturbs no other server's schema.

Review rounds

Round 1 — one Medium, four Low. The dot-segment hole (my own comment had claimed no id could escape its segment); {"part_ids": null} read as absence, the exact failure the shape rule exists to prevent; explicitly-empty relation strings dropped; the source/declared_by test proving non-advertisement while claiming non-acceptance; and no dispatch-level coverage at all.

Round 2 — three Low. My dot guard over-rejected, and my own test had pinned that over-broad behaviour; a bare catch could report an unrelated failure as a type error (fixed at all three extraction sites, not just the flagged one); and the non-vacuity guard froze the tool count so an unrelated new tool would have failed it.

Round 3 — clean, with JsonValue.TryGetValue<string> semantics verified against the .NET runtime source rather than assumed.

Deliberately not done

Skill text teaching when to declare a breakdown versus a relation. There is no work-items skill today — declare_work_item has never had one either — so this would mean introducing a new auto-registered skill that costs context in every session. That is a product call, not part of wiring the tools up, so the when/why lives in the tool descriptions for now.

🤖 Generated with Claude Code

realtonyyoung and others added 3 commits August 3, 2026 12:38
The server shipped five routes for declared work breakdown and relations, and no
agent could reach any of them: `kcap mcp workitems` exposed only declare_work_item
and get_session_work_items, and the server's design deliberately provides no UI
path. The feature was inert.

Adds five tools over the existing routes:

  declare_work_breakdown  POST /api/work-items/{parentId}/breakdown
  retract_work_breakdown  POST /api/work-items/{parentId}/breakdown/retract
  declare_work_relation   POST /api/work-items/{fromId}/relations
  retract_work_relation   POST /api/work-items/{fromId}/relations/retract
  get_work_item_topology  GET  /api/work-items/{id}/topology

Design decisions worth naming:

- No tool accepts `source` or `declared_by`. The server resolves both from the
  authenticated caller and rejects a source of "user" outright, so exposing either
  would be an argument the server ignores at best and a spoofing surface at worst.
  Asserted by a test over every tool rather than left to reviewer vigilance.

- Ids are REQUIRED with no fallback. ResolveSessionId can default to the ambient
  session because "the session I am running in" is unambiguous; there is no ambient
  work item, so a default would silently attach the wrong edge of the graph.

- Ids are URL-escaped, so one containing a slash or percent cannot walk out of its
  path segment into a different route.

- Semantic validation is NOT duplicated client-side. The rules the server owns —
  cross-repo edges, unknown/deleted ids, a parent among its own parts, self-
  relations, an empty parts list, the relation_kind vocabulary — surface as coded
  4xx bodies through the existing error mapping. What IS validated locally is
  SHAPE: a present-but-wrong-typed part_ids throws rather than being dropped,
  because a silently omitted selector turns a malformed request into a
  differently-shaped one whose rejection reads as if nothing had been sent. Same
  reasoning as the existing TryReadInt guard.

- relation_kind is passed through unvalidated on purpose: the server owns the
  vocabulary, so a client-side enum would drift from it as kinds are added, and the
  server's coded rejection names the real reason.

McpSchemaProperty gains an optional Items: these are the first `array`-typed
properties in any of these MCP servers, and an array with no `items` is incomplete
JSON Schema — a strict client can reject it and a model has to guess the element
type. Optional and trailing, so every existing call is unchanged, and omitted from
the wire when null.

Registration needed no change: kcap-workitems is already Claude-Code-plugin-only.

Mutation-verified — dropping the URL escape, exposing a `source` argument, and
dropping the items schema each fail exactly one test and nothing else.

McpWorkItemsServerTests 25/25; 254 tests green across 13 MCP-adjacent classes,
confirming the shared-record change disturbs no other server's schema.

Not done: teaching skill text when to declare a breakdown versus a relation. There
is no work-items skill today — declare_work_item has never had one either — so this
would mean introducing a new auto-registered skill that costs context in every
session. That is a product call, not part of wiring the tools up, so the when/why
lives in the tool descriptions for now.
…istent

Codex review round 1 — one Medium, four Low.

Medium: escaping alone did not contain an id to its path segment. `.` is
unreserved in RFC 3986, so EscapeDataString leaves it untouched and URI
normalization then REMOVES the segment: an id of "." reached
/api/work-items/breakdown and ".." reached /api/breakdown — a different route
whose response would be attributed to the id the caller passed. My own comment
claimed no id could do this. All-dot ids are now rejected outright; a dot inside an
otherwise-real id is still fine, which the test pins.

Low 1: the shape/semantic split was applied inconsistently. `{"part_ids": null}`
is a PRESENT wrong shape, but the `is { } node` form read it as absence and
silently omitted the field — the exact failure mode the rule exists to prevent.
Presence is now detected with TryGetPropertyValue and an explicit null throws.

Low 2: to_id/relation_kind dropped an explicitly-supplied empty string, so the
caller got the server's "required" error when they HAD supplied the value. Every
supplied string is now forwarded verbatim, including ""; absence stays absence and
a present non-string still fails locally.

Low 3: the "no tool accepts source/declared_by" test proved non-ADVERTISEMENT, not
non-acceptance — without additionalProperties:false a schema does not forbid a
caller sending them. Renamed to match what it checks, given a non-vacuity guard on
the tool count, and paired with a new test asserting the real guarantee: the body
builders never FORWARD either key even when supplied.

Low 4: nothing exercised the dispatch switch, so a wrong method, wrong suffix, or
mismatched body builder would have gone unnoticed. Six tests now drive
HandleToolCallAsync through a capturing HttpMessageHandler and assert the method,
URL and body per tool, plus that a missing id fails before any request is built.

Mutation-verified, each killing exactly one test: dot guard removed; empty-string
drop restored; null-as-absence restored; and the relations route paired with the
breakdown body — which only the new dispatch test catches.

McpWorkItemsServerTests 36/36; 222 green across 9 MCP-adjacent classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st instead of catching

Codex review round 2, three Low.

1. My dot guard over-rejected. Only "." and ".." are RFC dot segments; "..." and
   longer runs are ordinary path segments, so refusing them would reject an id the
   server might accept. Narrowed to exactly those two — and my own test had pinned
   the over-broad behaviour, so it is now written to fail in BOTH directions:
   mutation-verified against a guard that is too broad AND one that is absent.
   Percent-encoded dots confirmed inert: EscapeDataString escapes the '%' itself, and
   standard URI processing does not decode twice.

2. A bare catch around GetValue<string> would report an unrelated failure as a type
   error. Replaced with a JsonValue/TryGetValue shape test — applied at all three
   extraction sites, not only the one flagged. Renamed CopyRequiredishString to
   CopySuppliedString, which is what it actually does: forward any supplied string
   including "", reject a present null or non-string, leave an absent key absent.

3. The non-vacuity guard froze the tool count at 7, so adding an unrelated tool
   would have failed it. Loosened to greater-than-zero; the exact set is already
   pinned by Tools_list_exposes_the_declare_and_breakdown_surface, and duplicating
   that pin here bought nothing.

McpWorkItemsServerTests 36/36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

AI-1718

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Expose declared work-breakdown and relation MCP tools

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Adds five new MCP tools (declare_work_breakdown, retract_work_breakdown,
 declare_work_relation, retract_work_relation, get_work_item_topology) wiring existing server
 routes into kcap mcp workitems.
• Extends McpSchemaProperty with an optional Items field to support JSON-Schema-valid
 array-typed tool parameters.
• Rejects source/declared_by from tool schemas and strips them from request bodies to prevent
 identity spoofing.
• Requires all work-item ids with no fallback, escapes them, and blocks dot-segment (./..) path
 traversal into unintended routes.
• Adds extensive unit tests covering schema shape, body builders, URL escaping, and end-to-end
 dispatch via a fake HTTP handler.
Diagram

graph TD
    A["AI Agent"] --> B["kcap mcp workitems"]
    B --> C["BuildToolsList schema"] --> D["ItemUrl + body builders"]
    D --> E["HandleToolCallAsync dispatch"]
    E --> F[("kcap-server REST API")]
    F --> G["breakdown / relations / topology routes"]
Loading
High-Level Assessment

The PR follows the existing pattern used for declare_work_item/get_session_work_items, extending the same dispatch/body-builder structure rather than introducing new abstractions. Given the small, well-scoped route surface and existing conventions in the file, this direct extension is the right level of investment; a generic schema-driven proxy or code-gen approach would add complexity disproportionate to five routes.

Files changed (3) +505 / -5

Enhancement (2) +186 / -2
McpReviewServer.csAdd optional Items field to McpSchemaProperty for array types +6/-1

Add optional Items field to McpSchemaProperty for array types

• Extends the McpSchemaProperty record with an optional Items property so array-typed tool parameters declare their element type, keeping JSON Schema valid and omitted from the wire when null.

src/Capacitor.Cli/Commands/McpReviewServer.cs

McpWorkItemsServer.csWire up five new declared work-breakdown and relation MCP tools +180/-1

Wire up five new declared work-breakdown and relation MCP tools

• Adds dispatch cases, URL builders, and body builders for declare/retract_work_breakdown, declare/retract_work_relation, and get_work_item_topology. Enforces required, escaped, dot-segment-safe ids and strips server-owned fields (source, declared_by) from request bodies while forwarding caller-supplied values verbatim otherwise.

src/Capacitor.Cli/Commands/McpWorkItemsServer.cs

Tests (1) +319 / -3
McpWorkItemsServerTests.csAdd comprehensive tests for new breakdown/relation MCP tools +319/-3

Add comprehensive tests for new breakdown/relation MCP tools

• Adds tests covering tool schema exposure, rejection of source/declared_by advertisement and forwarding, required-id enforcement, array item typing, URL escaping and dot-segment rejection, body builder shape validation, and full dispatch through a fake HTTP handler.

test/Capacitor.Cli.Tests.Unit/McpWorkItemsServerTests.cs

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📜 Skill insights (0)

Grey Divider


Action required

1. README workitems tools outdated 📘 Rule violation ⚙ Maintainability
Description
This PR adds five new kcap mcp workitems tools, but README.md still documents the workitems MCP
server as providing only two tools. This violates the requirement to update README.md in the same PR
for user-facing CLI surface changes.
Code

src/Capacitor.Cli/Commands/McpWorkItemsServer.cs[R455-458]

+        new("declare_work_breakdown",
+            "Declare that a work item is broken down into parts (sub-items). Idempotent: re-declaring an "
+          + "existing part is accepted and reported as existing rather than created. A part can have at "
+          + "most one parent, and all items must live in the same repository.",
Evidence
McpWorkItemsServer.BuildToolsList() now registers additional user-facing MCP tools
(declare_work_breakdown, retract_work_breakdown, declare_work_relation,
retract_work_relation, get_work_item_topology), but the README's Work items MCP server section
still claims it provides only two tools and lists only those two, leaving the docs inaccurate for
the changed CLI surface.

CLAUDE.md: Update README.md in the Same PR for Any User-Facing CLI Surface Change
src/Capacitor.Cli/Commands/McpWorkItemsServer.cs[451-495]
README.md[534-546]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR expands the `kcap mcp workitems` tool surface (breakdowns, relations, topology), but `README.md` still states the server provides only `declare_work_item` and `get_session_work_items`.

## Issue Context
Compliance requires updating `README.md` in the same PR for any user-facing CLI surface change, including relevant per-command documentation (and the quick-start/overview if it describes the affected surface).

## Fix Focus Areas
- README.md[179-196]
- README.md[534-548]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +455 to +458
new("declare_work_breakdown",
"Declare that a work item is broken down into parts (sub-items). Idempotent: re-declaring an "
+ "existing part is accepted and reported as existing rather than created. A part can have at "
+ "most one parent, and all items must live in the same repository.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Readme workitems tools outdated 📘 Rule violation ⚙ Maintainability

This PR adds five new kcap mcp workitems tools, but README.md still documents the workitems MCP
server as providing only two tools. This violates the requirement to update README.md in the same PR
for user-facing CLI surface changes.
Agent Prompt
## Issue description
The PR expands the `kcap mcp workitems` tool surface (breakdowns, relations, topology), but `README.md` still states the server provides only `declare_work_item` and `get_session_work_items`.

## Issue Context
Compliance requires updating `README.md` in the same PR for any user-facing CLI surface change, including relevant per-command documentation (and the quick-start/overview if it describes the affected surface).

## Fix Focus Areas
- README.md[179-196]
- README.md[534-548]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@realtonyyoung
realtonyyoung merged commit b6a211e into main Aug 3, 2026
10 of 11 checks passed
@realtonyyoung
realtonyyoung deleted the tonyyoung/ai-1718-breakdown-mcp-tools branch August 3, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant