Skip to content

MCP Decisions

Distilled from 12 DECISIONS.md files. Each entry is a real fork in the road — what we chose, what we passed on, and why. Implementation details that aren't a tradeoff are NOT here (read the codebase for those).


Feature flags: MCP tools respect Pennant gates that protect HTTP routes

  • Chose: Each feature-gated MCP tool early-returns via Feature::for($tenant)->active('<flag>') at the top of handle(), mirroring EnsureFeatureActive middleware on the equivalent HTTP route.
  • Rejected: MCP tools bypass feature flags (consistent with "MCP tools skip HTTP middleware") so all flagged operations are reachable via MCP regardless of tenant entitlement.
  • Why: A flagged feature is a tenant entitlement, not an HTTP-layer concern. Bypassing means a tenant without the feature can still drive it via Claude/CLI — confusing UX and potentially a billing/compliance issue. Cost is one early-return per tool with TenantContext already injected.
  • Source: KD-XXXX (MCP & CLI report tools parity)

Issue resolution by ID or key — covered in scoped + non-scoped contexts

  • Chose: Override both Issue::resolveRouteBinding() and Project::resolveChildRouteBinding() plus a shared ResolvesIssue trait on MCP tools.
  • Rejected: Only override Issue::resolveRouteBinding() (works in non-scoped contexts only).
  • Why: Laravel's scopeBindings() calls the parent's resolveChildRouteBinding, not the child's resolveRouteBinding, so single-side override would have left key-based URLs broken on scoped API routes.
  • Source: KD-0119

Merging MoveIssueTool into UpdateIssueTool

  • Chose: Route lane changes through UpdateIssueAction via SaveIssueData.laneId.
  • Rejected: Branch on lane_id and call UpdateIssueBoardAction to mirror MoveIssueTool's exact path.
  • Why: UpdateIssueAction already handles lane-change detection and notifications; using the bulk board action for a single-issue MCP move would have meant two action paths for one tool.
  • Source: KD-0224

Marketing tool count after consolidation

  • Chose: Keep "20+ MCP tools" in landing/config copy, only update the docs pages with literal counts.
  • Rejected: Update all ~12 marketing surfaces to "15 tools".
  • Why: "20+" remains accurate when counting tools + resources together (15 + 10 = 25), so touching all marketing surfaces buys honesty we already have at the cost of a less impressive number.
  • Source: KD-0224

Removed tools — delete vs deprecate

  • Chose: Hard-delete the unused tools.
  • Rejected: Keep them with a deprecation marker.
  • Why: They were never invoked via MCP; keeping dead code costs maintenance and counts against the model's tool cap.
  • Source: KD-0224

Epic linking authorization

  • Chose: Allow any user with issue update permission to set epic_id; only validate that the epic belongs to the same project.
  • Rejected: Require Team Lead+ permission on the epic itself.
  • Why: Setting epic_id is conceptually an issue update, not an epic update; all project members can already see epics, so gating on epic-level permission would over-restrict normal users without protecting anything.
  • Source: KD-0320

Signed S3 URLs for MCP attachments

  • Chose: Build a Resources-layer AttachmentMcpData DTO that wraps Storage::temporaryUrl().
  • Rejected: Add Storage to the MCP arch-test allow-list (currently only Gate is permitted).
  • Why: The arch rule keeps MCP handlers thin; relaxing it for one tool would erode the constraint and invite future facade leakage. The DTO mirrors the existing ProfilePictureUrlsData pattern.
  • Source: KD-0368

CLI download default destination

  • Chose: Default kendo issue download output to ~/Downloads/<filename>.
  • Rejected: Default to current working directory (curl -O style).
  • Why: Developers run the CLI inside git repositories; CWD-default would silently drop files into version control. Requiring --output was rejected as too verbose for the common case.
  • Source: KD-0368

CLI streaming download

  • Chose: Use client.Do() + io.Copy to stream attachment bytes directly to disk.
  • Rejected: Reuse the existing client.Get() which reads the whole body into memory.
  • Why: Recordings and design files can blow the heap; streaming costs a few extra lines and removes an OOM risk on large attachments.
  • Source: KD-0368

Attachment fetch tool size limits

  • Chose: Two tiers — 5 MB binary (image), 500 KB text.
  • Rejected: Three tiers (image, PDF, text) or a single 5 MB cap.
  • Why: PDF is deferred (D7 below), so a separate PDF tier is dead config. A single 5 MB cap would let a 4.9 MB CSV blow the LLM's context budget.
  • Source: KD-0453

PDF support in fetch-attachment v1

  • Chose: Defer PDF — return Response::error('MIME … not supported') and document a follow-up.
  • Rejected: Return PDF as Response::image(mime=application/pdf), base64-encode in text, or transcode via smalot/pdfparser.
  • Why: Laravel MCP's Blob::toTool() blocks blob-in-tools at the SDK level. Faking it as an image violates the MCP spec; base64 consumes ~1.3× file size in tokens; PDF→text transcoding was explicitly out of scope.
  • Source: KD-0453

Orphan attachments in MCP

  • Chose: Reject orphan attachments (attachable_type = null) before the Gate check, fail-fast.
  • Rejected: Let the existing AttachmentPolicy::download accept them (it does today).
  • Why: Orphan IDs aren't discoverable via MCP resources, so supporting them only helps attacker-style ID enumeration with no real use case.
  • Source: KD-0453

Send-feedback delivery path

  • Chose: Have the MCP tool call the existing SendFeedbackAction which POSTs externally to the kendo.dev feedback URL.
  • Rejected: Mirror CreateReportTool and write directly to project 1 locally.
  • Why: Local writes only work when the MCP user's tenant is Kendo.dev itself; the whole point is letting any tenant submit feedback, so the cross-tenant Gate gymnastics make local-write a non-starter.
  • Source: KD-0457

Send-feedback title contract

  • Chose: Make title required end-to-end; the in-app bubble synthesises "Feedback van {firstName} {lastName}" on the frontend before POSTing.
  • Rejected: Keep the existing backend fallback that synthesises the title inside SendFeedbackAction.
  • Why: A single contract at every layer beats backend fallback logic that mirrors itself across two callers (bubble + MCP). One title-generation site, no dual-mode action.
  • Source: KD-0457

Send-feedback CLI auth path

  • Chose: Have the CLI POST to the user's own backend /api/feedback, which forwards externally.
  • Rejected: Bake the central FEEDBACK_REPORT_TOKEN into the CLI binary and call the external URL directly.
  • Why: Embedding a privileged system token in every user's laptop is a credential-leak risk; routing through the user's authenticated backend reuses their personal Bearer token.
  • Source: KD-0457

  • Chose: Query parameter ?report={id} on the existing reports overview page, written via router.replace.
  • Rejected: Nested child route /reports/:reportId with a router-view in the split-pane.
  • Why: Nested routes would require restructuring the inline split-pane and risked breaking drag/reorder/selection UX. router.replace also avoids history-stack pollution from per-row selection.
  • Source: KD-0459

MCP report resource URI shape

  • Chose: Flat kendo://reports/{reportId} and derive project from report.project_id.
  • Rejected: Nested kendo://projects/{projectId}/reports/{reportId}.
  • Why: Report ID is globally unique and LLMs sharing just #112 would fail to construct the nested URI without first discovering the project.
  • Source: KD-0459

Resource-layer feature flag and audit logging

  • Chose: Apply the ChecksReportFeature trait and AiMcpLogger to ReportResource (first MCP resource to do so).
  • Rejected: Follow the IssueResource convention (access gate only, no flag, no logger).
  • Why: AC required tool/resource parity for feature gating and observability; without it, a tenant could disable the feature on tools but still fetch reports via the resource path.
  • Source: KD-0459

Kendo Today widget data fetch shape

  • Chose: Single aggregator KendoTodayTool + BuildTodaySnapshotAction returning all three data sources atomically.
  • Rejected: Have the iframe fire three existing tools in Promise.all.
  • Why: One audit-log row per widget load instead of three; atomic snapshot semantics; simpler error recovery.
  • Source: KD-0481

Sharing query logic between Actions

  • Chose: Add a query scope scopeAssignedOpenFor to the Issue model.
  • Rejected: Duplicate the ~10 LOC query builder in both Actions, or inject a Service to share it.
  • Why: Deptrac forbids Actions → Actions, so one Action can't inject another. A query scope is the canonical Laravel mechanism and avoids both duplication and a single-purpose Service abstraction.
  • Source: KD-0481

MCP error handling discipline

  • Chose: Catch Throwable, log structured context via LogManager, return a generic user-safe Response::error.
  • Rejected: Rethrow the raw Throwable as several existing tools do.
  • Why: With APP_DEBUG=true the MCP layer can leak stack traces back to the LLM. New tools must set the migration target away from the legacy rethrow pattern, not cement it; widgets render in user-visible iframes which makes the leak more dangerous.
  • Source: KD-0481, KD-0550, KD-0551

MCP Apps content shape

  • Chose: Response::text(html)->withMeta(['ui' => …]) with mimeType = 'text/html;profile=mcp-app', validated by a 30-min spike.
  • Rejected: Build a local HtmlContent + HtmlResponse mirroring the existing ImageContent workaround.
  • Why: Laravel MCP's Text::toResource() already emits the exact wire shape the spec needs; the spike confirmed it works, saving ~100 LOC of local extension code. Fallback to local classes was kept as a contingency.
  • Source: KD-0481

Tenant host injection into widget HTML

  • Chose: Server-side template replacement at resource read time.
  • Rejected: Pass via _meta.ui.initialData or hardcode kendo.dev.
  • Why: Hardcoding breaks multi-tenancy. initialData requires widget-side glue to read and inject; a one-line string replacement using config('app.url') is cheaper and keeps the widget free of host detection.
  • Source: KD-0481

Widget bridge generic shape

  • Chose: Generic createBridge<TSnapshot> taking an applySnapshot(state, snapshot) callback.
  • Rejected: Bridge exposes Ref<TSnapshot | null> for widgets to read via computed.
  • Why: Nullable refs force every consumer chain to pay the null-check cost; a callback gives widgets full control over how snapshot fields land in reactive state and keeps the SDK plumbing separate from domain shape.
  • Source: KD-0486

MCP widget shared-layer location

  • Chose: Place under frontend/src/apps/mcp/shared/ as a sub-app-shared layer.
  • Rejected: Place under frontend/src/shared/mcp/ (the global shared layer).
  • Why: The ext-apps SDK is a widget-only concern; putting it under global src/shared/ would transitively pull it into tenant + central build/test graphs, violating the spirit of src/shared/ as truly cross-app reusable code.
  • Source: KD-0486

Bulk-assign-to-sprint authorization

  • Chose: Reuse IssuePolicy::updateBoard (the same gate the drag-drop batch path uses).
  • Rejected: Add a new bulkAssignToSprint policy method.
  • Why: Identical permission semantics already exist; a synonym method dilutes the policy surface and adds arch-test wiring for no behavioral gain.
  • Source: KD-0549

Bulk-assign cap

  • Chose: Cap issue_ids at 100 per call.
  • Rejected: No cap, relying on the thin-payload broadcast to fit any N under Reverb's 10 KB limit.
  • Why: Defensive bound on audit-log volume, SQL bind-list size, and unexpected callers. Matches kendo's existing 100-row default search limit.
  • Source: KD-0549

Bulk-assign broadcast strategy

  • Chose: Single thin-payload positionsUpdated (~70 B/issue) broadcast — accept the "newly-in-scope" UI gap on Board.
  • Rejected: Full IssueResourceData per issue (bulkUpdated) or hybrid full+positions broadcasts.
  • Why: Full payloads (~1–2 KB each, up to 65 KB with long descriptions) blow the 10 KB Reverb cap at ~6 issues. The Board edge case (issues moving INTO active sprint don't appear locally until refresh) is a known limitation tracked separately.
  • Source: KD-0549

Tool description framing for overlapping surfaces

  • Chose: Explicit "Prefer this tool over looping update-issue-tool whenever the only change is sprint membership" framing.
  • Rejected: Soft hint "More efficient than looping update-issue-tool".
  • Why: Anthropic's MCP guidance warns against overlapping tool surfaces; without retiring sprint_id from update-issue-tool, prescriptive wording is the only way to prevent the LLM picking the slow path even at N=1.
  • Source: KD-0549

Single-purpose vs generic bulk Action

  • Chose: Build a single-purpose BulkAssignIssuesToSprintAction.
  • Rejected: Build a generic BulkUpdateIssuesAction taking partial-fields DTOs.
  • Why: Each field family has different broadcast semantics — sprint changes go through thin positions, assignee changes need My Issues fanout, lane changes need notifications. A generic abstraction would prematurely couple three distinct downstream surfaces.
  • Source: KD-0549

start-work-on-issue transaction owner

  • Chose: New composing StartWorkOnIssueAction opens one outer transaction; inner Actions become Laravel savepoints.
  • Rejected: Have the MCP tool inject ConnectionInterface and own the transaction.
  • Why: ADR-0011 mandates Actions own transactions; pushing transaction control into the protocol layer violates the architecture and would force arch-test exclusions later. Mirrors the PromoteReportsAction precedent.
  • Source: KD-0550

start-work lane resolution

  • Chose: Require agent-provided lane_id (validated against the issue's project).
  • Rejected: Server-side title match Lane::where('title', 'In Progress') per the published spec.
  • Why: Lane titles are user-editable per project; title-matching is fragile. The companion prepare-issue-context tool returns lanes, so lane choice belongs in the gather step.
  • Source: KD-0550

Idempotent re-run of start-work

  • Chose: Pre-flight equality checks — skip UpdateIssueAction and CreateIssueBranchLinkAction when desired state already matches.
  • Rejected: Always run all steps and let inner Actions handle duplicates.
  • Why: CreateIssueBranchLinkAction throws BranchAlreadyLinkedException on duplicates and the exception doesn't structurally distinguish "same issue" from "different issue". Pre-flight checks also avoid no-op audit-log noise from UpdateIssueAction.
  • Source: KD-0550

start-work response shape

  • Chose: Hand-build a structured response with nested {id, title} blocks for lane/sprint, mirroring UpdateIssueTool.
  • Rejected: Return IssueResourceData::from(...)->toArray() (flat IDs, no nested objects).
  • Why: IssueResourceData::EAGER_LOAD deliberately omits lane/assignee/sprint to keep its shape lean; the agent calling start-work needs to render a confirmation card without firing a follow-up read.
  • Source: KD-0550

prepare-issue-context payload composition

  • Chose: Mirror existing MCP resource shapes (hand-rolled per resource), do NOT introduce HTTP ResourceData DTOs into the MCP layer.
  • Rejected: Reuse IssueResourceData::from() etc. as the issue spec suggested.
  • Why: No existing MCP resource uses HTTP ResourceData DTOs; doing so would set a new convention and risk drift if HTTP shape evolves and silently breaks MCP contracts. Layer unification is its own epic.
  • Source: KD-0551

prepare-issue vs prepare-project split

  • Chose: Two sibling tools (prepare-issue-context + prepare-project-context), each with one entity in scope.
  • Rejected: One fat tool with conditional fields via include_project_meta flag.
  • Why: Conditional response shapes are an outputSchema() lie that strict MCP clients reject; the agent can fire both tools in parallel for the same wall-clock cost when both gathers are needed.
  • Source: KD-0627

prepare-project-context field shape

  • Chose: Mirror existing kendo:// resource fields exactly; add members_count companion field; keep members[] unbounded.
  • Rejected: Trim to a "minimal context" shape, add all_sprints[] and epics[], or paginate members.
  • Why: Trimming defeats the consolidation goal (callers would still need fan-out for trimmed fields). Pagination is premature — worst case ~50 members × 80 bytes ≈ 4 KB.
  • Source: KD-0627

prepare-project-context migration strategy

  • Chose: Single PR with breaking shape change; no KendoServer::$version bump.
  • Rejected: Two PRs (add new tool first, slim old tool in follow-up) with a deprecation period.
  • Why: Atomic change avoids a window of overlapping responsibilities between the two tools. We don't currently version-pin anywhere; bumping for one breaking change sets a maintenance precedent we'd have to honor on every future schema change.
  • Source: KD-0627

  • Chose: Inline resolution in UnlinkBranchTool (walk issue->branchLinks() filtered by branch_name) rather than reusing the create-path's repo resolver.
  • Rejected: Generalise ResolveBranchTargetRepoAction to also locate an existing link by repo_full_name.
  • Why: "Find the target repo for a new branch" and "find the existing link to delete" are different problems with different error semantics (the resolver raises "no primary repo" where the unlinker wants "no link found"), so bending one helper to do both muddies it.
  • Source: KD-0777

Hint-mismatch behaviour on a destructive tool

  • Chose: Strict — when a supplied repo_full_name doesn't match the single candidate link, return an error listing the actual repo and refuse to delete.
  • Rejected: Lenient — treat the hint as advisory and unlink the one match anyway.
  • Why: A destructive op should refuse on stale or wrong assumptions; lenient matching would let an agent that thinks it's unlinking acme/foo succeed against acme/bar and never notice.
  • Source: KD-0777

"Matches the create path" means parity, not exceeding it

  • Chose: Cover unlink audit via the standard AiMcpLog row every MCP tool emits, since the create path has no per-entity audit logger either.
  • Rejected: Introduce a first-class IssueBranchLinkAuditLogger to make unlinks directly queryable.
  • Why: The AC said "matches the create path," and a per-entity logger would force a symmetric retrofit on the create path plus a migration and arch-test wiring — scope creep beyond literal parity.
  • Source: KD-0777

Agent-facing response shape vs reusing the SPA delete DTO

  • Chose: Emit a rich inline payload ({id, branch_name, repository.name, issue.id}) snapshotted before the Action deletes the row.
  • Rejected: Reuse the web UI's DeletedIssueBranchLinkResourceData ({id, issue_id}).
  • Why: The agent needs to confirm exactly what was deleted in one round-trip; the lean SPA shape would force the LLM to re-read the issue to name the branch — the agent surface has different requirements than the SPA surface.
  • Source: KD-0777

Label attach at create-issue — separate sync call vs extending the issue Action

  • Chose: create-issue runs CreateIssueAction, then makes a separate SyncIssueLabelsAction call when label_ids is supplied.
  • Rejected: Extend CreateIssueData/UpdateIssueData and the issue Actions to sync labels inside the issue transaction (like blockedBy/blocks).
  • Why: The web paths don't pass labels, so folding sync into the shared issue Action adds an unused parameter to production web code and modifies its unit tests; the separate call has zero blast radius and mirrors the web's deliberate split, accepting a narrow two-transaction partial-failure window that's retryable via sync-issue-labels.
  • Source: KD-0829

Changing labels on an existing issue — dedicated tool vs folding into update-issue

  • Chose: A dedicated sync-issue-labels tool; keep label_ids on create-issue only and drop it from update-issue.
  • Rejected: Fold label changes into update-issue so attach happens via create/update only (matching the issue's literal scope).
  • Why: A label-only update-issue call would re-invoke UpdateIssueAction with all-current values — a no-op re-save emitting a spurious "Issue Updated" audit row and broadcast — unless update-issue special-cases label-only changes; the dedicated tool mirrors the web's separate label endpoint 1:1 and gives one canonical way to change labels.
  • Source: KD-0829

Label output shape on issue-returning tools

  • Chose: Single-issue tools emit full labels: [{id, name, color}]; list tools (search-issues ≤100, get-my-issues ≤500) emit label_ids: [int] only.
  • Rejected: Return full label objects uniformly across single-issue and list tools.
  • Why: Hydrating full label rows × up to 500 results is an efficiency risk, and the id→name/color map is already available from prepare-project-context, so list tools only need ids (consistent with get-my-issues already being id-only for lane/sprint).
  • Source: KD-0829

Label read gating — bundle vs dedicated tool

  • Chose: prepare-project-context includes labels on project access alone, while the standalone get-labels tool additionally gates on the viewAny Label permission.
  • Rejected: Apply one uniform gate to both surfaces.
  • Why: The aggregate bundle follows the lanes/sprints/members precedent (project access, no per-resource gate) and label_ids already leak to project-access holders via issue tools, whereas the dedicated read tool faithfully mirrors the web label-index route's viewAny gate.
  • Source: KD-0829

@mention pre-processing — skill guidance vs server-side retry DTO

  • Chose: Ship the skill-guidance slice only (markdown edit to kendo-mcp/SKILL.md), with no backend changes.
  • Rejected: Build the server-side retry DTO that signals unresolved mentions so the agent can retry.
  • Why: The skill slice delivers the ambiguous→resolved win at zero backend/test cost, and observed unresolved-mention rates should decide whether the backend DTO earns its cost rather than paying it speculatively.
  • Source: KD-0860

Where @mention pre-processing guidance lives

  • Chose: Put the guidance in kendo-mcp/SKILL.md only, as the canonical MCP-usage reference other skills defer to.
  • Rejected: Duplicate the guidance across all content-writing skills (newbranch, plan-feature, triage-reports).
  • Why: A single source of truth avoids drift when the guidance evolves; the risk that another skill runs without loading kendo-mcp in time is acceptable because server-side resolution remains the fallback.
  • Source: KD-0860

update-comment-tool excluded from @mention pre-processing

  • Chose: Exclude update-comment-tool from the pre-processing guidance and document why.
  • Rejected: Include it for symmetry with add-comment (it also accepts a content field).
  • Why: UpdateCommentAction injects neither the mention resolver nor the notifier, so the server never resolves mentions on comment updates — pre-processing a field the server ignores is pointless.
  • Source: KD-0860

Agent behaviour when member[] is missing from context

  • Chose: Skip @mention pre-processing and fall back to server-side resolution when prepare-project-context hasn't run (so member[] is unavailable).
  • Rejected: Call prepare-project-context on demand to guarantee pre-processing always runs.
  • Why: The server-side resolver is the reliable fallback and the gather phase already requires the bundle for most write flows, so an extra round-trip just to force pre-processing isn't worth it.
  • Source: KD-0860

Sprint listing — discoverable tool vs existing resource

  • Chose: Add a GetSprintsTool Tool class mirroring GetEpicsTool, alongside the existing SprintsResource.
  • Rejected: Just promote SprintsResource discoverability via more explicit KendoServer instructions.
  • Why: Resources aren't returned by ToolSearch — only Tool classes are — so agents miss the resource in practice; the tool is the discoverable agent-facing surface while the resource stays as a lightweight direct-URI read path.
  • Source: KD-0891

Sprints in prepare-project-context — Planned+Active vs all

  • Chose: Include only Planned + Active sprints in the bundle; Completed sprints are fetched via GetSprintsTool.
  • Rejected: Include all sprints (Planned + Active + Completed) in prepare-project-context.
  • Why: The query cost is identical, but a year-old project can accumulate 20+ completed sprints that grow the payload unbounded and aren't planning context — the bundle is for planning/action, so historical sprints belong behind a separate call.
  • Source: KD-0891

GetSprintsTool return shape — sprint fields only vs embedded issues

  • Chose: Return sprint fields only (id, title, status, dates, issues_count), mirroring SprintsResource.
  • Rejected: Embed the full issue list per sprint, mirroring GetEpicsTool's deeper shape.
  • Why: Agents needing a sprint's issues call search-issues-tool filtered by sprint_id, so embedding issues would add N+1 hydration for a need that's already served elsewhere.
  • Source: KD-0891