Appearance
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 ofhandle(), mirroringEnsureFeatureActivemiddleware 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
TenantContextalready 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()andProject::resolveChildRouteBinding()plus a sharedResolvesIssuetrait on MCP tools. - Rejected: Only override
Issue::resolveRouteBinding()(works in non-scoped contexts only). - Why: Laravel's
scopeBindings()calls the parent'sresolveChildRouteBinding, not the child'sresolveRouteBinding, 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
UpdateIssueActionviaSaveIssueData.laneId. - Rejected: Branch on
lane_idand callUpdateIssueBoardActionto mirror MoveIssueTool's exact path. - Why:
UpdateIssueActionalready 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
AttachmentMcpDataDTO that wrapsStorage::temporaryUrl(). - Rejected: Add
Storageto the MCP arch-test allow-list (currently onlyGateis 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
ProfilePictureUrlsDatapattern. - Source: KD-0368
CLI download default destination
- Chose: Default
kendo issue downloadoutput to~/Downloads/<filename>. - Rejected: Default to current working directory (curl
-Ostyle). - Why: Developers run the CLI inside git repositories; CWD-default would silently drop files into version control. Requiring
--outputwas rejected as too verbose for the common case. - Source: KD-0368
CLI streaming download
- Chose: Use
client.Do()+io.Copyto 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 viasmalot/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::downloadaccept 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
SendFeedbackActionwhich POSTs externally to the kendo.dev feedback URL. - Rejected: Mirror
CreateReportTooland 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
titlerequired 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_TOKENinto 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
Report deep-link URL shape
- Chose: Query parameter
?report={id}on the existing reports overview page, written viarouter.replace. - Rejected: Nested child route
/reports/:reportIdwith 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.replacealso avoids history-stack pollution from per-row selection. - Source: KD-0459
MCP report resource URI shape
- Chose: Flat
kendo://reports/{reportId}and derive project fromreport.project_id. - Rejected: Nested
kendo://projects/{projectId}/reports/{reportId}. - Why: Report ID is globally unique and LLMs sharing just
#112would fail to construct the nested URI without first discovering the project. - Source: KD-0459
Resource-layer feature flag and audit logging
- Chose: Apply the
ChecksReportFeaturetrait andAiMcpLoggertoReportResource(first MCP resource to do so). - Rejected: Follow the
IssueResourceconvention (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+BuildTodaySnapshotActionreturning 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
scopeAssignedOpenForto theIssuemodel. - Rejected: Duplicate the ~10 LOC query builder in both Actions, or inject a
Serviceto 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 viaLogManager, return a generic user-safeResponse::error. - Rejected: Rethrow the raw
Throwableas several existing tools do. - Why: With
APP_DEBUG=truethe 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' => …])withmimeType = 'text/html;profile=mcp-app', validated by a 30-min spike. - Rejected: Build a local
HtmlContent+HtmlResponsemirroring the existingImageContentworkaround. - 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.initialDataor hardcodekendo.dev. - Why: Hardcoding breaks multi-tenancy.
initialDatarequires widget-side glue to read and inject; a one-line string replacement usingconfig('app.url')is cheaper and keeps the widget free of host detection. - Source: KD-0481
Widget bridge generic shape
- Chose: Generic
createBridge<TSnapshot>taking anapplySnapshot(state, snapshot)callback. - Rejected: Bridge exposes
Ref<TSnapshot | null>for widgets to read viacomputed. - 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 ofsrc/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
bulkAssignToSprintpolicy 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_idsat 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
IssueResourceDataper 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_idfromupdate-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
BulkUpdateIssuesActiontaking 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
StartWorkOnIssueActionopens one outer transaction; inner Actions become Laravel savepoints. - Rejected: Have the MCP tool inject
ConnectionInterfaceand 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
PromoteReportsActionprecedent. - 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-contexttool 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
UpdateIssueActionandCreateIssueBranchLinkActionwhen desired state already matches. - Rejected: Always run all steps and let inner Actions handle duplicates.
- Why:
CreateIssueBranchLinkActionthrowsBranchAlreadyLinkedExceptionon duplicates and the exception doesn't structurally distinguish "same issue" from "different issue". Pre-flight checks also avoid no-op audit-log noise fromUpdateIssueAction. - Source: KD-0550
start-work response shape
- Chose: Hand-build a structured response with nested
{id, title}blocks for lane/sprint, mirroringUpdateIssueTool. - Rejected: Return
IssueResourceData::from(...)->toArray()(flat IDs, no nested objects). - Why:
IssueResourceData::EAGER_LOADdeliberately 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
ResourceDataDTOs 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_metaflag. - 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; addmembers_countcompanion field; keepmembers[]unbounded. - Rejected: Trim to a "minimal context" shape, add
all_sprints[]andepics[], 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::$versionbump. - 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
Resolving an existing link vs finding a target for a new one
- Chose: Inline resolution in
UnlinkBranchTool(walkissue->branchLinks()filtered bybranch_name) rather than reusing the create-path's repo resolver. - Rejected: Generalise
ResolveBranchTargetRepoActionto also locate an existing link byrepo_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_namedoesn'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/foosucceed againstacme/barand never notice. - Source: KD-0777
"Matches the create path" means parity, not exceeding it
- Chose: Cover unlink audit via the standard
AiMcpLogrow every MCP tool emits, since the create path has no per-entity audit logger either. - Rejected: Introduce a first-class
IssueBranchLinkAuditLoggerto 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-issuerunsCreateIssueAction, then makes a separateSyncIssueLabelsActioncall whenlabel_idsis supplied. - Rejected: Extend
CreateIssueData/UpdateIssueDataand the issue Actions to sync labels inside the issue transaction (likeblockedBy/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-labelstool; keeplabel_idsoncreate-issueonly and drop it fromupdate-issue. - Rejected: Fold label changes into
update-issueso attach happens via create/update only (matching the issue's literal scope). - Why: A label-only
update-issuecall would re-invokeUpdateIssueActionwith all-current values — a no-op re-save emitting a spurious "Issue Updated" audit row and broadcast — unlessupdate-issuespecial-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) emitlabel_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 withget-my-issuesalready being id-only for lane/sprint). - Source: KD-0829
Label read gating — bundle vs dedicated tool
- Chose:
prepare-project-contextincludes labels on project access alone, while the standaloneget-labelstool additionally gates on theviewAnyLabel 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_idsalready leak to project-access holders via issue tools, whereas the dedicated read tool faithfully mirrors the web label-index route'sviewAnygate. - 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.mdonly, 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-toolfrom the pre-processing guidance and document why. - Rejected: Include it for symmetry with add-comment (it also accepts a
contentfield). - Why:
UpdateCommentActioninjects 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-contexthasn't run (somember[]is unavailable). - Rejected: Call
prepare-project-contexton 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
GetSprintsToolTool class mirroringGetEpicsTool, alongside the existingSprintsResource. - Rejected: Just promote
SprintsResourcediscoverability via more explicitKendoServerinstructions. - Why: Resources aren't returned by
ToolSearch— onlyToolclasses 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-toolfiltered bysprint_id, so embedding issues would add N+1 hydration for a need that's already served elsewhere. - Source: KD-0891