From c1eccf868d503e2df2a4cd115ed25abcf5134df0 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 10:56:33 -0700 Subject: [PATCH 01/27] docs: add design spec for jira-cli MCP server Captures the v1 design for an MCP server integrated into jira-cli: thin tool layer over pkg/jira, exposed as `jira mcp serve` over stdio, five tools (search/get/create/comment/transition), reusing existing config and auth. Intended for upstream PR. Made-with: Cursor --- .../2026-04-17-jira-mcp-server-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md diff --git a/docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md b/docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md new file mode 100644 index 00000000..114863bd --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md @@ -0,0 +1,165 @@ +# Jira CLI MCP Server — Design + +**Status:** Approved (brainstorm) — pending implementation plan +**Date:** 2026-04-17 +**Scope:** v1 of an MCP server integrated into `jira-cli`, intended to be upstreamed to `ankitpokhrel/jira-cli`. + +## Goal + +Add a Model Context Protocol (MCP) server to `jira-cli` so that an LLM running in an MCP host (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the CLI's existing config, auth, and Jira API client. + +## Non-goals (v1) + +- Multi-user / hosted deployment. +- HTTP/SSE transport (stdio only for v1; design leaves room to add later). +- Mirroring the entire CLI surface (see "Tool surface" for the v1 set). +- MCP resources or prompts (tools only). +- Any read-only mode flag, dry-run mode, or extra confirmation gate beyond what the MCP host already provides. + +## Use case + +Single-user, IDE-integrated coding assistant. The user runs `jira mcp serve` from an MCP host configured to spawn it over stdio. The LLM uses the tools to look up tickets it's working on, file new ones, comment, and transition state — all gated by the host's own per-tool prompts. + +## Approach + +**Thin MCP layer over `pkg/jira`.** A new `internal/mcp/` package builds an MCP server using the official `github.com/modelcontextprotocol/go-sdk`. Each tool is a small adapter that calls the existing `pkg/jira` client. No refactor of the existing `internal/cmd/...` layer; no shelling out to the `jira` binary. + +This keeps the diff small, isolates MCP code from the TUI/Cobra/Survey machinery in `internal/cmd/...`, and makes the tool layer trivially unit-testable. + +## Package layout + +``` +internal/cmd/mcp/ // NEW: cobra command surface + mcp.go // `jira mcp` parent cmd + serve/serve.go // `jira mcp serve` (wires SDK + tools, stdio) +internal/mcp/ // NEW: MCP server core + server.go // builds *mcp.Server, registers tools + tools/ + deps.go // shared Deps struct + helpers + search_issues.go + get_issue.go + create_issue.go + add_comment.go + transition_issue.go + _test.go // per-tool unit tests + server_test.go // in-memory transport round-trip test +``` + +Wiring: +- `internal/cmd/root` registers the new `mcp` parent command alongside `issue`, `epic`, etc. +- `internal/cmd/mcp/serve` constructs `tools.Deps` from `viper` + `api.DefaultClient` and hands it to `internal/mcp.NewServer(deps)`, then runs the server over stdio. + +Dependency rules: +- `internal/mcp/tools/*` may import `pkg/jira`, `pkg/adf`, and standard library only. No `cobra`, `viper`, `survey`, or `tui`. +- All viper reads live in `internal/cmd/mcp/serve`. Tools receive their dependencies through `tools.Deps`. + +## New external dependency + +`github.com/modelcontextprotocol/go-sdk` — the official MCP Go SDK (Tier 1, maintained with Google). Used for the server skeleton, schema derivation from Go structs, and the stdio transport. One new top-level dep; acceptable for upstream. + +## Tool surface (v1) + +All inputs are JSON-Schema-derived from Go structs via the SDK's reflection. All outputs are JSON for structured fields, with the issue browser URL included where applicable so the LLM can cite/link. + +### `search_issues` + +- **Input:** `{ jql?: string, project?: string, status?: string, assignee?: string, limit?: int (default 50, max 100) }` +- **Behavior:** If `jql` is provided, use it verbatim, scoped to `project` if set (else the configured project). Otherwise compose JQL from the other filters. Calls `client.Search`. +- **Output:** `{ total: int, issues: [{ key, summary, status, type, priority, assignee, reporter, created, updated, url }] }`. Lean rows — no description or comments. + +### `get_issue` + +- **Input:** `{ key: string (required), include_comments?: bool (default true), comment_limit?: int (default 10) }` +- **Behavior:** `client.GetIssue(key)` plus comment fetch. ADF description converted to markdown via `pkg/adf`. +- **Output:** Full issue: `key, summary, status, type, priority, assignee, reporter, labels, components, fix_versions, parent, links, description (markdown), comments [{ author, body, created }], url`. + +### `create_issue` + +- **Input:** `{ summary: string (required), type: string (required, e.g. "Task"|"Bug"|"Story"), project?: string, description?: string, priority?: string, labels?: string[], components?: string[], assignee?: string, parent?: string }` +- **Behavior:** Selects `client.Create` vs `client.CreateV2` based on installation type, matching the existing create command's logic. +- **Output:** `{ key, url }`. + +### `add_comment` + +- **Input:** `{ key: string (required), body: string (required), internal?: bool }` +- **Behavior:** Calls the appropriate add-comment method on the client based on installation type. +- **Output:** `{ key, comment_id, url }`. + +### `transition_issue` + +- **Input:** `{ key: string (required), transition: string (required, e.g. "In Progress"|"Done"), comment?: string, resolution?: string, assignee?: string }` +- **Behavior:** Resolves transition name → id via `client.GetTransitions`. If the name is unknown, returns a tool error listing valid transitions. Then calls `client.Transition`. +- **Output:** `{ key, from_status, to_status, url }`. + +## Cross-cutting behavior + +- **Project defaulting:** When a tool's `project` field is omitted, fall back to `viper.GetString("project.key")`. Only required to be passed for cross-project work. +- **URLs:** Every output that references an issue includes `{server}/browse/{key}` so the LLM can cite/link in chat. +- **Concurrency:** The SDK invokes tool handlers concurrently. The `pkg/jira` client is already safe for concurrent use (HTTP client under the hood). No additional locking. +- **Cancellation:** Each handler receives a `context.Context` from the SDK. It is threaded into outbound HTTP calls so a host-cancelled tool call also cancels the upstream Jira request. If `pkg/jira` does not currently accept a context on the relevant methods, add a context-accepting variant (smallest possible change) rather than refactoring the existing API. + +## Lifecycle and transport + +`jira mcp serve`: + +1. Cobra command parses inherited globals (`--config`, `--debug`); no MCP-specific flags in v1. +2. Reads viper config the same way every other `jira` subcommand does, so `JIRA_API_TOKEN`, `JIRA_CONFIG_FILE`, `~/.config/.jira/.config.yml`, `.netrc`, and keychain auth all keep working unchanged. +3. Constructs `api.DefaultClient(debug)` once, builds `tools.Deps{ Client, Project, Server, Installation }`. +4. Builds `mcp.NewServer(...)`, registers the five tools, and calls `server.Run(ctx, mcp.NewStdioTransport())`. +5. Blocks until stdin closes or SIGINT/SIGTERM is received. + +**Stdout discipline:** With stdio transport, stdout is reserved exclusively for JSON-RPC frames. The MCP code path must not call any of the CLI's stdout printers; it returns structured tool results as values and emits all logs to stderr. + +**Failing fast:** If config is missing at startup (e.g. no config file and no `JIRA_API_TOKEN`), the command fails before the MCP handshake with a message pointing at `jira init`. Better than appearing connected and breaking on first call. + +## Configuration in MCP hosts + +Documented snippet for Cursor / Claude Desktop: + +```json +{ + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } +} +``` + +This snippet appears in both the README and `jira mcp serve --help`. + +## Error handling + +Three categories, each handled differently: + +1. **Bad input from the LLM** (missing required field, unknown transition name, malformed JQL): returned as a structured MCP tool error (`isError: true`) with a clear message and, where useful, the list of valid options. Example: `transition_issue` with `transition: "Doing"` returns `"Unknown transition 'Doing' for ISSUE-1. Valid transitions: To Do, In Progress, Done"`. The LLM can self-correct on the next turn. +2. **Jira API errors** (4xx/5xx from upstream): pass the upstream error message through to the tool result so the LLM can reason about it. Auth failures get a one-liner pointing at `JIRA_API_TOKEN` so the user can fix their setup. +3. **Server-internal errors**: logged to stderr with full context. Tool handlers wrap their body in `defer recover()` that converts panics into tool errors so a single bad call cannot kill the server mid-session. + +## Testing + +Mirroring the existing repo's pattern (`*_test.go` next to source, `testify`): + +- **Per-tool unit tests** in `internal/mcp/tools/_test.go`. Each test uses a fake `pkg/jira` client (matching whatever fake/`httptest` pattern `pkg/jira/*_test.go` already uses). Coverage targets: happy path, required-field validation, default-project fallback, error passthrough, and (for `transition_issue`) the unknown-transition case. +- **One integration test** in `internal/mcp/server_test.go` using `mcp.NewInMemoryTransports()` to exercise the round-trip `tools/list` plus a `tools/call` for one read tool and one write tool. Catches schema-derivation and SDK-API drift. +- No live-Jira tests in CI (matches the rest of the repo). + +Coverage density should match `internal/cmd/issue/*_test.go`. + +## Documentation + +- **README:** New top-level "MCP server" section after "Scripts", with the host config snippet, the list of five tools, and a short example transcript. +- **`jira mcp serve --help`:** Includes the host config snippet inline so users can find it from the CLI. + +## Rollout + +Single PR containing the new packages, tests, and README update. Self-contained; the upstream maintainer can evaluate it as a unit. No changes to existing commands or to the public API of `pkg/jira` except the targeted addition of context-accepting variants if needed for cancellation. + +## Future work (explicitly deferred) + +- HTTP/SSE transport via a `--http :PORT` flag on `serve`. +- Additional tools: edit, assign, link/unlink, list sprints/epics, list projects/boards, get current user, delete (with extra gating), worklog. +- MCP resources (e.g. "current sprint") and prompts (e.g. "summarize my open issues"). +- A `--read-only` startup flag and per-tool `dry_run` if real-world use surfaces a need. From d77cdbe8ea2b1f67068d922cfee95888cd2abc44 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:06:06 -0700 Subject: [PATCH 02/27] docs: add implementation plan for jira-cli MCP server Bite-sized, TDD-style plan that walks through adding the MCP server package, the five tools (search/get/create/comment/transition), the `jira mcp serve` cobra command, and README docs. Each task is self-contained with full test code, full implementation code, exact commands, and a commit step. Made-with: Cursor --- .../plans/2026-04-17-jira-mcp-server.md | 2055 +++++++++++++++++ 1 file changed, 2055 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-jira-mcp-server.md diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md new file mode 100644 index 00000000..db649a7d --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -0,0 +1,2055 @@ +# Jira CLI MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Model Context Protocol (MCP) server to `jira-cli`, exposed as `jira mcp serve` over stdio, with five tools: `search_issues`, `get_issue`, `create_issue`, `add_comment`, `transition_issue`. + +**Architecture:** Thin MCP layer in a new `internal/mcp/` package. Tool handlers depend only on `pkg/jira` (via the existing `api` package's v2/v3 proxies) and `pkg/adf`. A new `internal/cmd/mcp/` package wires `jira mcp serve` into the existing Cobra tree, builds dependencies from viper, and runs the SDK's stdio transport. Tool handlers are unit-tested with `httptest.NewServer` (matching `pkg/jira/*_test.go` style); the full server is round-tripped once with `mcp.NewInMemoryTransports`. + +**Tech Stack:** Go 1.25, Cobra, Viper, `github.com/modelcontextprotocol/go-sdk` v1.5.0, `httptest`, `testify`. + +**Spec:** `docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md` + +**Conventions used in this plan:** +- All file paths are relative to the repo root. +- All `go test` commands run from the repo root. +- All commits use the project's existing message style (`feat:`, `test:`, `docs:`). +- The MCP code path **must never write to stdout** (it would corrupt JSON-RPC framing). All logs go to stderr; tool results are returned as values. + +--- + +## Task 0: Add SDK dependency and create empty package skeleton + +**Files:** +- Modify: `go.mod`, `go.sum` +- Create: `internal/mcp/doc.go` +- Create: `internal/mcp/tools/doc.go` + +- [ ] **Step 1: Add the official MCP Go SDK dependency** + +```bash +go get github.com/modelcontextprotocol/go-sdk@v1.5.0 +``` + +Expected: `go get` completes; `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.5.0`; `go.sum` updated. If `v1.5.0` is no longer the latest stable, use the latest stable v1.x. + +- [ ] **Step 2: Create empty package marker for `internal/mcp`** + +Create `internal/mcp/doc.go`: + +```go +// Package mcp implements a Model Context Protocol server that exposes +// a subset of jira-cli's capabilities to MCP-aware hosts (e.g. Cursor, +// Claude Desktop). Wiring lives in internal/cmd/mcp. +package mcp +``` + +- [ ] **Step 3: Create empty package marker for `internal/mcp/tools`** + +Create `internal/mcp/tools/doc.go`: + +```go +// Package tools holds the individual MCP tool handlers. Each handler is +// a small adapter from a typed input/output struct onto the existing +// pkg/jira client. Handlers must not depend on cobra, viper, survey, or +// tui; their dependencies are injected via the Deps struct. +package tools +``` + +- [ ] **Step 4: Verify the module still builds** + +```bash +go build ./... +``` + +Expected: exit 0, no output. + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/mcp/doc.go internal/mcp/tools/doc.go +git commit -m "feat(mcp): add modelcontextprotocol/go-sdk dependency and package skeleton" +``` + +--- + +## Task 1: Define the `tools.Deps` struct + +**Files:** +- Create: `internal/mcp/tools/deps.go` +- Create: `internal/mcp/tools/deps_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/deps_test.go`: + +```go +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeps_IssueURL(t *testing.T) { + d := &Deps{Server: "https://example.atlassian.net"} + assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) +} + +func TestDeps_IssueURL_TrimsTrailingSlash(t *testing.T) { + d := &Deps{Server: "https://example.atlassian.net/"} + assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) +} + +func TestDeps_ResolveProject_UsesDefaultWhenEmpty(t *testing.T) { + d := &Deps{DefaultProject: "ABC"} + assert.Equal(t, "ABC", d.ResolveProject("")) +} + +func TestDeps_ResolveProject_PrefersExplicit(t *testing.T) { + d := &Deps{DefaultProject: "ABC"} + assert.Equal(t, "XYZ", d.ResolveProject("XYZ")) +} + +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestDeps -v +``` + +Expected: FAIL — `Deps` undefined. + +- [ ] **Step 3: Implement `Deps`** + +Create `internal/mcp/tools/deps.go`: + +```go +package tools + +import ( + "strings" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// Deps bundles the runtime dependencies every MCP tool handler needs. +// It is constructed once in internal/cmd/mcp/serve and shared (read-only) +// across all tool invocations. +type Deps struct { + Client *jira.Client + Server string + DefaultProject string + Installation string +} + +// IssueURL returns the browser URL for a given issue key. +func (d *Deps) IssueURL(key string) string { + return strings.TrimRight(d.Server, "/") + "/browse/" + key +} + +// ResolveProject returns explicit if non-empty, otherwise the configured +// default project key. +func (d *Deps) ResolveProject(explicit string) string { + if explicit != "" { + return explicit + } + return d.DefaultProject +} + +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestDeps -v +``` + +Expected: PASS, all 5 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/deps.go internal/mcp/tools/deps_test.go +git commit -m "feat(mcp): add tools.Deps with project/url/installation helpers" +``` + +--- + +## Task 2: Add the `search_issues` tool + +**Files:** +- Create: `internal/mcp/tools/search_issues.go` +- Create: `internal/mcp/tools/search_issues_test.go` + +The tool calls `api.ProxySearch`, which selects the v2 or v3 endpoint based on the configured installation type. The `api` package reads `viper.GetString("installation")` internally; tests set that via `viper.Set("installation", ...)`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/search_issues_test.go`: + +```go +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const searchResponseBody = `{ + "issues": [ + { + "key": "TEST-1", + "fields": { + "summary": "First issue", + "status": {"name": "To Do"}, + "issueType": {"name": "Task"}, + "priority": {"name": "Medium"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "labels": [] + } + } + ] +}` + +func newSearchTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + + cleanup := func() { + server.Close() + viper.Set("installation", prevInstall) + } + return deps, cleanup +} + +func TestSearchIssues_UsesProvidedJQL(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/search/jql", r.URL.Path) + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(searchResponseBody)) + }) + defer cleanup() + + out, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ + JQL: "summary ~ first", + }) + require.NoError(t, err) + + assert.Equal(t, "summary ~ first", capturedJQL) + require.Len(t, out.Issues, 1) + assert.Equal(t, "TEST-1", out.Issues[0].Key) + assert.Equal(t, "First issue", out.Issues[0].Summary) + assert.Equal(t, "To Do", out.Issues[0].Status) + assert.Equal(t, "Alice", out.Issues[0].Assignee) + assert.True(t, strings.HasSuffix(out.Issues[0].URL, "/browse/TEST-1")) +} + +func TestSearchIssues_ComposesJQLFromFilters(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ + Status: "In Progress", + Assignee: "alice", + }) + require.NoError(t, err) + + assert.Contains(t, capturedJQL, `project = "TEST"`) + assert.Contains(t, capturedJQL, `status = "In Progress"`) + assert.Contains(t, capturedJQL, `assignee = "alice"`) +} + +func TestSearchIssues_AssigneeMe(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Assignee: "me"}) + require.NoError(t, err) + assert.Contains(t, capturedJQL, `assignee = currentUser()`) +} + +func TestSearchIssues_LimitClampedTo100(t *testing.T) { + var capturedLimit string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedLimit = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Limit: 500}) + require.NoError(t, err) + assert.Equal(t, "100", capturedLimit) +} + +func TestSearchIssues_DefaultLimit(t *testing.T) { + var capturedLimit string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedLimit = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{}) + require.NoError(t, err) + assert.Equal(t, "50", capturedLimit) +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestSearchIssues -v +``` + +Expected: FAIL — `SearchIssues`, `SearchIssuesInput` undefined. + +- [ ] **Step 3: Implement the tool** + +Create `internal/mcp/tools/search_issues.go`: + +```go +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/ankitpokhrel/jira-cli/api" +) + +// SearchIssuesInput is the input schema for the search_issues tool. +type SearchIssuesInput struct { + JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. If set, other filter fields are ignored except project (which scopes the JQL when present)."` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project)"` + Status string `json:"status,omitempty" jsonschema:"filter by status name, e.g. \"To Do\""` + Assignee string `json:"assignee,omitempty" jsonschema:"filter by assignee. Use \"me\" for the configured user, \"none\" for unassigned, or a username/account id."` + Limit int `json:"limit,omitempty" jsonschema:"maximum number of issues to return (default 50, clamped to 100)"` +} + +// SearchIssuesOutput is the structured result of the search_issues tool. +type SearchIssuesOutput struct { + Total int `json:"total"` + Issues []IssueBrief `json:"issues"` +} + +// IssueBrief is a lean projection of jira.Issue used for list-style outputs. +type IssueBrief struct { + Key string `json:"key"` + Summary string `json:"summary"` + Status string `json:"status"` + Type string `json:"type"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Reporter string `json:"reporter"` + Created string `json:"created"` + Updated string `json:"updated"` + URL string `json:"url"` +} + +// SearchIssues runs the search_issues tool. +func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssuesOutput, error) { + limit := in.Limit + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + jql := strings.TrimSpace(in.JQL) + project := d.ResolveProject(in.Project) + + if jql == "" { + jql = composeJQL(project, in.Status, in.Assignee) + } else if project != "" && !strings.Contains(strings.ToLower(jql), "project") { + jql = fmt.Sprintf(`project = %q AND (%s)`, project, jql) + } + + res, err := api.ProxySearch(d.Client, jql, 0, uint(limit)) + if err != nil { + return SearchIssuesOutput{}, err + } + + out := SearchIssuesOutput{Issues: make([]IssueBrief, 0, len(res.Issues))} + for _, iss := range res.Issues { + out.Issues = append(out.Issues, IssueBrief{ + Key: iss.Key, + Summary: iss.Fields.Summary, + Status: iss.Fields.Status.Name, + Type: iss.Fields.IssueType.Name, + Priority: iss.Fields.Priority.Name, + Assignee: iss.Fields.Assignee.Name, + Reporter: iss.Fields.Reporter.Name, + Created: iss.Fields.Created, + Updated: iss.Fields.Updated, + URL: d.IssueURL(iss.Key), + }) + } + out.Total = len(out.Issues) + return out, nil +} + +func composeJQL(project, status, assignee string) string { + var clauses []string + if project != "" { + clauses = append(clauses, fmt.Sprintf(`project = %q`, project)) + } + if status != "" { + clauses = append(clauses, fmt.Sprintf(`status = %q`, status)) + } + switch strings.ToLower(assignee) { + case "": + // no clause + case "me": + clauses = append(clauses, "assignee = currentUser()") + case "none", "x": + clauses = append(clauses, "assignee is EMPTY") + default: + clauses = append(clauses, fmt.Sprintf(`assignee = %q`, assignee)) + } + if len(clauses) == 0 { + return "" + } + return strings.Join(clauses, " AND ") + " ORDER BY created DESC" +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestSearchIssues -v +``` + +Expected: PASS, all 5 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/search_issues.go internal/mcp/tools/search_issues_test.go +git commit -m "feat(mcp): add search_issues tool" +``` + +--- + +## Task 3: Add the `bodyToMarkdown` helper for ADF/string conversion + +The `Description` and comment `Body` fields in `pkg/jira` are typed as `interface{}`: `*adf.ADF` after a v3 fetch (post-`ifaceToADF`), `string` after a v2 fetch. The MCP tools need a single helper to render either to markdown. + +**Files:** +- Create: `internal/mcp/tools/markdown.go` +- Create: `internal/mcp/tools/markdown_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/markdown_test.go`: + +```go +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ankitpokhrel/jira-cli/pkg/adf" +) + +func TestBodyToMarkdown_String(t *testing.T) { + assert.Equal(t, "hello", bodyToMarkdown("hello")) +} + +func TestBodyToMarkdown_Nil(t *testing.T) { + assert.Equal(t, "", bodyToMarkdown(nil)) +} + +func TestBodyToMarkdown_ADF(t *testing.T) { + doc := &adf.ADF{ + Version: 1, + DocType: "doc", + Content: []*adf.Node{ + { + NodeType: "paragraph", + Content: []*adf.Node{ + {NodeType: "text", NodeValue: adf.NodeValue{Text: "Hello world"}}, + }, + }, + }, + } + got := bodyToMarkdown(doc) + assert.Contains(t, got, "Hello world") +} + +func TestBodyToMarkdown_UnknownType(t *testing.T) { + assert.Equal(t, "", bodyToMarkdown(123)) +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestBodyToMarkdown -v +``` + +Expected: FAIL — `bodyToMarkdown` undefined. + +- [ ] **Step 3: Implement the helper** + +Create `internal/mcp/tools/markdown.go`: + +```go +package tools + +import ( + "github.com/ankitpokhrel/jira-cli/pkg/adf" +) + +// bodyToMarkdown renders a Jira description-or-comment body field to markdown. +// The body is interface{} because v3 returns *adf.ADF and v2 returns string. +func bodyToMarkdown(body any) string { + switch v := body.(type) { + case nil: + return "" + case string: + return v + case *adf.ADF: + if v == nil { + return "" + } + return adf.NewTranslator(v, adf.NewMarkdownTranslator()).Translate() + default: + return "" + } +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestBodyToMarkdown -v +``` + +Expected: PASS, all 4 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/markdown.go internal/mcp/tools/markdown_test.go +git commit -m "feat(mcp): add bodyToMarkdown helper for ADF/string description bodies" +``` + +--- + +## Task 4: Add the `get_issue` tool + +**Files:** +- Create: `internal/mcp/tools/get_issue.go` +- Create: `internal/mcp/tools/get_issue_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/get_issue_test.go`: + +```go +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const getIssueResponseV3 = `{ + "key": "TEST-1", + "fields": { + "summary": "Sample bug", + "status": {"name": "In Progress"}, + "issueType": {"name": "Bug"}, + "priority": {"name": "High"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "labels": ["backend", "urgent"], + "components": [{"name": "API"}], + "fixVersions": [{"name": "v2.0"}], + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "description": { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Repro steps"}]} + ] + }, + "comment": { + "total": 1, + "comments": [ + { + "id": "100", + "author": {"displayName": "Carol", "emailAddress": "carol@example.com", "active": true}, + "body": { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Looking into it"}]} + ] + }, + "created": "2026-01-03T10:00:00.000+0000" + } + ] + } + } +}` + +func newIssueTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestGetIssue_Cloud(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV3)) + }) + defer cleanup() + + out, err := GetIssue(context.Background(), deps, GetIssueInput{Key: "TEST-1"}) + require.NoError(t, err) + + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "Sample bug", out.Summary) + assert.Equal(t, "In Progress", out.Status) + assert.Equal(t, "Bug", out.Type) + assert.Equal(t, "High", out.Priority) + assert.Equal(t, "Alice", out.Assignee) + assert.Equal(t, "Bob", out.Reporter) + assert.Equal(t, []string{"backend", "urgent"}, out.Labels) + assert.Equal(t, []string{"API"}, out.Components) + assert.Equal(t, []string{"v2.0"}, out.FixVersions) + assert.Contains(t, out.Description, "Repro steps") + require.Len(t, out.Comments, 1) + assert.Equal(t, "Carol", out.Comments[0].Author) + assert.Contains(t, out.Comments[0].Body, "Looking into it") + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestGetIssue_RequiresKey(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called when key is missing") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := GetIssue(context.Background(), deps, GetIssueInput{Key: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") +} + +func TestGetIssue_RespectsCommentLimit(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV3)) + }) + defer cleanup() + + out, err := GetIssue(context.Background(), deps, GetIssueInput{ + Key: "TEST-1", + IncludeComments: boolPtr(false), + }) + require.NoError(t, err) + assert.Empty(t, out.Comments) +} + +func boolPtr(b bool) *bool { return &b } +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestGetIssue -v +``` + +Expected: FAIL — `GetIssue`, `GetIssueInput` undefined. + +- [ ] **Step 3: Implement the tool** + +Create `internal/mcp/tools/get_issue.go`: + +```go +package tools + +import ( + "context" + "errors" + + "github.com/ankitpokhrel/jira-cli/api" + issuefilter "github.com/ankitpokhrel/jira-cli/pkg/jira/filter/issue" +) + + + +// GetIssueInput is the input schema for the get_issue tool. +type GetIssueInput struct { + Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` + IncludeComments *bool `json:"include_comments,omitempty" jsonschema:"include recent comments in the response (default true)"` + CommentLimit int `json:"comment_limit,omitempty" jsonschema:"maximum number of recent comments to include (default 10)"` +} + +// GetIssueOutput is the structured result of the get_issue tool. +type GetIssueOutput struct { + Key string `json:"key"` + Summary string `json:"summary"` + Status string `json:"status"` + Type string `json:"type"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Reporter string `json:"reporter"` + Labels []string `json:"labels"` + Components []string `json:"components"` + FixVersions []string `json:"fix_versions"` + Parent string `json:"parent,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` + Description string `json:"description"` + Comments []CommentBrief `json:"comments,omitempty"` + URL string `json:"url"` +} + +// CommentBrief is a lean projection of an issue comment. +type CommentBrief struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Created string `json:"created"` +} + +// GetIssue runs the get_issue tool. +func GetIssue(_ context.Context, d *Deps, in GetIssueInput) (GetIssueOutput, error) { + if in.Key == "" { + return GetIssueOutput{}, errors.New("key is required") + } + + includeComments := true + if in.IncludeComments != nil { + includeComments = *in.IncludeComments + } + commentLimit := in.CommentLimit + if commentLimit <= 0 { + commentLimit = 10 + } + + iss, err := api.ProxyGetIssue(d.Client, in.Key, issuefilter.NewNumCommentsFilter(uint(commentLimit))) + if err != nil { + return GetIssueOutput{}, err + } + + out := GetIssueOutput{ + Key: iss.Key, + Summary: iss.Fields.Summary, + Status: iss.Fields.Status.Name, + Type: iss.Fields.IssueType.Name, + Priority: iss.Fields.Priority.Name, + Assignee: iss.Fields.Assignee.Name, + Reporter: iss.Fields.Reporter.Name, + Labels: iss.Fields.Labels, + Created: iss.Fields.Created, + Updated: iss.Fields.Updated, + Description: bodyToMarkdown(iss.Fields.Description), + URL: d.IssueURL(iss.Key), + } + if iss.Fields.Parent != nil { + out.Parent = iss.Fields.Parent.Key + } + for _, c := range iss.Fields.Components { + out.Components = append(out.Components, c.Name) + } + for _, v := range iss.Fields.FixVersions { + out.FixVersions = append(out.FixVersions, v.Name) + } + + if includeComments && iss.Fields.Comment.Total > 0 { + comments := iss.Fields.Comment.Comments + // Take the last commentLimit comments (newest), preserving original chronological order. + start := 0 + if len(comments) > commentLimit { + start = len(comments) - commentLimit + } + for i := start; i < len(comments); i++ { + c := comments[i] + out.Comments = append(out.Comments, CommentBrief{ + ID: c.ID, + Author: c.Author.Name, + Body: bodyToMarkdown(c.Body), + Created: c.Created, + }) + } + } + + return out, nil +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestGetIssue -v +``` + +Expected: PASS, all 3 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/get_issue.go internal/mcp/tools/get_issue_test.go +git commit -m "feat(mcp): add get_issue tool" +``` + +--- + +## Task 5: Add the `create_issue` tool + +**Files:** +- Create: `internal/mcp/tools/create_issue.go` +- Create: `internal/mcp/tools/create_issue_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/create_issue_test.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func newCreateTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestCreateIssue_Success(t *testing.T) { + var capturedBody map[string]any + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &capturedBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "TEST-42"}`)) + }) + defer cleanup() + + out, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "New thing", + Type: "Task", + }) + require.NoError(t, err) + assert.Equal(t, "TEST-42", out.Key) + assert.Equal(t, deps.IssueURL("TEST-42"), out.URL) + + fields, _ := capturedBody["fields"].(map[string]any) + require.NotNil(t, fields) + project, _ := fields["project"].(map[string]any) + assert.Equal(t, "TEST", project["key"]) + assert.Equal(t, "New thing", fields["summary"]) +} + +func TestCreateIssue_RequiresSummary(t *testing.T) { + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Type: "Task"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "summary is required") +} + +func TestCreateIssue_RequiresType(t *testing.T) { + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Summary: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type is required") +} + +func TestCreateIssue_OverridesProject(t *testing.T) { + var capturedProject string + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + fields, _ := body["fields"].(map[string]any) + project, _ := fields["project"].(map[string]any) + capturedProject, _ = project["key"].(string) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "OTHER-1"}`)) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "x", Type: "Task", Project: "OTHER", + }) + require.NoError(t, err) + assert.Equal(t, "OTHER", capturedProject) +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestCreateIssue -v +``` + +Expected: FAIL — `CreateIssue`, `CreateIssueInput` undefined. + +- [ ] **Step 3: Implement the tool** + +Create `internal/mcp/tools/create_issue.go`: + +```go +package tools + +import ( + "context" + "errors" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// CreateIssueInput is the input schema for the create_issue tool. +type CreateIssueInput struct { + Summary string `json:"summary" jsonschema:"issue summary (required)"` + Type string `json:"type" jsonschema:"issue type, e.g. \"Task\", \"Bug\", \"Story\" (required)"` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project)"` + Description string `json:"description,omitempty" jsonschema:"issue description in markdown"` + Priority string `json:"priority,omitempty" jsonschema:"priority name, e.g. \"High\""` + Labels []string `json:"labels,omitempty"` + Components []string `json:"components,omitempty"` + Assignee string `json:"assignee,omitempty" jsonschema:"assignee account id (Cloud) or username (Local)"` + Parent string `json:"parent,omitempty" jsonschema:"parent issue key (use this for epic link or sub-task parent)"` +} + +// CreateIssueOutput is the structured result of the create_issue tool. +type CreateIssueOutput struct { + Key string `json:"key"` + URL string `json:"url"` +} + +// CreateIssue runs the create_issue tool. +func CreateIssue(_ context.Context, d *Deps, in CreateIssueInput) (CreateIssueOutput, error) { + if in.Summary == "" { + return CreateIssueOutput{}, errors.New("summary is required") + } + if in.Type == "" { + return CreateIssueOutput{}, errors.New("type is required") + } + + project := d.ResolveProject(in.Project) + if project == "" { + return CreateIssueOutput{}, errors.New("project is required (no default project configured)") + } + + req := &jira.CreateRequest{ + Project: project, + IssueType: in.Type, + Summary: in.Summary, + Body: in.Description, + Priority: in.Priority, + Labels: in.Labels, + Components: in.Components, + Assignee: in.Assignee, + ParentIssueKey: in.Parent, + } + req.ForInstallationType(d.Installation) + + resp, err := api.ProxyCreate(d.Client, req) + if err != nil { + return CreateIssueOutput{}, err + } + return CreateIssueOutput{Key: resp.Key, URL: d.IssueURL(resp.Key)}, nil +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestCreateIssue -v +``` + +Expected: PASS, all 4 subtests. If a test fails because `jira.CreateRequest.Body` cannot accept a string for v3 (it's `interface{}` per the type def, but the V3 endpoint may require ADF), keep `Body` as the input string for now — the V3 API will accept plain text in many cases; the existing CLI also passes raw strings here. Adjust only if the test against the fake server actually fails on assertion of the body shape. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/create_issue.go internal/mcp/tools/create_issue_test.go +git commit -m "feat(mcp): add create_issue tool" +``` + +--- + +## Task 6: Add the `add_comment` tool + +**Files:** +- Create: `internal/mcp/tools/add_comment.go` +- Create: `internal/mcp/tools/add_comment_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/add_comment_test.go`: + +```go +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func newCommentTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestAddComment_Success(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1/comment", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "999"}`)) + }) + defer cleanup() + + out, err := AddComment(context.Background(), deps, AddCommentInput{ + Key: "TEST-1", + Body: "Hello world", + }) + require.NoError(t, err) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestAddComment_RequiresKey(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := AddComment(context.Background(), deps, AddCommentInput{Body: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") +} + +func TestAddComment_RequiresBody(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := AddComment(context.Background(), deps, AddCommentInput{Key: "TEST-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "body is required") +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestAddComment -v +``` + +Expected: FAIL — `AddComment`, `AddCommentInput` undefined. + +- [ ] **Step 3: Implement the tool** + +Create `internal/mcp/tools/add_comment.go`: + +```go +package tools + +import ( + "context" + "errors" +) + +// AddCommentInput is the input schema for the add_comment tool. +type AddCommentInput struct { + Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` + Body string `json:"body" jsonschema:"comment body in markdown (required)"` + Internal bool `json:"internal,omitempty" jsonschema:"mark as an internal (service-desk) comment"` +} + +// AddCommentOutput is the structured result of the add_comment tool. +type AddCommentOutput struct { + Key string `json:"key"` + URL string `json:"url"` +} + +// AddComment runs the add_comment tool. +func AddComment(_ context.Context, d *Deps, in AddCommentInput) (AddCommentOutput, error) { + if in.Key == "" { + return AddCommentOutput{}, errors.New("key is required") + } + if in.Body == "" { + return AddCommentOutput{}, errors.New("body is required") + } + if err := d.Client.AddIssueComment(in.Key, in.Body, in.Internal); err != nil { + return AddCommentOutput{}, err + } + return AddCommentOutput{Key: in.Key, URL: d.IssueURL(in.Key)}, nil +} +``` + +Note: `AddIssueComment` does not return the created comment ID, so the spec's `comment_id` field is dropped from the output. If a future spec revision needs it, add a thin wrapper that captures the response body. + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestAddComment -v +``` + +Expected: PASS, all 3 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/add_comment.go internal/mcp/tools/add_comment_test.go +git commit -m "feat(mcp): add add_comment tool" +``` + +--- + +## Task 7: Add the `transition_issue` tool + +**Files:** +- Create: `internal/mcp/tools/transition_issue.go` +- Create: `internal/mcp/tools/transition_issue_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/tools/transition_issue_test.go`: + +```go +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const transitionsResponse = `{ + "transitions": [ + {"id": "11", "name": "To Do", "isAvailable": true}, + {"id": "21", "name": "In Progress", "isAvailable": true}, + {"id": "31", "name": "Done", "isAvailable": true} + ] +}` + +func newTransitionTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestTransitionIssue_Success(t *testing.T) { + var postedID string + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + assert.Equal(t, "/rest/api/3/issue/TEST-1/transitions", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + case http.MethodPost: + assert.Equal(t, "/rest/api/2/issue/TEST-1/transitions", r.URL.Path) + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + tr, _ := body["transition"].(map[string]any) + postedID, _ = tr["id"].(string) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + }) + defer cleanup() + + out, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "In Progress", + }) + require.NoError(t, err) + assert.Equal(t, "21", postedID) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "In Progress", out.ToStatus) + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestTransitionIssue_CaseInsensitiveMatch(t *testing.T) { + var postedID string + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + return + } + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + tr, _ := body["transition"].(map[string]any) + postedID, _ = tr["id"].(string) + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "in progress", + }) + require.NoError(t, err) + assert.Equal(t, "21", postedID) +} + +func TestTransitionIssue_UnknownTransition(t *testing.T) { + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "POST should not happen for unknown transition") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "Doing", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown transition") + assert.Contains(t, err.Error(), "To Do") + assert.Contains(t, err.Error(), "In Progress") + assert.Contains(t, err.Error(), "Done") +} + +func TestTransitionIssue_RequiresKeyAndTransition(t *testing.T) { + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{Transition: "Done"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") + + _, err = TransitionIssue(context.Background(), deps, TransitionIssueInput{Key: "TEST-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "transition is required") +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/tools/ -run TestTransitionIssue -v +``` + +Expected: FAIL — `TransitionIssue`, `TransitionIssueInput` undefined. + +- [ ] **Step 3: Implement the tool** + +Create `internal/mcp/tools/transition_issue.go`: + +```go +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// TransitionIssueInput is the input schema for the transition_issue tool. +type TransitionIssueInput struct { + Key string `json:"key" jsonschema:"issue key (required)"` + Transition string `json:"transition" jsonschema:"target transition name, e.g. \"In Progress\" (required, case-insensitive)"` + Comment string `json:"comment,omitempty" jsonschema:"optional comment to add as part of the transition (workflow must allow it)"` + Resolution string `json:"resolution,omitempty" jsonschema:"optional resolution name to set, e.g. \"Fixed\""` + Assignee string `json:"assignee,omitempty" jsonschema:"optional new assignee (account id on Cloud, username on Local)"` +} + +// TransitionIssueOutput is the structured result of the transition_issue tool. +type TransitionIssueOutput struct { + Key string `json:"key"` + ToStatus string `json:"to_status"` + URL string `json:"url"` +} + +// TransitionIssue runs the transition_issue tool. +func TransitionIssue(_ context.Context, d *Deps, in TransitionIssueInput) (TransitionIssueOutput, error) { + if in.Key == "" { + return TransitionIssueOutput{}, errors.New("key is required") + } + if in.Transition == "" { + return TransitionIssueOutput{}, errors.New("transition is required") + } + + transitions, err := api.ProxyTransitions(d.Client, in.Key) + if err != nil { + return TransitionIssueOutput{}, err + } + + var match *jira.Transition + target := strings.ToLower(strings.TrimSpace(in.Transition)) + available := make([]string, 0, len(transitions)) + for _, t := range transitions { + available = append(available, t.Name) + if strings.ToLower(t.Name) == target { + match = t + } + } + if match == nil { + return TransitionIssueOutput{}, fmt.Errorf( + "unknown transition %q for %s. Valid transitions: %s", + in.Transition, in.Key, strings.Join(available, ", "), + ) + } + + req := &jira.TransitionRequest{ + Transition: &jira.TransitionRequestData{ + ID: match.ID.String(), + Name: match.Name, + }, + } + if in.Comment != "" { + req.Update = &jira.TransitionRequestUpdate{} + req.Update.Comment = append(req.Update.Comment, struct { + Add struct { + Body string `json:"body"` + } `json:"add"` + }{ + Add: struct { + Body string `json:"body"` + }{Body: in.Comment}, + }) + } + if in.Resolution != "" || in.Assignee != "" { + req.Fields = &jira.TransitionRequestFields{} + if in.Resolution != "" { + req.Fields.Resolution = &struct { + Name string `json:"name"` + }{Name: in.Resolution} + } + if in.Assignee != "" { + req.Fields.Assignee = &struct { + Name string `json:"name"` + }{Name: in.Assignee} + } + } + + if _, err := d.Client.Transition(in.Key, req); err != nil { + return TransitionIssueOutput{}, err + } + return TransitionIssueOutput{ + Key: in.Key, + ToStatus: match.Name, + URL: d.IssueURL(in.Key), + }, nil +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/tools/ -run TestTransitionIssue -v +``` + +Expected: PASS, all 4 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/tools/transition_issue.go internal/mcp/tools/transition_issue_test.go +git commit -m "feat(mcp): add transition_issue tool" +``` + +--- + +## Task 8: Build the MCP server (registration + in-memory round trip test) + +**Files:** +- Create: `internal/mcp/server.go` +- Create: `internal/mcp/server_test.go` + +The server constructor takes a `*tools.Deps`, builds a `*mcp.Server`, and registers all five tools using the SDK's `mcp.AddTool` generic helper. Each registration adapts the `(d, in) -> (out, err)` tool function to the SDK's `(ctx, *CallToolRequest, In) -> (*CallToolResult, Out, error)` signature. + +- [ ] **Step 1: Write the failing test** + +Create `internal/mcp/server_test.go`: + +```go +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func TestServer_ListsAllTools(t *testing.T) { + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + defer viper.Set("installation", prevInstall) + + jiraServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"issues": []}`)) + })) + defer jiraServer.Close() + + deps := &tools.Deps{ + Client: jira.NewClient(jira.Config{Server: jiraServer.URL}, jira.WithTimeout(3*time.Second)), + Server: jiraServer.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + + srv := NewServer(deps) + require.NotNil(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + serverT, clientT := mcp.NewInMemoryTransports() + + serverDone := make(chan error, 1) + go func() { serverDone <- srv.Run(ctx, serverT) }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer session.Close() + + listed, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + require.NoError(t, err) + + names := make(map[string]bool) + for _, tool := range listed.Tools { + names[tool.Name] = true + } + for _, expected := range []string{ + "search_issues", "get_issue", "create_issue", "add_comment", "transition_issue", + } { + assert.True(t, names[expected], "expected tool %q to be registered", expected) + } + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_issues", + Arguments: map[string]any{"jql": "project = TEST"}, + }) + require.NoError(t, err) + assert.False(t, res.IsError, "search_issues should succeed against the fake server") + + // Validation errors must come back as IsError tool results, not transport errors. + res, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_issue", + Arguments: map[string]any{}, // missing required "key" + }) + require.NoError(t, err) + assert.True(t, res.IsError, "missing required key should produce a tool error result") +} +``` + +- [ ] **Step 2: Run the test and verify it fails** + +```bash +go test ./internal/mcp/ -run TestServer -v +``` + +Expected: FAIL — `NewServer` undefined. + +- [ ] **Step 3: Implement the server** + +Create `internal/mcp/server.go`: + +```go +package mcp + +import ( + "context" + "fmt" + "os" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" +) + +const ( + // ServerName is the implementation name advertised over MCP. + ServerName = "jira-cli" + // ServerVersion is the MCP server version advertised to clients. + // Bumped independently of the jira-cli release version when the MCP + // surface changes in a backward-incompatible way. + ServerVersion = "0.1.0" +) + +// NewServer constructs a configured *mcp.Server with all jira-cli tools +// registered. The caller is responsible for invoking server.Run with a +// transport. +func NewServer(d *tools.Deps) *mcpsdk.Server { + srv := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: ServerName, + Version: ServerVersion, + }, nil) + + registerTool(srv, "search_issues", + "Search Jira issues by JQL or simple filters. Defaults to the configured project.", + d, tools.SearchIssues) + + registerTool(srv, "get_issue", + "Get full details of a Jira issue including description and recent comments.", + d, tools.GetIssue) + + registerTool(srv, "create_issue", + "Create a new Jira issue in the given project.", + d, tools.CreateIssue) + + registerTool(srv, "add_comment", + "Add a comment to a Jira issue.", + d, tools.AddComment) + + registerTool(srv, "transition_issue", + "Transition a Jira issue to a new status by name (e.g. \"In Progress\", \"Done\").", + d, tools.TransitionIssue) + + return srv +} + +// registerTool adapts a tools.* handler (which takes Deps + Input and returns +// Output + error) onto the SDK's expected handler signature. It also recovers +// from panics in the handler body so a single bad call cannot kill the server +// mid-session, and converts both errors and panics into MCP tool errors that +// the LLM can read. +func registerTool[In, Out any]( + srv *mcpsdk.Server, + name, description string, + d *tools.Deps, + fn func(context.Context, *tools.Deps, In) (Out, error), +) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: name, Description: description}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in In) (result *mcpsdk.CallToolResult, out Out, err error) { + defer func() { + if r := recover(); r != nil { + var zero Out + fmt.Fprintf(os.Stderr, "mcp: panic in tool %q: %v\n", name, r) + result = &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{ + Text: fmt.Sprintf("internal error in tool %q: %v", name, r), + }}, + } + out = zero + err = nil + } + }() + + out, callErr := fn(ctx, d, in) + if callErr != nil { + var zero Out + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: callErr.Error()}}, + }, zero, nil + } + return nil, out, nil + }, + ) +} +``` + +- [ ] **Step 4: Run the test and verify it passes** + +```bash +go test ./internal/mcp/ -run TestServer -v +``` + +Expected: PASS. If the SDK's exact symbol names differ slightly between v1.5.0 minor versions (e.g. `mcp.NewInMemoryTransports` vs `mcp.NewInMemoryTransport`), check `pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp` for the current API and adjust the test only — the production code in `server.go` follows the README example verbatim. + +- [ ] **Step 5: Run the whole MCP test suite to confirm everything still passes** + +```bash +go test ./internal/mcp/... -v +``` + +Expected: PASS, all suites. + +- [ ] **Step 6: Commit** + +```bash +git add internal/mcp/server.go internal/mcp/server_test.go +git commit -m "feat(mcp): add Server constructor and in-memory round-trip test" +``` + +--- + +## Task 9: Wire `jira mcp serve` into the Cobra tree + +**Files:** +- Create: `internal/cmd/mcp/mcp.go` +- Create: `internal/cmd/mcp/serve/serve.go` +- Modify: `internal/cmd/root/root.go` + +The `serve` command is the only place viper is read on the MCP path. It builds `tools.Deps` and runs the server over stdio. **It must not write to stdout**; all logging goes to stderr. + +- [ ] **Step 1: Create the `mcp` parent command** + +Create `internal/cmd/mcp/mcp.go`: + +```go +package mcp + +import ( + "github.com/spf13/cobra" + + "github.com/ankitpokhrel/jira-cli/internal/cmd/mcp/serve" +) + +const helpText = `Run jira-cli as a Model Context Protocol (MCP) server, exposing +Jira operations to MCP-aware hosts (e.g. Cursor, Claude Desktop).` + +// NewCmdMCP is the parent command for MCP-related subcommands. +func NewCmdMCP() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run jira-cli as an MCP server", + Long: helpText, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(serve.NewCmdServe()) + return cmd +} +``` + +- [ ] **Step 2: Create the `serve` subcommand** + +Create `internal/cmd/mcp/serve/serve.go`: + +```go +package serve + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + jiramcp "github.com/ankitpokhrel/jira-cli/internal/mcp" + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" +) + +const helpText = `Start an MCP server over stdio. + +Configure your MCP host (Cursor, Claude Desktop, etc.) like this: + + { + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } + } + +The server inherits the same configuration as every other jira-cli command: +JIRA_CONFIG_FILE, ~/.config/.jira/.config.yml, .netrc, and keychain all work +unchanged. The server reads from stdin and writes JSON-RPC frames to stdout; +all logs go to stderr.` + +// NewCmdServe is the `jira mcp serve` command. +func NewCmdServe() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Start an MCP server over stdio", + Long: helpText, + RunE: run, + } +} + +func run(cmd *cobra.Command, _ []string) error { + server := viper.GetString("server") + if server == "" { + return fmt.Errorf("no Jira server configured. Run 'jira init' to set up the tool") + } + + debug := viper.GetBool("debug") + deps := &tools.Deps{ + Client: api.DefaultClient(debug), + Server: server, + DefaultProject: viper.GetString("project.key"), + Installation: viper.GetString("installation"), + } + + srv := jiramcp.NewServer(deps) + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + fmt.Fprintln(os.Stderr, "jira-cli MCP server: listening on stdio") + if err := srv.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + return fmt.Errorf("mcp server: %w", err) + } + return nil +} +``` + +- [ ] **Step 3: Register the new command in root** + +Modify `internal/cmd/root/root.go`. In the import block, add: + +```go +"github.com/ankitpokhrel/jira-cli/internal/cmd/mcp" +``` + +In the `addChildCommands` function, add `mcp.NewCmdMCP()` to the call to `cmd.AddCommand`. After the change, the function looks like: + +```go +func addChildCommands(cmd *cobra.Command) { + cmd.AddCommand( + initCmd.NewCmdInit(), + issue.NewCmdIssue(), + epic.NewCmdEpic(), + sprint.NewCmdSprint(), + board.NewCmdBoard(), + project.NewCmdProject(), + open.NewCmdOpen(), + me.NewCmdMe(), + serverinfo.NewCmdServerInfo(), + completion.NewCmdCompletion(), + version.NewCmdVersion(), + release.NewCmdRelease(), + man.NewCmdMan(), + mcp.NewCmdMCP(), + ) +} +``` + +- [ ] **Step 4: Allowlist `mcp` and `serve` in `cmdRequireToken`?** + +No. The MCP server requires a configured Jira instance to do anything useful, and the existing token check (`PersistentPreRun` in root) is exactly what we want to fail early with a clear message. No change needed here. + +- [ ] **Step 5: Verify the binary builds and the help text appears** + +```bash +go build ./... +``` + +Expected: exit 0. + +```bash +go run ./cmd/jira mcp --help +``` + +Expected: prints the parent help text, listing `serve` as a subcommand. + +```bash +go run ./cmd/jira mcp serve --help +``` + +Expected: prints the serve help, including the JSON config snippet. + +- [ ] **Step 6: Commit** + +```bash +git add internal/cmd/mcp/mcp.go internal/cmd/mcp/serve/serve.go internal/cmd/root/root.go +git commit -m "feat(mcp): add 'jira mcp serve' cobra command (stdio transport)" +``` + +--- + +## Task 10: Update README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Find the insertion point** + +Open `README.md` and locate the `## Scripts` heading. The new section will be inserted *before* `## Scripts`, after the closing of `### Other commands`. + +- [ ] **Step 2: Add the MCP section** + +Insert the following new section immediately after the `### Other commands` block and immediately before the `## Scripts` heading. Use the existing markdown style (no emojis, plain headings). + +```markdown +## MCP server + +`jira-cli` ships an embedded [Model Context Protocol](https://modelcontextprotocol.io) server so MCP-aware hosts (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the same config, auth, and Jira API client as the rest of the CLI. + +Start it from your MCP host configuration: + +```json +{ + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } +} +``` + +The server speaks stdio and exposes the following tools: + +| Tool | Purpose | +| --- | --- | +| `search_issues` | Search by raw JQL or simple `status`/`assignee` filters. | +| `get_issue` | Full issue details including description and recent comments. | +| `create_issue` | Create a new issue in a project. | +| `add_comment` | Add a comment to an issue. | +| `transition_issue` | Move an issue to a new status by name. | + +Every tool that returns an issue also returns its browser URL so the LLM can cite or link to it directly. +``` + +- [ ] **Step 3: Verify the README renders cleanly** + +Open `README.md` in any markdown previewer (or just re-read the diff) and confirm: +- The new section appears between `### Other commands` and `## Scripts`. +- The fenced JSON block renders. +- The table renders. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: document the jira-cli MCP server" +``` + +--- + +## Task 11: Final verification + +- [ ] **Step 1: Run all tests** + +```bash +go test ./... +``` + +Expected: PASS across the whole repo. No new failures in pre-existing packages. + +- [ ] **Step 2: Run the project's CI recipe** + +```bash +make ci +``` + +Expected: lint + tests pass. If golangci-lint flags style issues in new files (e.g. missing comments on exported symbols), fix them. + +- [ ] **Step 3: Smoke-test the binary end-to-end** + +```bash +go install ./cmd/jira +jira mcp serve --help +``` + +Expected: help text appears, including the JSON snippet. + +Optionally, with a configured Jira instance: + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual","version":"0"}}}' | jira mcp serve +``` + +Expected: a JSON-RPC `initialize` response on stdout, server log lines on stderr, then the process exits when stdin closes. + +- [ ] **Step 4: Confirm the git log is clean** + +```bash +git log --oneline | head -15 +``` + +Expected: a sequence of small, focused commits matching this plan's tasks. Nothing else. + +--- + +## Notes for the implementer + +- **Stdout discipline:** Search the new code for any accidental `fmt.Println`, `fmt.Printf`, or `os.Stdout` writes before merging. Stdout belongs exclusively to JSON-RPC. +- **Context cancellation is best-effort in v1:** `pkg/jira` high-level methods do not currently accept a `context.Context`. The MCP handlers receive a context from the SDK but cannot thread it into outbound HTTP calls. A 15-second client timeout still bounds individual requests. Adding context-accepting variants to `pkg/jira` is explicitly deferred per the spec. +- **`api` package and viper:** Tools call `api.Proxy*` functions, which read `viper.GetString("installation")` internally. Tests must set `viper.Set("installation", jira.InstallationTypeCloud)` (or `InstallationTypeLocal`) and restore the previous value. The helper functions in each test file already do this. +- **SDK API drift:** The plan targets `v1.5.0`. If the implementer pulls a newer version with breaking changes, the canonical reference is the README example at https://github.com/modelcontextprotocol/go-sdk — adapt only `server.go` and `server_test.go`; the rest of the code is SDK-independent. From bd8de5065e518c001a63a1931f72be489e30c4ff Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:26:30 -0700 Subject: [PATCH 03/27] feat(mcp): add internal/mcp package skeleton Made-with: Cursor --- internal/mcp/doc.go | 4 ++++ internal/mcp/tools/doc.go | 5 +++++ 2 files changed, 9 insertions(+) create mode 100644 internal/mcp/doc.go create mode 100644 internal/mcp/tools/doc.go diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go new file mode 100644 index 00000000..9e6940af --- /dev/null +++ b/internal/mcp/doc.go @@ -0,0 +1,4 @@ +// Package mcp implements a Model Context Protocol server that exposes +// a subset of jira-cli's capabilities to MCP-aware hosts (e.g. Cursor, +// Claude Desktop). Wiring lives in internal/cmd/mcp. +package mcp diff --git a/internal/mcp/tools/doc.go b/internal/mcp/tools/doc.go new file mode 100644 index 00000000..8bc4a8b3 --- /dev/null +++ b/internal/mcp/tools/doc.go @@ -0,0 +1,5 @@ +// Package tools holds the individual MCP tool handlers. Each handler is +// a small adapter from a typed input/output struct onto the existing +// pkg/jira client. Handlers must not depend on cobra, viper, survey, or +// tui; their dependencies are injected via the Deps struct. +package tools From eb3fc80fc549556e8332c9574dec8397872c9caa Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:26:51 -0700 Subject: [PATCH 04/27] docs(mcp): defer SDK dependency to Task 2 (first SDK importer) Made-with: Cursor --- .../plans/2026-04-17-jira-mcp-server.md | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index db649a7d..0f8824f0 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -18,22 +18,15 @@ --- -## Task 0: Add SDK dependency and create empty package skeleton +## Task 0: Create empty package skeleton **Files:** -- Modify: `go.mod`, `go.sum` - Create: `internal/mcp/doc.go` - Create: `internal/mcp/tools/doc.go` -- [ ] **Step 1: Add the official MCP Go SDK dependency** - -```bash -go get github.com/modelcontextprotocol/go-sdk@v1.5.0 -``` - -Expected: `go get` completes; `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.5.0`; `go.sum` updated. If `v1.5.0` is no longer the latest stable, use the latest stable v1.x. +The MCP Go SDK is intentionally **not** added in this task. Adding it now would leave `go.mod` with a require line that no source justifies, which `go mod tidy` would revert. The SDK is added in Task 2, alongside the first source file that imports it. -- [ ] **Step 2: Create empty package marker for `internal/mcp`** +- [ ] **Step 1: Create empty package marker for `internal/mcp`** Create `internal/mcp/doc.go`: @@ -44,7 +37,7 @@ Create `internal/mcp/doc.go`: package mcp ``` -- [ ] **Step 3: Create empty package marker for `internal/mcp/tools`** +- [ ] **Step 2: Create empty package marker for `internal/mcp/tools`** Create `internal/mcp/tools/doc.go`: @@ -56,7 +49,7 @@ Create `internal/mcp/tools/doc.go`: package tools ``` -- [ ] **Step 4: Verify the module still builds** +- [ ] **Step 3: Verify the module still builds** ```bash go build ./... @@ -64,11 +57,11 @@ go build ./... Expected: exit 0, no output. -- [ ] **Step 5: Commit** +- [ ] **Step 4: Commit** ```bash -git add go.mod go.sum internal/mcp/doc.go internal/mcp/tools/doc.go -git commit -m "feat(mcp): add modelcontextprotocol/go-sdk dependency and package skeleton" +git add internal/mcp/doc.go internal/mcp/tools/doc.go +git commit -m "feat(mcp): add internal/mcp package skeleton" ``` --- @@ -181,12 +174,23 @@ git commit -m "feat(mcp): add tools.Deps with project/url/installation helpers" ## Task 2: Add the `search_issues` tool **Files:** +- Modify: `go.mod`, `go.sum` - Create: `internal/mcp/tools/search_issues.go` - Create: `internal/mcp/tools/search_issues_test.go` The tool calls `api.ProxySearch`, which selects the v2 or v3 endpoint based on the configured installation type. The `api` package reads `viper.GetString("installation")` internally; tests set that via `viper.Set("installation", ...)`. -- [ ] **Step 1: Write the failing test** +This is the first task that imports the MCP Go SDK, so the SDK dependency is added here (intentionally deferred from Task 0 so `go.mod` is never in a state that `go mod tidy` would revert). + +- [ ] **Step 1: Add the official MCP Go SDK dependency** + +```bash +go get github.com/modelcontextprotocol/go-sdk@v1.5.0 +``` + +Expected: `go get` completes; `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.5.0` (recorded as `// indirect` for now — it'll be promoted to direct when Task 8 imports it). If `v1.5.0` is no longer the latest stable, use the latest stable v1.x. + +- [ ] **Step 2: Write the failing test** Create `internal/mcp/tools/search_issues_test.go`: @@ -337,7 +341,7 @@ func TestSearchIssues_DefaultLimit(t *testing.T) { } ``` -- [ ] **Step 2: Run the test and verify it fails** +- [ ] **Step 3: Run the test and verify it fails** ```bash go test ./internal/mcp/tools/ -run TestSearchIssues -v @@ -345,7 +349,7 @@ go test ./internal/mcp/tools/ -run TestSearchIssues -v Expected: FAIL — `SearchIssues`, `SearchIssuesInput` undefined. -- [ ] **Step 3: Implement the tool** +- [ ] **Step 4: Implement the tool** Create `internal/mcp/tools/search_issues.go`: @@ -457,7 +461,7 @@ func composeJQL(project, status, assignee string) string { } ``` -- [ ] **Step 4: Run the test and verify it passes** +- [ ] **Step 5: Run the test and verify it passes** ```bash go test ./internal/mcp/tools/ -run TestSearchIssues -v @@ -465,10 +469,10 @@ go test ./internal/mcp/tools/ -run TestSearchIssues -v Expected: PASS, all 5 subtests. -- [ ] **Step 5: Commit** +- [ ] **Step 6: Commit** ```bash -git add internal/mcp/tools/search_issues.go internal/mcp/tools/search_issues_test.go +git add go.mod go.sum internal/mcp/tools/search_issues.go internal/mcp/tools/search_issues_test.go git commit -m "feat(mcp): add search_issues tool" ``` From c1a44d56e503a8c36c76698851b3b093cefcadc2 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:35:41 -0700 Subject: [PATCH 05/27] docs(mcp): defer SDK fetch to Task 8 (first SDK importer) Made-with: Cursor --- .../plans/2026-04-17-jira-mcp-server.md | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index 0f8824f0..0a4a60e4 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -174,23 +174,14 @@ git commit -m "feat(mcp): add tools.Deps with project/url/installation helpers" ## Task 2: Add the `search_issues` tool **Files:** -- Modify: `go.mod`, `go.sum` - Create: `internal/mcp/tools/search_issues.go` - Create: `internal/mcp/tools/search_issues_test.go` The tool calls `api.ProxySearch`, which selects the v2 or v3 endpoint based on the configured installation type. The `api` package reads `viper.GetString("installation")` internally; tests set that via `viper.Set("installation", ...)`. -This is the first task that imports the MCP Go SDK, so the SDK dependency is added here (intentionally deferred from Task 0 so `go.mod` is never in a state that `go mod tidy` would revert). - -- [ ] **Step 1: Add the official MCP Go SDK dependency** +The MCP Go SDK is **not** added in this task either — Task 2's source files don't import it (only `api`, `context`, `fmt`, `strings`). The SDK fetch lands in Task 8, the first task whose source actually imports `github.com/modelcontextprotocol/go-sdk/mcp`. -```bash -go get github.com/modelcontextprotocol/go-sdk@v1.5.0 -``` - -Expected: `go get` completes; `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.5.0` (recorded as `// indirect` for now — it'll be promoted to direct when Task 8 imports it). If `v1.5.0` is no longer the latest stable, use the latest stable v1.x. - -- [ ] **Step 2: Write the failing test** +- [ ] **Step 1: Write the failing test** Create `internal/mcp/tools/search_issues_test.go`: @@ -341,7 +332,7 @@ func TestSearchIssues_DefaultLimit(t *testing.T) { } ``` -- [ ] **Step 3: Run the test and verify it fails** +- [ ] **Step 2: Run the test and verify it fails** ```bash go test ./internal/mcp/tools/ -run TestSearchIssues -v @@ -349,7 +340,7 @@ go test ./internal/mcp/tools/ -run TestSearchIssues -v Expected: FAIL — `SearchIssues`, `SearchIssuesInput` undefined. -- [ ] **Step 4: Implement the tool** +- [ ] **Step 3: Implement the tool** Create `internal/mcp/tools/search_issues.go`: @@ -461,7 +452,7 @@ func composeJQL(project, status, assignee string) string { } ``` -- [ ] **Step 5: Run the test and verify it passes** +- [ ] **Step 4: Run the test and verify it passes** ```bash go test ./internal/mcp/tools/ -run TestSearchIssues -v @@ -469,10 +460,10 @@ go test ./internal/mcp/tools/ -run TestSearchIssues -v Expected: PASS, all 5 subtests. -- [ ] **Step 6: Commit** +- [ ] **Step 5: Commit** ```bash -git add go.mod go.sum internal/mcp/tools/search_issues.go internal/mcp/tools/search_issues_test.go +git add internal/mcp/tools/search_issues.go internal/mcp/tools/search_issues_test.go git commit -m "feat(mcp): add search_issues tool" ``` @@ -1536,12 +1527,23 @@ git commit -m "feat(mcp): add transition_issue tool" ## Task 8: Build the MCP server (registration + in-memory round trip test) **Files:** +- Modify: `go.mod`, `go.sum` - Create: `internal/mcp/server.go` - Create: `internal/mcp/server_test.go` The server constructor takes a `*tools.Deps`, builds a `*mcp.Server`, and registers all five tools using the SDK's `mcp.AddTool` generic helper. Each registration adapts the `(d, in) -> (out, err)` tool function to the SDK's `(ctx, *CallToolRequest, In) -> (*CallToolResult, Out, error)` signature. -- [ ] **Step 1: Write the failing test** +This is the first task whose source actually imports the MCP Go SDK, so the SDK dependency is added here. (Adding it earlier would have left `go.mod` in a state that `go mod tidy` would revert.) + +- [ ] **Step 1: Add the official MCP Go SDK dependency** + +```bash +go get github.com/modelcontextprotocol/go-sdk@v1.5.0 +``` + +Expected: `go get` completes; `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.5.0`. Once Steps 2–5 below add the source files that import the SDK, `go mod tidy` will keep the requirement and (if previously listed as `// indirect`) promote it to a direct require. If `v1.5.0` is no longer the latest stable, use the latest stable v1.x. + +- [ ] **Step 2: Write the failing test** Create `internal/mcp/server_test.go`: @@ -1628,7 +1630,7 @@ func TestServer_ListsAllTools(t *testing.T) { } ``` -- [ ] **Step 2: Run the test and verify it fails** +- [ ] **Step 3: Run the test and verify it fails** ```bash go test ./internal/mcp/ -run TestServer -v @@ -1636,7 +1638,7 @@ go test ./internal/mcp/ -run TestServer -v Expected: FAIL — `NewServer` undefined. -- [ ] **Step 3: Implement the server** +- [ ] **Step 4: Implement the server** Create `internal/mcp/server.go`: @@ -1737,7 +1739,7 @@ func registerTool[In, Out any]( } ``` -- [ ] **Step 4: Run the test and verify it passes** +- [ ] **Step 5: Run the test and verify it passes** ```bash go test ./internal/mcp/ -run TestServer -v @@ -1745,7 +1747,7 @@ go test ./internal/mcp/ -run TestServer -v Expected: PASS. If the SDK's exact symbol names differ slightly between v1.5.0 minor versions (e.g. `mcp.NewInMemoryTransports` vs `mcp.NewInMemoryTransport`), check `pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp` for the current API and adjust the test only — the production code in `server.go` follows the README example verbatim. -- [ ] **Step 5: Run the whole MCP test suite to confirm everything still passes** +- [ ] **Step 6: Run the whole MCP test suite to confirm everything still passes** ```bash go test ./internal/mcp/... -v @@ -1753,10 +1755,13 @@ go test ./internal/mcp/... -v Expected: PASS, all suites. -- [ ] **Step 6: Commit** +- [ ] **Step 7: Commit** + +Before committing, run `go mod tidy` to make sure `go.mod`/`go.sum` are minimal and that the SDK requirement is now recorded as a direct require (since `server.go` and `server_test.go` import it). ```bash -git add internal/mcp/server.go internal/mcp/server_test.go +go mod tidy +git add go.mod go.sum internal/mcp/server.go internal/mcp/server_test.go git commit -m "feat(mcp): add Server constructor and in-memory round-trip test" ``` From fb8f581d3694dd34f0ff964d3ce7a771aee0e079 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:37:02 -0700 Subject: [PATCH 06/27] feat(mcp): add tools.Deps with project/url/installation helpers Made-with: Cursor --- internal/mcp/tools/deps.go | 31 +++++++++++++++++++++++++++++++ internal/mcp/tools/deps_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 internal/mcp/tools/deps.go create mode 100644 internal/mcp/tools/deps_test.go diff --git a/internal/mcp/tools/deps.go b/internal/mcp/tools/deps.go new file mode 100644 index 00000000..265e79cf --- /dev/null +++ b/internal/mcp/tools/deps.go @@ -0,0 +1,31 @@ +package tools + +import ( + "strings" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// Deps bundles the runtime dependencies every MCP tool handler needs. +// It is constructed once in internal/cmd/mcp/serve and shared (read-only) +// across all tool invocations. +type Deps struct { + Client *jira.Client + Server string + DefaultProject string + Installation string +} + +// IssueURL returns the browser URL for a given issue key. +func (d *Deps) IssueURL(key string) string { + return strings.TrimRight(d.Server, "/") + "/browse/" + key +} + +// ResolveProject returns explicit if non-empty, otherwise the configured +// default project key. +func (d *Deps) ResolveProject(explicit string) string { + if explicit != "" { + return explicit + } + return d.DefaultProject +} diff --git a/internal/mcp/tools/deps_test.go b/internal/mcp/tools/deps_test.go new file mode 100644 index 00000000..df47e3bc --- /dev/null +++ b/internal/mcp/tools/deps_test.go @@ -0,0 +1,27 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeps_IssueURL(t *testing.T) { + d := &Deps{Server: "https://example.atlassian.net"} + assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) +} + +func TestDeps_IssueURL_TrimsTrailingSlash(t *testing.T) { + d := &Deps{Server: "https://example.atlassian.net/"} + assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) +} + +func TestDeps_ResolveProject_UsesDefaultWhenEmpty(t *testing.T) { + d := &Deps{DefaultProject: "ABC"} + assert.Equal(t, "ABC", d.ResolveProject("")) +} + +func TestDeps_ResolveProject_PrefersExplicit(t *testing.T) { + d := &Deps{DefaultProject: "ABC"} + assert.Equal(t, "XYZ", d.ResolveProject("XYZ")) +} From 9a39e5bfc13f7787ec50ada7bb0e438fb6d21b6d Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:40:01 -0700 Subject: [PATCH 07/27] docs(mcp): honor browse_server viper override in serve plan Made-with: Cursor --- docs/superpowers/plans/2026-04-17-jira-mcp-server.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index 0a4a60e4..9ae2a781 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -1865,10 +1865,18 @@ func run(cmd *cobra.Command, _ []string) error { return fmt.Errorf("no Jira server configured. Run 'jira init' to set up the tool") } + // Honor browse_server override the same way internal/cmdutil.GenerateServerBrowseURL does, + // so MCP-emitted issue URLs match what the rest of the CLI produces for users whose web + // client and API endpoints differ. + browseServer := server + if v := viper.GetString("browse_server"); v != "" { + browseServer = v + } + debug := viper.GetBool("debug") deps := &tools.Deps{ Client: api.DefaultClient(debug), - Server: server, + Server: browseServer, DefaultProject: viper.GetString("project.key"), Installation: viper.GetString("installation"), } From 59bdbfd294eb16e0053344636c748c27fb1d39a7 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:42:48 -0700 Subject: [PATCH 08/27] feat(mcp): add search_issues tool Made-with: Cursor --- internal/mcp/tools/search_issues.go | 107 +++++++++++++++++ internal/mcp/tools/search_issues_test.go | 144 +++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 internal/mcp/tools/search_issues.go create mode 100644 internal/mcp/tools/search_issues_test.go diff --git a/internal/mcp/tools/search_issues.go b/internal/mcp/tools/search_issues.go new file mode 100644 index 00000000..6a0f8b8c --- /dev/null +++ b/internal/mcp/tools/search_issues.go @@ -0,0 +1,107 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/ankitpokhrel/jira-cli/api" +) + +// SearchIssuesInput is the input schema for the search_issues tool. +type SearchIssuesInput struct { + JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. Passed through verbatim unless project is also set, in which case the JQL is wrapped as 'project = X AND (your JQL)'. If you set project alongside JQL, your JQL must not contain its own ORDER BY clause."` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project when JQL is omitted; when JQL is provided, only set this if you want the JQL scoped to a specific project)"` + Status string `json:"status,omitempty" jsonschema:"filter by status name, e.g. \"To Do\""` + Assignee string `json:"assignee,omitempty" jsonschema:"filter by assignee. Use \"me\" for the configured user, \"none\" for unassigned, or a username/account id."` + Limit int `json:"limit,omitempty" jsonschema:"maximum number of issues to return (default 50, clamped to 100)"` +} + +// SearchIssuesOutput is the structured result of the search_issues tool. +type SearchIssuesOutput struct { + // Returned is the number of issues in this response page. The Jira v3 + // /search/jql endpoint does not return a total match count; callers that + // need to know whether more results exist should rerun with a larger Limit. + Returned int `json:"returned"` + Issues []IssueBrief `json:"issues"` +} + +// IssueBrief is a lean projection of jira.Issue used for list-style outputs. +type IssueBrief struct { + Key string `json:"key"` + Summary string `json:"summary"` + Status string `json:"status"` + Type string `json:"type"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Reporter string `json:"reporter"` + Created string `json:"created"` + Updated string `json:"updated"` + URL string `json:"url"` +} + +// SearchIssues runs the search_issues tool. +func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssuesOutput, error) { + limit := in.Limit + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + jql := strings.TrimSpace(in.JQL) + project := d.ResolveProject(in.Project) + + if jql == "" { + jql = composeJQL(project, in.Status, in.Assignee) + } else if in.Project != "" { + jql = fmt.Sprintf(`project = %q AND (%s)`, in.Project, jql) + } + + res, err := api.ProxySearch(d.Client, jql, 0, uint(limit)) + if err != nil { + return SearchIssuesOutput{}, err + } + + out := SearchIssuesOutput{Issues: make([]IssueBrief, 0, len(res.Issues))} + for _, iss := range res.Issues { + out.Issues = append(out.Issues, IssueBrief{ + Key: iss.Key, + Summary: iss.Fields.Summary, + Status: iss.Fields.Status.Name, + Type: iss.Fields.IssueType.Name, + Priority: iss.Fields.Priority.Name, + Assignee: iss.Fields.Assignee.Name, + Reporter: iss.Fields.Reporter.Name, + Created: iss.Fields.Created, + Updated: iss.Fields.Updated, + URL: d.IssueURL(iss.Key), + }) + } + out.Returned = len(out.Issues) + return out, nil +} + +func composeJQL(project, status, assignee string) string { + var clauses []string + if project != "" { + clauses = append(clauses, fmt.Sprintf(`project = %q`, project)) + } + if status != "" { + clauses = append(clauses, fmt.Sprintf(`status = %q`, status)) + } + switch strings.ToLower(assignee) { + case "": + case "me": + clauses = append(clauses, "assignee = currentUser()") + case "none", "x": + clauses = append(clauses, "assignee is EMPTY") + default: + clauses = append(clauses, fmt.Sprintf(`assignee = %q`, assignee)) + } + if len(clauses) == 0 { + return "" + } + return strings.Join(clauses, " AND ") + " ORDER BY created DESC" +} diff --git a/internal/mcp/tools/search_issues_test.go b/internal/mcp/tools/search_issues_test.go new file mode 100644 index 00000000..5c573c50 --- /dev/null +++ b/internal/mcp/tools/search_issues_test.go @@ -0,0 +1,144 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const searchResponseBody = `{ + "issues": [ + { + "key": "TEST-1", + "fields": { + "summary": "First issue", + "status": {"name": "To Do"}, + "issueType": {"name": "Task"}, + "priority": {"name": "Medium"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "labels": [] + } + } + ] +}` + +func newSearchTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + + cleanup := func() { + server.Close() + viper.Set("installation", prevInstall) + } + return deps, cleanup +} + +func TestSearchIssues_UsesProvidedJQL(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/search/jql", r.URL.Path) + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(searchResponseBody)) + }) + defer cleanup() + + out, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ + JQL: "summary ~ first", + }) + require.NoError(t, err) + + assert.Equal(t, "summary ~ first", capturedJQL) + require.Len(t, out.Issues, 1) + assert.Equal(t, "TEST-1", out.Issues[0].Key) + assert.Equal(t, "First issue", out.Issues[0].Summary) + assert.Equal(t, "To Do", out.Issues[0].Status) + assert.Equal(t, "Alice", out.Issues[0].Assignee) + assert.True(t, strings.HasSuffix(out.Issues[0].URL, "/browse/TEST-1")) +} + +func TestSearchIssues_ComposesJQLFromFilters(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ + Status: "In Progress", + Assignee: "alice", + }) + require.NoError(t, err) + + assert.Contains(t, capturedJQL, `project = "TEST"`) + assert.Contains(t, capturedJQL, `status = "In Progress"`) + assert.Contains(t, capturedJQL, `assignee = "alice"`) +} + +func TestSearchIssues_AssigneeMe(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Assignee: "me"}) + require.NoError(t, err) + assert.Contains(t, capturedJQL, `assignee = currentUser()`) +} + +func TestSearchIssues_LimitClampedTo100(t *testing.T) { + var capturedLimit string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedLimit = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Limit: 500}) + require.NoError(t, err) + assert.Equal(t, "100", capturedLimit) +} + +func TestSearchIssues_DefaultLimit(t *testing.T) { + var capturedLimit string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedLimit = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{}) + require.NoError(t, err) + assert.Equal(t, "50", capturedLimit) +} From e53ccb4abd7b02f795da7fc70bca64797cf4b75b Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:43:31 -0700 Subject: [PATCH 09/27] docs(mcp): clarify JQL passthrough vs. composed-JQL project scoping Made-with: Cursor --- .../superpowers/plans/2026-04-17-jira-mcp-server.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index 9ae2a781..d92f7953 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -395,12 +395,17 @@ func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssue } jql := strings.TrimSpace(in.JQL) - project := d.ResolveProject(in.Project) if jql == "" { - jql = composeJQL(project, in.Status, in.Assignee) - } else if project != "" && !strings.Contains(strings.ToLower(jql), "project") { - jql = fmt.Sprintf(`project = %q AND (%s)`, project, jql) + // No JQL → compose one from the simple filters, scoped to the resolved + // (default-or-explicit) project so plain "list my open issues" calls + // stay inside the configured project. + jql = composeJQL(d.ResolveProject(in.Project), in.Status, in.Assignee) + } else if in.Project != "" && !strings.Contains(strings.ToLower(jql), "project") { + // JQL is a power-user escape hatch: pass it through unmodified by + // default, and only wrap with a project clause when the caller + // explicitly opted in by setting Project on the input. + jql = fmt.Sprintf(`project = %q AND (%s)`, in.Project, jql) } res, err := api.ProxySearch(d.Client, jql, 0, uint(limit)) From 0537782b72f03e7418ea4b20b935f1fe623cadeb Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 11:52:45 -0700 Subject: [PATCH 10/27] docs(mcp): tighten search_issues schema and rename Total to Returned Made-with: Cursor --- .../plans/2026-04-17-jira-mcp-server.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index d92f7953..347a4f83 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -357,8 +357,8 @@ import ( // SearchIssuesInput is the input schema for the search_issues tool. type SearchIssuesInput struct { - JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. If set, other filter fields are ignored except project (which scopes the JQL when present)."` - Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project)"` + JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. Passed through verbatim unless project is also set, in which case the JQL is wrapped as 'project = X AND (your JQL)'. If you set project alongside JQL, your JQL must not contain its own ORDER BY clause."` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project when JQL is omitted; when JQL is provided, only set this if you want the JQL scoped to a specific project)"` Status string `json:"status,omitempty" jsonschema:"filter by status name, e.g. \"To Do\""` Assignee string `json:"assignee,omitempty" jsonschema:"filter by assignee. Use \"me\" for the configured user, \"none\" for unassigned, or a username/account id."` Limit int `json:"limit,omitempty" jsonschema:"maximum number of issues to return (default 50, clamped to 100)"` @@ -366,8 +366,11 @@ type SearchIssuesInput struct { // SearchIssuesOutput is the structured result of the search_issues tool. type SearchIssuesOutput struct { - Total int `json:"total"` - Issues []IssueBrief `json:"issues"` + // Returned is the number of issues in this response page. The Jira v3 + // /search/jql endpoint does not return a total match count; callers that + // need to know whether more results exist should rerun with a larger Limit. + Returned int `json:"returned"` + Issues []IssueBrief `json:"issues"` } // IssueBrief is a lean projection of jira.Issue used for list-style outputs. @@ -401,10 +404,12 @@ func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssue // (default-or-explicit) project so plain "list my open issues" calls // stay inside the configured project. jql = composeJQL(d.ResolveProject(in.Project), in.Status, in.Assignee) - } else if in.Project != "" && !strings.Contains(strings.ToLower(jql), "project") { + } else if in.Project != "" { // JQL is a power-user escape hatch: pass it through unmodified by - // default, and only wrap with a project clause when the caller - // explicitly opted in by setting Project on the input. + // default. Wrap with a project clause only when the caller explicitly + // opted in by setting Project on the input. Per the input schema, the + // caller is responsible for not including ORDER BY in their JQL when + // they opt into wrapping (Jira disallows ORDER BY inside parentheses). jql = fmt.Sprintf(`project = %q AND (%s)`, in.Project, jql) } @@ -428,7 +433,7 @@ func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssue URL: d.IssueURL(iss.Key), }) } - out.Total = len(out.Issues) + out.Returned = len(out.Issues) return out, nil } From d7fc5adfef502d596db0c66fd7007c4acf806c81 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:14:48 -0700 Subject: [PATCH 11/27] feat(mcp): add bodyToMarkdown helper for ADF/string description bodies Made-with: Cursor --- internal/mcp/tools/markdown.go | 23 +++++++++++++++++ internal/mcp/tools/markdown_test.go | 38 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 internal/mcp/tools/markdown.go create mode 100644 internal/mcp/tools/markdown_test.go diff --git a/internal/mcp/tools/markdown.go b/internal/mcp/tools/markdown.go new file mode 100644 index 00000000..cc3a7c63 --- /dev/null +++ b/internal/mcp/tools/markdown.go @@ -0,0 +1,23 @@ +package tools + +import ( + "github.com/ankitpokhrel/jira-cli/pkg/adf" +) + +// bodyToMarkdown renders a Jira description-or-comment body field to markdown. +// The body is interface{} because v3 returns *adf.ADF and v2 returns string. +func bodyToMarkdown(body any) string { + switch v := body.(type) { + case nil: + return "" + case string: + return v + case *adf.ADF: + if v == nil { + return "" + } + return adf.NewTranslator(v, adf.NewMarkdownTranslator()).Translate() + default: + return "" + } +} diff --git a/internal/mcp/tools/markdown_test.go b/internal/mcp/tools/markdown_test.go new file mode 100644 index 00000000..b0adccd2 --- /dev/null +++ b/internal/mcp/tools/markdown_test.go @@ -0,0 +1,38 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ankitpokhrel/jira-cli/pkg/adf" +) + +func TestBodyToMarkdown_String(t *testing.T) { + assert.Equal(t, "hello", bodyToMarkdown("hello")) +} + +func TestBodyToMarkdown_Nil(t *testing.T) { + assert.Equal(t, "", bodyToMarkdown(nil)) +} + +func TestBodyToMarkdown_ADF(t *testing.T) { + doc := &adf.ADF{ + Version: 1, + DocType: "doc", + Content: []*adf.Node{ + { + NodeType: "paragraph", + Content: []*adf.Node{ + {NodeType: "text", NodeValue: adf.NodeValue{Text: "Hello world"}}, + }, + }, + }, + } + got := bodyToMarkdown(doc) + assert.Contains(t, got, "Hello world") +} + +func TestBodyToMarkdown_UnknownType(t *testing.T) { + assert.Equal(t, "", bodyToMarkdown(123)) +} From e5a58f8a3df9c8a3e80154f5b0f6863bd41435d6 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:15:41 -0700 Subject: [PATCH 12/27] feat(mcp): add get_issue tool Made-with: Cursor --- internal/mcp/tools/get_issue.go | 109 ++++++++++++++++++++++ internal/mcp/tools/get_issue_test.go | 133 +++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 internal/mcp/tools/get_issue.go create mode 100644 internal/mcp/tools/get_issue_test.go diff --git a/internal/mcp/tools/get_issue.go b/internal/mcp/tools/get_issue.go new file mode 100644 index 00000000..a65859da --- /dev/null +++ b/internal/mcp/tools/get_issue.go @@ -0,0 +1,109 @@ +package tools + +import ( + "context" + "errors" + + "github.com/ankitpokhrel/jira-cli/api" + issuefilter "github.com/ankitpokhrel/jira-cli/pkg/jira/filter/issue" +) + +// GetIssueInput is the input schema for the get_issue tool. +type GetIssueInput struct { + Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` + IncludeComments *bool `json:"include_comments,omitempty" jsonschema:"include recent comments in the response (default true)"` + CommentLimit int `json:"comment_limit,omitempty" jsonschema:"maximum number of recent comments to include (default 10)"` +} + +// GetIssueOutput is the structured result of the get_issue tool. +type GetIssueOutput struct { + Key string `json:"key"` + Summary string `json:"summary"` + Status string `json:"status"` + Type string `json:"type"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Reporter string `json:"reporter"` + Labels []string `json:"labels"` + Components []string `json:"components"` + FixVersions []string `json:"fix_versions"` + Parent string `json:"parent,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` + Description string `json:"description"` + Comments []CommentBrief `json:"comments,omitempty"` + URL string `json:"url"` +} + +// CommentBrief is a lean projection of an issue comment. +type CommentBrief struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Created string `json:"created"` +} + +// GetIssue runs the get_issue tool. +func GetIssue(_ context.Context, d *Deps, in GetIssueInput) (GetIssueOutput, error) { + if in.Key == "" { + return GetIssueOutput{}, errors.New("key is required") + } + + includeComments := true + if in.IncludeComments != nil { + includeComments = *in.IncludeComments + } + commentLimit := in.CommentLimit + if commentLimit <= 0 { + commentLimit = 10 + } + + iss, err := api.ProxyGetIssue(d.Client, in.Key, issuefilter.NewNumCommentsFilter(uint(commentLimit))) + if err != nil { + return GetIssueOutput{}, err + } + + out := GetIssueOutput{ + Key: iss.Key, + Summary: iss.Fields.Summary, + Status: iss.Fields.Status.Name, + Type: iss.Fields.IssueType.Name, + Priority: iss.Fields.Priority.Name, + Assignee: iss.Fields.Assignee.Name, + Reporter: iss.Fields.Reporter.Name, + Labels: iss.Fields.Labels, + Created: iss.Fields.Created, + Updated: iss.Fields.Updated, + Description: bodyToMarkdown(iss.Fields.Description), + URL: d.IssueURL(iss.Key), + } + if iss.Fields.Parent != nil { + out.Parent = iss.Fields.Parent.Key + } + for _, c := range iss.Fields.Components { + out.Components = append(out.Components, c.Name) + } + for _, v := range iss.Fields.FixVersions { + out.FixVersions = append(out.FixVersions, v.Name) + } + + if includeComments && iss.Fields.Comment.Total > 0 { + comments := iss.Fields.Comment.Comments + // Take the last commentLimit comments (newest), preserving chronological order. + start := 0 + if len(comments) > commentLimit { + start = len(comments) - commentLimit + } + for i := start; i < len(comments); i++ { + c := comments[i] + out.Comments = append(out.Comments, CommentBrief{ + ID: c.ID, + Author: c.Author.DisplayName, + Body: bodyToMarkdown(c.Body), + Created: c.Created, + }) + } + } + + return out, nil +} diff --git a/internal/mcp/tools/get_issue_test.go b/internal/mcp/tools/get_issue_test.go new file mode 100644 index 00000000..3f3f7b71 --- /dev/null +++ b/internal/mcp/tools/get_issue_test.go @@ -0,0 +1,133 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const getIssueResponseV3 = `{ + "key": "TEST-1", + "fields": { + "summary": "Sample bug", + "status": {"name": "In Progress"}, + "issueType": {"name": "Bug"}, + "priority": {"name": "High"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "labels": ["backend", "urgent"], + "components": [{"name": "API"}], + "fixVersions": [{"name": "v2.0"}], + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "description": { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Repro steps"}]} + ] + }, + "comment": { + "total": 1, + "comments": [ + { + "id": "100", + "author": {"displayName": "Carol", "emailAddress": "carol@example.com", "active": true}, + "body": { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Looking into it"}]} + ] + }, + "created": "2026-01-03T10:00:00.000+0000" + } + ] + } + } +}` + +func newIssueTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestGetIssue_Cloud(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV3)) + }) + defer cleanup() + + out, err := GetIssue(context.Background(), deps, GetIssueInput{Key: "TEST-1"}) + require.NoError(t, err) + + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "Sample bug", out.Summary) + assert.Equal(t, "In Progress", out.Status) + assert.Equal(t, "Bug", out.Type) + assert.Equal(t, "High", out.Priority) + assert.Equal(t, "Alice", out.Assignee) + assert.Equal(t, "Bob", out.Reporter) + assert.Equal(t, []string{"backend", "urgent"}, out.Labels) + assert.Equal(t, []string{"API"}, out.Components) + assert.Equal(t, []string{"v2.0"}, out.FixVersions) + assert.Contains(t, out.Description, "Repro steps") + require.Len(t, out.Comments, 1) + assert.Equal(t, "Carol", out.Comments[0].Author) + assert.Contains(t, out.Comments[0].Body, "Looking into it") + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestGetIssue_RequiresKey(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called when key is missing") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := GetIssue(context.Background(), deps, GetIssueInput{Key: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") +} + +func TestGetIssue_RespectsCommentLimit(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV3)) + }) + defer cleanup() + + out, err := GetIssue(context.Background(), deps, GetIssueInput{ + Key: "TEST-1", + IncludeComments: boolPtr(false), + }) + require.NoError(t, err) + assert.Empty(t, out.Comments) +} + +func boolPtr(b bool) *bool { return &b } From b0cde56433bdc53eb6b2367b1614d476dbb8a5c0 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:16:25 -0700 Subject: [PATCH 13/27] feat(mcp): add create_issue tool Made-with: Cursor --- internal/mcp/tools/create_issue.go | 62 +++++++++++++ internal/mcp/tools/create_issue_test.go | 112 ++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 internal/mcp/tools/create_issue.go create mode 100644 internal/mcp/tools/create_issue_test.go diff --git a/internal/mcp/tools/create_issue.go b/internal/mcp/tools/create_issue.go new file mode 100644 index 00000000..1963a8a5 --- /dev/null +++ b/internal/mcp/tools/create_issue.go @@ -0,0 +1,62 @@ +package tools + +import ( + "context" + "errors" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// CreateIssueInput is the input schema for the create_issue tool. +type CreateIssueInput struct { + Summary string `json:"summary" jsonschema:"issue summary (required)"` + Type string `json:"type" jsonschema:"issue type, e.g. \"Task\", \"Bug\", \"Story\" (required)"` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project)"` + Description string `json:"description,omitempty" jsonschema:"issue description in markdown"` + Priority string `json:"priority,omitempty" jsonschema:"priority name, e.g. \"High\""` + Labels []string `json:"labels,omitempty"` + Components []string `json:"components,omitempty"` + Assignee string `json:"assignee,omitempty" jsonschema:"assignee account id (Cloud) or username (Local)"` + Parent string `json:"parent,omitempty" jsonschema:"parent issue key (use this for epic link or sub-task parent)"` +} + +// CreateIssueOutput is the structured result of the create_issue tool. +type CreateIssueOutput struct { + Key string `json:"key"` + URL string `json:"url"` +} + +// CreateIssue runs the create_issue tool. +func CreateIssue(_ context.Context, d *Deps, in CreateIssueInput) (CreateIssueOutput, error) { + if in.Summary == "" { + return CreateIssueOutput{}, errors.New("summary is required") + } + if in.Type == "" { + return CreateIssueOutput{}, errors.New("type is required") + } + + project := d.ResolveProject(in.Project) + if project == "" { + return CreateIssueOutput{}, errors.New("project is required (no default project configured)") + } + + req := &jira.CreateRequest{ + Project: project, + IssueType: in.Type, + Summary: in.Summary, + Body: in.Description, + Priority: in.Priority, + Labels: in.Labels, + Components: in.Components, + Assignee: in.Assignee, + ParentIssueKey: in.Parent, + } + req.ForInstallationType(d.Installation) + + resp, err := api.ProxyCreate(d.Client, req) + if err != nil { + return CreateIssueOutput{}, err + } + return CreateIssueOutput{Key: resp.Key, URL: d.IssueURL(resp.Key)}, nil +} diff --git a/internal/mcp/tools/create_issue_test.go b/internal/mcp/tools/create_issue_test.go new file mode 100644 index 00000000..0bfece17 --- /dev/null +++ b/internal/mcp/tools/create_issue_test.go @@ -0,0 +1,112 @@ +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func newCreateTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestCreateIssue_Success(t *testing.T) { + var capturedBody map[string]any + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &capturedBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "TEST-42"}`)) + }) + defer cleanup() + + out, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "New thing", + Type: "Task", + }) + require.NoError(t, err) + assert.Equal(t, "TEST-42", out.Key) + assert.Equal(t, deps.IssueURL("TEST-42"), out.URL) + + fields, _ := capturedBody["fields"].(map[string]any) + require.NotNil(t, fields) + project, _ := fields["project"].(map[string]any) + assert.Equal(t, "TEST", project["key"]) + assert.Equal(t, "New thing", fields["summary"]) +} + +func TestCreateIssue_RequiresSummary(t *testing.T) { + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Type: "Task"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "summary is required") +} + +func TestCreateIssue_RequiresType(t *testing.T) { + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Summary: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type is required") +} + +func TestCreateIssue_OverridesProject(t *testing.T) { + var capturedProject string + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + fields, _ := body["fields"].(map[string]any) + project, _ := fields["project"].(map[string]any) + capturedProject, _ = project["key"].(string) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "OTHER-1"}`)) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "x", Type: "Task", Project: "OTHER", + }) + require.NoError(t, err) + assert.Equal(t, "OTHER", capturedProject) +} From 95e405ef8b0b63f76c43a784eab8a2ae51279ef4 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:16:59 -0700 Subject: [PATCH 14/27] feat(mcp): add add_comment tool Made-with: Cursor --- internal/mcp/tools/add_comment.go | 33 +++++++++++ internal/mcp/tools/add_comment_test.go | 77 ++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 internal/mcp/tools/add_comment.go create mode 100644 internal/mcp/tools/add_comment_test.go diff --git a/internal/mcp/tools/add_comment.go b/internal/mcp/tools/add_comment.go new file mode 100644 index 00000000..cbb76a93 --- /dev/null +++ b/internal/mcp/tools/add_comment.go @@ -0,0 +1,33 @@ +package tools + +import ( + "context" + "errors" +) + +// AddCommentInput is the input schema for the add_comment tool. +type AddCommentInput struct { + Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` + Body string `json:"body" jsonschema:"comment body in markdown (required)"` + Internal bool `json:"internal,omitempty" jsonschema:"mark as an internal (service-desk) comment"` +} + +// AddCommentOutput is the structured result of the add_comment tool. +type AddCommentOutput struct { + Key string `json:"key"` + URL string `json:"url"` +} + +// AddComment runs the add_comment tool. +func AddComment(_ context.Context, d *Deps, in AddCommentInput) (AddCommentOutput, error) { + if in.Key == "" { + return AddCommentOutput{}, errors.New("key is required") + } + if in.Body == "" { + return AddCommentOutput{}, errors.New("body is required") + } + if err := d.Client.AddIssueComment(in.Key, in.Body, in.Internal); err != nil { + return AddCommentOutput{}, err + } + return AddCommentOutput{Key: in.Key, URL: d.IssueURL(in.Key)}, nil +} diff --git a/internal/mcp/tools/add_comment_test.go b/internal/mcp/tools/add_comment_test.go new file mode 100644 index 00000000..bb424452 --- /dev/null +++ b/internal/mcp/tools/add_comment_test.go @@ -0,0 +1,77 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func newCommentTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestAddComment_Success(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1/comment", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "999"}`)) + }) + defer cleanup() + + out, err := AddComment(context.Background(), deps, AddCommentInput{ + Key: "TEST-1", + Body: "Hello world", + }) + require.NoError(t, err) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestAddComment_RequiresKey(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := AddComment(context.Background(), deps, AddCommentInput{Body: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") +} + +func TestAddComment_RequiresBody(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := AddComment(context.Background(), deps, AddCommentInput{Key: "TEST-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "body is required") +} From b14e3dd885aec773171b9107fbb25937b0505391 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:17:54 -0700 Subject: [PATCH 15/27] feat(mcp): add transition_issue tool Made-with: Cursor --- internal/mcp/tools/transition_issue.go | 99 ++++++++++++++ internal/mcp/tools/transition_issue_test.go | 138 ++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 internal/mcp/tools/transition_issue.go create mode 100644 internal/mcp/tools/transition_issue_test.go diff --git a/internal/mcp/tools/transition_issue.go b/internal/mcp/tools/transition_issue.go new file mode 100644 index 00000000..ccc3ec30 --- /dev/null +++ b/internal/mcp/tools/transition_issue.go @@ -0,0 +1,99 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// TransitionIssueInput is the input schema for the transition_issue tool. +type TransitionIssueInput struct { + Key string `json:"key" jsonschema:"issue key (required)"` + Transition string `json:"transition" jsonschema:"target transition name, e.g. \"In Progress\" (required, case-insensitive)"` + Comment string `json:"comment,omitempty" jsonschema:"optional comment to add as part of the transition (workflow must allow it)"` + Resolution string `json:"resolution,omitempty" jsonschema:"optional resolution name to set, e.g. \"Fixed\""` + Assignee string `json:"assignee,omitempty" jsonschema:"optional new assignee (account id on Cloud, username on Local)"` +} + +// TransitionIssueOutput is the structured result of the transition_issue tool. +type TransitionIssueOutput struct { + Key string `json:"key"` + ToStatus string `json:"to_status"` + URL string `json:"url"` +} + +// TransitionIssue runs the transition_issue tool. +func TransitionIssue(_ context.Context, d *Deps, in TransitionIssueInput) (TransitionIssueOutput, error) { + if in.Key == "" { + return TransitionIssueOutput{}, errors.New("key is required") + } + if in.Transition == "" { + return TransitionIssueOutput{}, errors.New("transition is required") + } + + transitions, err := api.ProxyTransitions(d.Client, in.Key) + if err != nil { + return TransitionIssueOutput{}, err + } + + var match *jira.Transition + target := strings.ToLower(strings.TrimSpace(in.Transition)) + available := make([]string, 0, len(transitions)) + for _, t := range transitions { + available = append(available, t.Name) + if strings.ToLower(t.Name) == target { + match = t + } + } + if match == nil { + return TransitionIssueOutput{}, fmt.Errorf( + "unknown transition %q for %s. Valid transitions: %s", + in.Transition, in.Key, strings.Join(available, ", "), + ) + } + + req := &jira.TransitionRequest{ + Transition: &jira.TransitionRequestData{ + ID: match.ID.String(), + Name: match.Name, + }, + } + if in.Comment != "" { + req.Update = &jira.TransitionRequestUpdate{} + req.Update.Comment = append(req.Update.Comment, struct { + Add struct { + Body string `json:"body"` + } `json:"add"` + }{ + Add: struct { + Body string `json:"body"` + }{Body: in.Comment}, + }) + } + if in.Resolution != "" || in.Assignee != "" { + req.Fields = &jira.TransitionRequestFields{} + if in.Resolution != "" { + req.Fields.Resolution = &struct { + Name string `json:"name"` + }{Name: in.Resolution} + } + if in.Assignee != "" { + req.Fields.Assignee = &struct { + Name string `json:"name"` + }{Name: in.Assignee} + } + } + + if _, err := d.Client.Transition(in.Key, req); err != nil { + return TransitionIssueOutput{}, err + } + return TransitionIssueOutput{ + Key: in.Key, + ToStatus: match.Name, + URL: d.IssueURL(in.Key), + }, nil +} diff --git a/internal/mcp/tools/transition_issue_test.go b/internal/mcp/tools/transition_issue_test.go new file mode 100644 index 00000000..cce00df2 --- /dev/null +++ b/internal/mcp/tools/transition_issue_test.go @@ -0,0 +1,138 @@ +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const transitionsResponse = `{ + "transitions": [ + {"id": "11", "name": "To Do", "isAvailable": true}, + {"id": "21", "name": "In Progress", "isAvailable": true}, + {"id": "31", "name": "Done", "isAvailable": true} + ] +}` + +func newTransitionTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestTransitionIssue_Success(t *testing.T) { + var postedID string + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + assert.Equal(t, "/rest/api/3/issue/TEST-1/transitions", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + case http.MethodPost: + assert.Equal(t, "/rest/api/2/issue/TEST-1/transitions", r.URL.Path) + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + tr, _ := body["transition"].(map[string]any) + postedID, _ = tr["id"].(string) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + }) + defer cleanup() + + out, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "In Progress", + }) + require.NoError(t, err) + assert.Equal(t, "21", postedID) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "In Progress", out.ToStatus) + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestTransitionIssue_CaseInsensitiveMatch(t *testing.T) { + var postedID string + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + return + } + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + tr, _ := body["transition"].(map[string]any) + postedID, _ = tr["id"].(string) + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "in progress", + }) + require.NoError(t, err) + assert.Equal(t, "21", postedID) +} + +func TestTransitionIssue_UnknownTransition(t *testing.T) { + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "POST should not happen for unknown transition") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "Doing", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown transition") + assert.Contains(t, err.Error(), "To Do") + assert.Contains(t, err.Error(), "In Progress") + assert.Contains(t, err.Error(), "Done") +} + +func TestTransitionIssue_RequiresKeyAndTransition(t *testing.T) { + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{Transition: "Done"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") + + _, err = TransitionIssue(context.Background(), deps, TransitionIssueInput{Key: "TEST-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "transition is required") +} From 296a0c358354a2a5a77184aa075ce0be96534a61 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:18:41 -0700 Subject: [PATCH 16/27] docs(mcp): fix get_issue comment Author to use DisplayName (v3 API returns displayName, not name) Made-with: Cursor --- docs/superpowers/plans/2026-04-17-jira-mcp-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index 347a4f83..f78f3c31 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -848,7 +848,7 @@ func GetIssue(_ context.Context, d *Deps, in GetIssueInput) (GetIssueOutput, err c := comments[i] out.Comments = append(out.Comments, CommentBrief{ ID: c.ID, - Author: c.Author.Name, + Author: c.Author.DisplayName, Body: bodyToMarkdown(c.Body), Created: c.Created, }) From a1495e3bcc14014ca61dfef23eb697ad323fba68 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:24:30 -0700 Subject: [PATCH 17/27] docs(mcp): drop create_issue.Parent and transition_issue.Assignee for v1 (unreliable on Cloud/classic) Made-with: Cursor --- .../plans/2026-04-17-jira-mcp-server.md | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index f78f3c31..e422fb6a 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -1025,6 +1025,11 @@ import ( ) // CreateIssueInput is the input schema for the create_issue tool. +// +// v1 intentionally omits parent/epic linking: the underlying pkg/jira.CreateRequest +// routes that through project-type-aware fields (EpicField, SubtaskField) that the +// MCP layer doesn't currently resolve, so exposing it here would silently drop the +// link for classic projects. Add back when pkg/jira grows a first-class linker. type CreateIssueInput struct { Summary string `json:"summary" jsonschema:"issue summary (required)"` Type string `json:"type" jsonschema:"issue type, e.g. \"Task\", \"Bug\", \"Story\" (required)"` @@ -1034,7 +1039,6 @@ type CreateIssueInput struct { Labels []string `json:"labels,omitempty"` Components []string `json:"components,omitempty"` Assignee string `json:"assignee,omitempty" jsonschema:"assignee account id (Cloud) or username (Local)"` - Parent string `json:"parent,omitempty" jsonschema:"parent issue key (use this for epic link or sub-task parent)"` } // CreateIssueOutput is the structured result of the create_issue tool. @@ -1058,15 +1062,14 @@ func CreateIssue(_ context.Context, d *Deps, in CreateIssueInput) (CreateIssueOu } req := &jira.CreateRequest{ - Project: project, - IssueType: in.Type, - Summary: in.Summary, - Body: in.Description, - Priority: in.Priority, - Labels: in.Labels, - Components: in.Components, - Assignee: in.Assignee, - ParentIssueKey: in.Parent, + Project: project, + IssueType: in.Type, + Summary: in.Summary, + Body: in.Description, + Priority: in.Priority, + Labels: in.Labels, + Components: in.Components, + Assignee: in.Assignee, } req.ForInstallationType(d.Installation) @@ -1429,12 +1432,16 @@ import ( ) // TransitionIssueInput is the input schema for the transition_issue tool. +// +// v1 omits Assignee-during-transition because pkg/jira.TransitionRequestFields.Assignee +// only accepts a `{name: ...}` body, which Jira Cloud ignores for account-id users. Users +// who need to reassign on Cloud should do it in a separate step. Revisit when pkg/jira +// grows accountId support on the transition endpoint. type TransitionIssueInput struct { Key string `json:"key" jsonschema:"issue key (required)"` Transition string `json:"transition" jsonschema:"target transition name, e.g. \"In Progress\" (required, case-insensitive)"` Comment string `json:"comment,omitempty" jsonschema:"optional comment to add as part of the transition (workflow must allow it)"` Resolution string `json:"resolution,omitempty" jsonschema:"optional resolution name to set, e.g. \"Fixed\""` - Assignee string `json:"assignee,omitempty" jsonschema:"optional new assignee (account id on Cloud, username on Local)"` } // TransitionIssueOutput is the structured result of the transition_issue tool. @@ -1492,17 +1499,11 @@ func TransitionIssue(_ context.Context, d *Deps, in TransitionIssueInput) (Trans }{Body: in.Comment}, }) } - if in.Resolution != "" || in.Assignee != "" { - req.Fields = &jira.TransitionRequestFields{} - if in.Resolution != "" { - req.Fields.Resolution = &struct { + if in.Resolution != "" { + req.Fields = &jira.TransitionRequestFields{ + Resolution: &struct { Name string `json:"name"` - }{Name: in.Resolution} - } - if in.Assignee != "" { - req.Fields.Assignee = &struct { - Name string `json:"name"` - }{Name: in.Assignee} + }{Name: in.Resolution}, } } From e499e2ad7256fd38c789be56b5eab6cca54f198f Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:26:00 -0700 Subject: [PATCH 18/27] feat(mcp): narrow create_issue and transition_issue schemas for v1 Drop create_issue.Parent (epic/sub-task linking needs project-type resolution we don't do yet) and transition_issue.Assignee (pkg/jira only supports v2-style {name} bodies, which Cloud ignores for account-id users). Made-with: Cursor --- internal/mcp/tools/create_issue.go | 23 +++++++++++++---------- internal/mcp/tools/transition_issue.go | 20 +++++++++----------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/internal/mcp/tools/create_issue.go b/internal/mcp/tools/create_issue.go index 1963a8a5..f813211c 100644 --- a/internal/mcp/tools/create_issue.go +++ b/internal/mcp/tools/create_issue.go @@ -9,6 +9,11 @@ import ( ) // CreateIssueInput is the input schema for the create_issue tool. +// +// v1 intentionally omits parent/epic linking: the underlying pkg/jira.CreateRequest +// routes that through project-type-aware fields (EpicField, SubtaskField) that the +// MCP layer doesn't currently resolve, so exposing it here would silently drop the +// link for classic projects. Add back when pkg/jira grows a first-class linker. type CreateIssueInput struct { Summary string `json:"summary" jsonschema:"issue summary (required)"` Type string `json:"type" jsonschema:"issue type, e.g. \"Task\", \"Bug\", \"Story\" (required)"` @@ -18,7 +23,6 @@ type CreateIssueInput struct { Labels []string `json:"labels,omitempty"` Components []string `json:"components,omitempty"` Assignee string `json:"assignee,omitempty" jsonschema:"assignee account id (Cloud) or username (Local)"` - Parent string `json:"parent,omitempty" jsonschema:"parent issue key (use this for epic link or sub-task parent)"` } // CreateIssueOutput is the structured result of the create_issue tool. @@ -42,15 +46,14 @@ func CreateIssue(_ context.Context, d *Deps, in CreateIssueInput) (CreateIssueOu } req := &jira.CreateRequest{ - Project: project, - IssueType: in.Type, - Summary: in.Summary, - Body: in.Description, - Priority: in.Priority, - Labels: in.Labels, - Components: in.Components, - Assignee: in.Assignee, - ParentIssueKey: in.Parent, + Project: project, + IssueType: in.Type, + Summary: in.Summary, + Body: in.Description, + Priority: in.Priority, + Labels: in.Labels, + Components: in.Components, + Assignee: in.Assignee, } req.ForInstallationType(d.Installation) diff --git a/internal/mcp/tools/transition_issue.go b/internal/mcp/tools/transition_issue.go index ccc3ec30..52fe021b 100644 --- a/internal/mcp/tools/transition_issue.go +++ b/internal/mcp/tools/transition_issue.go @@ -11,12 +11,16 @@ import ( ) // TransitionIssueInput is the input schema for the transition_issue tool. +// +// v1 intentionally omits assignee: pkg/jira.TransitionRequestFields.Assignee only +// supports the v2-style {"name": "..."} shape, which Jira Cloud ignores for +// account-id-style users. Reassignment should go through a dedicated tool (or wait +// until pkg/jira grows accountId-aware transition field support). type TransitionIssueInput struct { Key string `json:"key" jsonschema:"issue key (required)"` Transition string `json:"transition" jsonschema:"target transition name, e.g. \"In Progress\" (required, case-insensitive)"` Comment string `json:"comment,omitempty" jsonschema:"optional comment to add as part of the transition (workflow must allow it)"` Resolution string `json:"resolution,omitempty" jsonschema:"optional resolution name to set, e.g. \"Fixed\""` - Assignee string `json:"assignee,omitempty" jsonschema:"optional new assignee (account id on Cloud, username on Local)"` } // TransitionIssueOutput is the structured result of the transition_issue tool. @@ -74,17 +78,11 @@ func TransitionIssue(_ context.Context, d *Deps, in TransitionIssueInput) (Trans }{Body: in.Comment}, }) } - if in.Resolution != "" || in.Assignee != "" { - req.Fields = &jira.TransitionRequestFields{} - if in.Resolution != "" { - req.Fields.Resolution = &struct { + if in.Resolution != "" { + req.Fields = &jira.TransitionRequestFields{ + Resolution: &struct { Name string `json:"name"` - }{Name: in.Resolution} - } - if in.Assignee != "" { - req.Fields.Assignee = &struct { - Name string `json:"name"` - }{Name: in.Assignee} + }{Name: in.Resolution}, } } From 19f03a643d38d233cb203e7377b0794b3b43ef01 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:29:06 -0700 Subject: [PATCH 19/27] feat(mcp): add Server constructor and in-memory round-trip test Made-with: Cursor --- go.mod | 8 +++- go.sum | 24 ++++++++-- internal/mcp/server.go | 94 +++++++++++++++++++++++++++++++++++++ internal/mcp/server_test.go | 80 +++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 internal/mcp/server.go create mode 100644 internal/mcp/server_test.go diff --git a/go.mod b/go.mod index f17c8b04..c7c31150 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/mitchellh/go-homedir v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/rivo/tview v0.0.0-20240406141410-79d4cc321256 github.com/russross/blackfriday/v2 v2.1.0 @@ -47,6 +48,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect @@ -64,18 +66,22 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.23.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ff292e4f..84474c11 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,12 @@ github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAY github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -109,6 +113,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -136,6 +142,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= @@ -165,6 +175,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= @@ -187,6 +199,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -200,8 +214,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -220,6 +234,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..e75bbe05 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "fmt" + "os" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" +) + +const ( + // ServerName is the implementation name advertised over MCP. + ServerName = "jira-cli" + // ServerVersion is the MCP server version advertised to clients. + // Bumped independently of the jira-cli release version when the MCP + // surface changes in a backward-incompatible way. + ServerVersion = "0.1.0" +) + +// NewServer constructs a configured *mcp.Server with all jira-cli tools +// registered. The caller is responsible for invoking server.Run with a +// transport. +func NewServer(d *tools.Deps) *mcpsdk.Server { + srv := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: ServerName, + Version: ServerVersion, + }, nil) + + registerTool(srv, "search_issues", + "Search Jira issues by JQL or simple filters. Defaults to the configured project.", + d, tools.SearchIssues) + + registerTool(srv, "get_issue", + "Get full details of a Jira issue including description and recent comments.", + d, tools.GetIssue) + + registerTool(srv, "create_issue", + "Create a new Jira issue in the given project.", + d, tools.CreateIssue) + + registerTool(srv, "add_comment", + "Add a comment to a Jira issue.", + d, tools.AddComment) + + registerTool(srv, "transition_issue", + "Transition a Jira issue to a new status by name (e.g. \"In Progress\", \"Done\").", + d, tools.TransitionIssue) + + return srv +} + +// registerTool adapts a tools.* handler (which takes Deps + Input and returns +// Output + error) onto the SDK's expected handler signature. It also recovers +// from panics in the handler body so a single bad call cannot kill the server +// mid-session, and converts both errors and panics into MCP tool errors that +// the LLM can read. +func registerTool[In, Out any]( + srv *mcpsdk.Server, + name, description string, + d *tools.Deps, + fn func(context.Context, *tools.Deps, In) (Out, error), +) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: name, Description: description}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in In) (result *mcpsdk.CallToolResult, out Out, err error) { + defer func() { + if r := recover(); r != nil { + var zero Out + fmt.Fprintf(os.Stderr, "mcp: panic in tool %q: %v\n", name, r) + result = &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{ + Text: fmt.Sprintf("internal error in tool %q: %v", name, r), + }}, + } + out = zero + err = nil + } + }() + + out, callErr := fn(ctx, d, in) + if callErr != nil { + var zero Out + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: callErr.Error()}}, + }, zero, nil + } + return nil, out, nil + }, + ) +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 00000000..d4d47666 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,80 @@ +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func TestServer_ListsAllTools(t *testing.T) { + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + defer viper.Set("installation", prevInstall) + + jiraServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"issues": []}`)) + })) + defer jiraServer.Close() + + deps := &tools.Deps{ + Client: jira.NewClient(jira.Config{Server: jiraServer.URL}, jira.WithTimeout(3*time.Second)), + Server: jiraServer.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + + srv := NewServer(deps) + require.NotNil(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + serverT, clientT := mcp.NewInMemoryTransports() + + serverDone := make(chan error, 1) + go func() { serverDone <- srv.Run(ctx, serverT) }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer session.Close() + + listed, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + require.NoError(t, err) + + names := make(map[string]bool) + for _, tool := range listed.Tools { + names[tool.Name] = true + } + for _, expected := range []string{ + "search_issues", "get_issue", "create_issue", "add_comment", "transition_issue", + } { + assert.True(t, names[expected], "expected tool %q to be registered", expected) + } + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_issues", + Arguments: map[string]any{"jql": "project = TEST"}, + }) + require.NoError(t, err) + assert.False(t, res.IsError, "search_issues should succeed against the fake server") + + // Validation errors must come back as IsError tool results, not transport errors. + res, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_issue", + Arguments: map[string]any{}, // missing required "key" + }) + require.NoError(t, err) + assert.True(t, res.IsError, "missing required key should produce a tool error result") +} From a665efc46667d17b5b5cf61a75176b4836252f72 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:31:22 -0700 Subject: [PATCH 20/27] feat(mcp): add 'jira mcp serve' cobra command (stdio transport) Made-with: Cursor --- internal/cmd/mcp/mcp.go | 24 ++++++++++ internal/cmd/mcp/serve/serve.go | 79 +++++++++++++++++++++++++++++++++ internal/cmd/root/root.go | 2 + 3 files changed, 105 insertions(+) create mode 100644 internal/cmd/mcp/mcp.go create mode 100644 internal/cmd/mcp/serve/serve.go diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go new file mode 100644 index 00000000..289de7ea --- /dev/null +++ b/internal/cmd/mcp/mcp.go @@ -0,0 +1,24 @@ +package mcp + +import ( + "github.com/spf13/cobra" + + "github.com/ankitpokhrel/jira-cli/internal/cmd/mcp/serve" +) + +const helpText = `Run jira-cli as a Model Context Protocol (MCP) server, exposing +Jira operations to MCP-aware hosts (e.g. Cursor, Claude Desktop).` + +// NewCmdMCP is the parent command for MCP-related subcommands. +func NewCmdMCP() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run jira-cli as an MCP server", + Long: helpText, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(serve.NewCmdServe()) + return cmd +} diff --git a/internal/cmd/mcp/serve/serve.go b/internal/cmd/mcp/serve/serve.go new file mode 100644 index 00000000..e58d5d34 --- /dev/null +++ b/internal/cmd/mcp/serve/serve.go @@ -0,0 +1,79 @@ +package serve + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + jiramcp "github.com/ankitpokhrel/jira-cli/internal/mcp" + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" +) + +const helpText = `Start an MCP server over stdio. + +Configure your MCP host (Cursor, Claude Desktop, etc.) like this: + + { + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } + } + +The server inherits the same configuration as every other jira-cli command: +JIRA_CONFIG_FILE, ~/.config/.jira/.config.yml, .netrc, and keychain all work +unchanged. The server reads from stdin and writes JSON-RPC frames to stdout; +all logs go to stderr.` + +// NewCmdServe is the `jira mcp serve` command. +func NewCmdServe() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Start an MCP server over stdio", + Long: helpText, + RunE: run, + } +} + +func run(cmd *cobra.Command, _ []string) error { + server := viper.GetString("server") + if server == "" { + return fmt.Errorf("no Jira server configured. Run 'jira init' to set up the tool") + } + + // Honor browse_server override the same way internal/cmdutil.GenerateServerBrowseURL does, + // so MCP-emitted issue URLs match what the rest of the CLI produces for users whose web + // client and API endpoints differ. + browseServer := server + if v := viper.GetString("browse_server"); v != "" { + browseServer = v + } + + debug := viper.GetBool("debug") + deps := &tools.Deps{ + Client: api.DefaultClient(debug), + Server: browseServer, + DefaultProject: viper.GetString("project.key"), + Installation: viper.GetString("installation"), + } + + srv := jiramcp.NewServer(deps) + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + fmt.Fprintln(os.Stderr, "jira-cli MCP server: listening on stdio") + if err := srv.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + return fmt.Errorf("mcp server: %w", err) + } + return nil +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 7bd95e1b..7f008951 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -14,6 +14,7 @@ import ( initCmd "github.com/ankitpokhrel/jira-cli/internal/cmd/init" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue" "github.com/ankitpokhrel/jira-cli/internal/cmd/man" + "github.com/ankitpokhrel/jira-cli/internal/cmd/mcp" "github.com/ankitpokhrel/jira-cli/internal/cmd/me" "github.com/ankitpokhrel/jira-cli/internal/cmd/open" "github.com/ankitpokhrel/jira-cli/internal/cmd/project" @@ -140,6 +141,7 @@ func addChildCommands(cmd *cobra.Command) { version.NewCmdVersion(), release.NewCmdRelease(), man.NewCmdMan(), + mcp.NewCmdMCP(), ) } From 262927bcbe7a208d91141925e7dd4f083a309478 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:31:38 -0700 Subject: [PATCH 21/27] docs: document the jira-cli MCP server Made-with: Cursor --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 28a16856..b0db0a7d 100644 --- a/README.md +++ b/README.md @@ -720,6 +720,36 @@ jira board list ``` +## MCP server + +`jira-cli` ships an embedded [Model Context Protocol](https://modelcontextprotocol.io) server so MCP-aware hosts (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the same config, auth, and Jira API client as the rest of the CLI. + +Start it from your MCP host configuration: + +```json +{ + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } +} +``` + +The server speaks stdio and exposes the following tools: + +| Tool | Purpose | +| --- | --- | +| `search_issues` | Search by raw JQL or simple `status`/`assignee` filters. | +| `get_issue` | Full issue details including description and recent comments. | +| `create_issue` | Create a new issue in a project. | +| `add_comment` | Add a comment to an issue. | +| `transition_issue` | Move an issue to a new status by name. | + +Every tool that returns an issue also returns its browser URL so the LLM can cite or link to it directly. + ## Scripts Often times, you may want to use the output of the command to do something cool. However, the default interactive UI might not allow you to do that. The tool comes with the `--plain` flag that displays results in a simple layout that can then be manipulated from the shell script. From bde6cf50c4165f8db7c19c9e89cb1dc9b4a91569 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:33:56 -0700 Subject: [PATCH 22/27] docs(mcp): drop unused context import from serve.go code block Made-with: Cursor --- docs/superpowers/plans/2026-04-17-jira-mcp-server.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md index e422fb6a..d47eefca 100644 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md @@ -1826,7 +1826,6 @@ Create `internal/cmd/mcp/serve/serve.go`: package serve import ( - "context" "fmt" "os" "os/signal" From eec04f5016a65922a0a431ceedc6dba67fbb23eb Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 12:41:06 -0700 Subject: [PATCH 23/27] fix(mcp): prevent --debug from corrupting stdio and cover panic recovery pkg/jira's debug dump writes to stdout, which would corrupt the JSON-RPC framing used by the stdio MCP transport. Force-disable debug in the MCP path with a stderr notice so users know their config flag is being ignored for this session. Also add a test that panic inside a tool handler surfaces as a tool error (IsError=true) without killing the transport. Made-with: Cursor --- internal/cmd/mcp/serve/serve.go | 10 +++++++-- internal/mcp/server_test.go | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/cmd/mcp/serve/serve.go b/internal/cmd/mcp/serve/serve.go index e58d5d34..f900f693 100644 --- a/internal/cmd/mcp/serve/serve.go +++ b/internal/cmd/mcp/serve/serve.go @@ -58,9 +58,15 @@ func run(cmd *cobra.Command, _ []string) error { browseServer = v } - debug := viper.GetBool("debug") + // Stdout is reserved for JSON-RPC frames; pkg/jira's debug dump and + // root's "Using config file: ..." both write to stdout when debug is + // enabled, which would corrupt the MCP transport. Force-disable here + // and surface a stderr notice if the user had it on in config. + if viper.GetBool("debug") { + fmt.Fprintln(os.Stderr, "jira-cli MCP server: ignoring debug=true (would corrupt stdio transport)") + } deps := &tools.Deps{ - Client: api.DefaultClient(debug), + Client: api.DefaultClient(false), Server: browseServer, DefaultProject: viper.GetString("project.key"), Installation: viper.GetString("installation"), diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index d4d47666..b9fbcded 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -78,3 +78,40 @@ func TestServer_ListsAllTools(t *testing.T) { require.NoError(t, err) assert.True(t, res.IsError, "missing required key should produce a tool error result") } + +func TestRegisterTool_RecoversFromPanic(t *testing.T) { + srv := mcp.NewServer(&mcp.Implementation{Name: "panic-test", Version: "v0"}, nil) + + type panicIn struct{} + type panicOut struct{} + + registerTool(srv, "panic_tool", "always panics", + &tools.Deps{}, + func(context.Context, *tools.Deps, panicIn) (panicOut, error) { + panic("boom") + }, + ) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + serverT, clientT := mcp.NewInMemoryTransports() + go func() { _ = srv.Run(ctx, serverT) }() + + client := mcp.NewClient(&mcp.Implementation{Name: "panic-client", Version: "v0"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "panic_tool", + Arguments: map[string]any{}, + }) + require.NoError(t, err, "transport should survive a panicking handler") + assert.True(t, res.IsError, "panic should surface as a tool error, not a transport error") + require.NotEmpty(t, res.Content) + if tc, ok := res.Content[0].(*mcp.TextContent); ok { + assert.Contains(t, tc.Text, "panic_tool") + assert.Contains(t, tc.Text, "boom") + } +} From 9ddac87efc9f5b084655f8360cf7a89264303249 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Fri, 17 Apr 2026 13:18:11 -0700 Subject: [PATCH 24/27] test(mcp): add v2/Local installation path coverage Every existing tool test pins viper.Set("installation", Cloud) and only exercises the v3 branches of api.ProxySearch / ProxyGetIssue / ProxyCreate. Add three tests that route through the v2 branches instead: - TestSearchIssues_Local hits /rest/api/2/search with startAt in the query. - TestGetIssue_Local serves a v2 body with a plain-string description and verifies bodyToMarkdown(string) works end-to-end. - TestCreateIssue_Local verifies CreateRequest.ForInstallationType serializes {"name": "alice"} rather than {"accountId": "alice"} for the assignee. Made-with: Cursor --- internal/mcp/tools/create_issue_test.go | 38 ++++++++++++++++++++++ internal/mcp/tools/get_issue_test.go | 40 ++++++++++++++++++++++++ internal/mcp/tools/search_issues_test.go | 28 +++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/internal/mcp/tools/create_issue_test.go b/internal/mcp/tools/create_issue_test.go index 0bfece17..1185892e 100644 --- a/internal/mcp/tools/create_issue_test.go +++ b/internal/mcp/tools/create_issue_test.go @@ -110,3 +110,41 @@ func TestCreateIssue_OverridesProject(t *testing.T) { require.NoError(t, err) assert.Equal(t, "OTHER", capturedProject) } + +func TestCreateIssue_Local(t *testing.T) { + var ( + capturedPath string + capturedBody map[string]any + ) + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &capturedBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "TEST-42"}`)) + }) + defer cleanup() + + viper.Set("installation", jira.InstallationTypeLocal) + deps.Installation = jira.InstallationTypeLocal + + out, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "Local bug", + Type: "Bug", + Assignee: "alice", + }) + require.NoError(t, err) + + assert.Equal(t, "/rest/api/2/issue", capturedPath) + assert.Equal(t, "TEST-42", out.Key) + + fields, _ := capturedBody["fields"].(map[string]any) + require.NotNil(t, fields) + assignee, _ := fields["assignee"].(map[string]any) + require.NotNil(t, assignee) + assert.Equal(t, "alice", assignee["name"]) + _, hasAccountID := assignee["accountId"] + assert.False(t, hasAccountID, "v2 assignee should not use accountId") +} diff --git a/internal/mcp/tools/get_issue_test.go b/internal/mcp/tools/get_issue_test.go index 3f3f7b71..3badb2a5 100644 --- a/internal/mcp/tools/get_issue_test.go +++ b/internal/mcp/tools/get_issue_test.go @@ -131,3 +131,43 @@ func TestGetIssue_RespectsCommentLimit(t *testing.T) { } func boolPtr(b bool) *bool { return &b } + +const getIssueResponseV2 = `{ + "key": "TEST-1", + "fields": { + "summary": "Sample bug", + "status": {"name": "In Progress"}, + "issueType": {"name": "Bug"}, + "priority": {"name": "High"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "labels": [], + "components": [], + "fixVersions": [], + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "description": "Repro steps from wiki markup", + "comment": {"total": 0, "comments": []} + } +}` + +func TestGetIssue_Local(t *testing.T) { + var capturedPath string + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV2)) + }) + defer cleanup() + + viper.Set("installation", jira.InstallationTypeLocal) + deps.Installation = jira.InstallationTypeLocal + + out, err := GetIssue(context.Background(), deps, GetIssueInput{Key: "TEST-1"}) + require.NoError(t, err) + + assert.Equal(t, "/rest/api/2/issue/TEST-1", capturedPath) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "In Progress", out.Status) + assert.Equal(t, "Repro steps from wiki markup", out.Description) +} diff --git a/internal/mcp/tools/search_issues_test.go b/internal/mcp/tools/search_issues_test.go index 5c573c50..ae74a971 100644 --- a/internal/mcp/tools/search_issues_test.go +++ b/internal/mcp/tools/search_issues_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -142,3 +143,30 @@ func TestSearchIssues_DefaultLimit(t *testing.T) { require.NoError(t, err) assert.Equal(t, "50", capturedLimit) } + +func TestSearchIssues_Local(t *testing.T) { + var ( + capturedPath string + capturedQuery url.Values + ) + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(searchResponseBody)) + }) + defer cleanup() + + viper.Set("installation", jira.InstallationTypeLocal) + deps.Installation = jira.InstallationTypeLocal + + out, err := SearchIssues(context.Background(), deps, SearchIssuesInput{JQL: "summary ~ first"}) + require.NoError(t, err) + + assert.Equal(t, "/rest/api/2/search", capturedPath) + assert.Equal(t, "0", capturedQuery.Get("startAt")) + assert.Equal(t, "50", capturedQuery.Get("maxResults")) + assert.Equal(t, 1, out.Returned) + require.Len(t, out.Issues, 1) + assert.Equal(t, "TEST-1", out.Issues[0].Key) +} From 50c4285e6ad9d478655306a016bed982f9b26f23 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Mon, 20 Apr 2026 07:41:36 -0700 Subject: [PATCH 25/27] chore: remove workflow-internal design docs from tree The specs/ and plans/ files under docs/superpowers/ are Cursor-workflow artifacts (TDD plans, design iterations) useful during development but not appropriate for the upstream repository. History preserves them for reference; the delivered tree does not. Made-with: Cursor --- .../plans/2026-04-17-jira-mcp-server.md | 2082 ----------------- .../2026-04-17-jira-mcp-server-design.md | 165 -- 2 files changed, 2247 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-17-jira-mcp-server.md delete mode 100644 docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md diff --git a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md b/docs/superpowers/plans/2026-04-17-jira-mcp-server.md deleted file mode 100644 index d47eefca..00000000 --- a/docs/superpowers/plans/2026-04-17-jira-mcp-server.md +++ /dev/null @@ -1,2082 +0,0 @@ -# Jira CLI MCP Server Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Model Context Protocol (MCP) server to `jira-cli`, exposed as `jira mcp serve` over stdio, with five tools: `search_issues`, `get_issue`, `create_issue`, `add_comment`, `transition_issue`. - -**Architecture:** Thin MCP layer in a new `internal/mcp/` package. Tool handlers depend only on `pkg/jira` (via the existing `api` package's v2/v3 proxies) and `pkg/adf`. A new `internal/cmd/mcp/` package wires `jira mcp serve` into the existing Cobra tree, builds dependencies from viper, and runs the SDK's stdio transport. Tool handlers are unit-tested with `httptest.NewServer` (matching `pkg/jira/*_test.go` style); the full server is round-tripped once with `mcp.NewInMemoryTransports`. - -**Tech Stack:** Go 1.25, Cobra, Viper, `github.com/modelcontextprotocol/go-sdk` v1.5.0, `httptest`, `testify`. - -**Spec:** `docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md` - -**Conventions used in this plan:** -- All file paths are relative to the repo root. -- All `go test` commands run from the repo root. -- All commits use the project's existing message style (`feat:`, `test:`, `docs:`). -- The MCP code path **must never write to stdout** (it would corrupt JSON-RPC framing). All logs go to stderr; tool results are returned as values. - ---- - -## Task 0: Create empty package skeleton - -**Files:** -- Create: `internal/mcp/doc.go` -- Create: `internal/mcp/tools/doc.go` - -The MCP Go SDK is intentionally **not** added in this task. Adding it now would leave `go.mod` with a require line that no source justifies, which `go mod tidy` would revert. The SDK is added in Task 2, alongside the first source file that imports it. - -- [ ] **Step 1: Create empty package marker for `internal/mcp`** - -Create `internal/mcp/doc.go`: - -```go -// Package mcp implements a Model Context Protocol server that exposes -// a subset of jira-cli's capabilities to MCP-aware hosts (e.g. Cursor, -// Claude Desktop). Wiring lives in internal/cmd/mcp. -package mcp -``` - -- [ ] **Step 2: Create empty package marker for `internal/mcp/tools`** - -Create `internal/mcp/tools/doc.go`: - -```go -// Package tools holds the individual MCP tool handlers. Each handler is -// a small adapter from a typed input/output struct onto the existing -// pkg/jira client. Handlers must not depend on cobra, viper, survey, or -// tui; their dependencies are injected via the Deps struct. -package tools -``` - -- [ ] **Step 3: Verify the module still builds** - -```bash -go build ./... -``` - -Expected: exit 0, no output. - -- [ ] **Step 4: Commit** - -```bash -git add internal/mcp/doc.go internal/mcp/tools/doc.go -git commit -m "feat(mcp): add internal/mcp package skeleton" -``` - ---- - -## Task 1: Define the `tools.Deps` struct - -**Files:** -- Create: `internal/mcp/tools/deps.go` -- Create: `internal/mcp/tools/deps_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/deps_test.go`: - -```go -package tools - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDeps_IssueURL(t *testing.T) { - d := &Deps{Server: "https://example.atlassian.net"} - assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) -} - -func TestDeps_IssueURL_TrimsTrailingSlash(t *testing.T) { - d := &Deps{Server: "https://example.atlassian.net/"} - assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) -} - -func TestDeps_ResolveProject_UsesDefaultWhenEmpty(t *testing.T) { - d := &Deps{DefaultProject: "ABC"} - assert.Equal(t, "ABC", d.ResolveProject("")) -} - -func TestDeps_ResolveProject_PrefersExplicit(t *testing.T) { - d := &Deps{DefaultProject: "ABC"} - assert.Equal(t, "XYZ", d.ResolveProject("XYZ")) -} - -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestDeps -v -``` - -Expected: FAIL — `Deps` undefined. - -- [ ] **Step 3: Implement `Deps`** - -Create `internal/mcp/tools/deps.go`: - -```go -package tools - -import ( - "strings" - - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -// Deps bundles the runtime dependencies every MCP tool handler needs. -// It is constructed once in internal/cmd/mcp/serve and shared (read-only) -// across all tool invocations. -type Deps struct { - Client *jira.Client - Server string - DefaultProject string - Installation string -} - -// IssueURL returns the browser URL for a given issue key. -func (d *Deps) IssueURL(key string) string { - return strings.TrimRight(d.Server, "/") + "/browse/" + key -} - -// ResolveProject returns explicit if non-empty, otherwise the configured -// default project key. -func (d *Deps) ResolveProject(explicit string) string { - if explicit != "" { - return explicit - } - return d.DefaultProject -} - -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestDeps -v -``` - -Expected: PASS, all 5 subtests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/deps.go internal/mcp/tools/deps_test.go -git commit -m "feat(mcp): add tools.Deps with project/url/installation helpers" -``` - ---- - -## Task 2: Add the `search_issues` tool - -**Files:** -- Create: `internal/mcp/tools/search_issues.go` -- Create: `internal/mcp/tools/search_issues_test.go` - -The tool calls `api.ProxySearch`, which selects the v2 or v3 endpoint based on the configured installation type. The `api` package reads `viper.GetString("installation")` internally; tests set that via `viper.Set("installation", ...)`. - -The MCP Go SDK is **not** added in this task either — Task 2's source files don't import it (only `api`, `context`, `fmt`, `strings`). The SDK fetch lands in Task 8, the first task whose source actually imports `github.com/modelcontextprotocol/go-sdk/mcp`. - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/search_issues_test.go`: - -```go -package tools - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -const searchResponseBody = `{ - "issues": [ - { - "key": "TEST-1", - "fields": { - "summary": "First issue", - "status": {"name": "To Do"}, - "issueType": {"name": "Task"}, - "priority": {"name": "Medium"}, - "assignee": {"displayName": "Alice"}, - "reporter": {"displayName": "Bob"}, - "created": "2026-01-01T10:00:00.000+0000", - "updated": "2026-01-02T10:00:00.000+0000", - "labels": [] - } - } - ] -}` - -func newSearchTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { - t.Helper() - - server := httptest.NewServer(handler) - client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) - - prevInstall := viper.GetString("installation") - viper.Set("installation", jira.InstallationTypeCloud) - - deps := &Deps{ - Client: client, - Server: server.URL, - DefaultProject: "TEST", - Installation: jira.InstallationTypeCloud, - } - - cleanup := func() { - server.Close() - viper.Set("installation", prevInstall) - } - return deps, cleanup -} - -func TestSearchIssues_UsesProvidedJQL(t *testing.T) { - var capturedJQL string - deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/3/search/jql", r.URL.Path) - capturedJQL = r.URL.Query().Get("jql") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(searchResponseBody)) - }) - defer cleanup() - - out, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ - JQL: "summary ~ first", - }) - require.NoError(t, err) - - assert.Equal(t, "summary ~ first", capturedJQL) - require.Len(t, out.Issues, 1) - assert.Equal(t, "TEST-1", out.Issues[0].Key) - assert.Equal(t, "First issue", out.Issues[0].Summary) - assert.Equal(t, "To Do", out.Issues[0].Status) - assert.Equal(t, "Alice", out.Issues[0].Assignee) - assert.True(t, strings.HasSuffix(out.Issues[0].URL, "/browse/TEST-1")) -} - -func TestSearchIssues_ComposesJQLFromFilters(t *testing.T) { - var capturedJQL string - deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - capturedJQL = r.URL.Query().Get("jql") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"issues": []}`)) - }) - defer cleanup() - - _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ - Status: "In Progress", - Assignee: "alice", - }) - require.NoError(t, err) - - assert.Contains(t, capturedJQL, `project = "TEST"`) - assert.Contains(t, capturedJQL, `status = "In Progress"`) - assert.Contains(t, capturedJQL, `assignee = "alice"`) -} - -func TestSearchIssues_AssigneeMe(t *testing.T) { - var capturedJQL string - deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - capturedJQL = r.URL.Query().Get("jql") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"issues": []}`)) - }) - defer cleanup() - - _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Assignee: "me"}) - require.NoError(t, err) - assert.Contains(t, capturedJQL, `assignee = currentUser()`) -} - -func TestSearchIssues_LimitClampedTo100(t *testing.T) { - var capturedLimit string - deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - capturedLimit = r.URL.Query().Get("maxResults") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"issues": []}`)) - }) - defer cleanup() - - _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Limit: 500}) - require.NoError(t, err) - assert.Equal(t, "100", capturedLimit) -} - -func TestSearchIssues_DefaultLimit(t *testing.T) { - var capturedLimit string - deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - capturedLimit = r.URL.Query().Get("maxResults") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"issues": []}`)) - }) - defer cleanup() - - _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{}) - require.NoError(t, err) - assert.Equal(t, "50", capturedLimit) -} -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestSearchIssues -v -``` - -Expected: FAIL — `SearchIssues`, `SearchIssuesInput` undefined. - -- [ ] **Step 3: Implement the tool** - -Create `internal/mcp/tools/search_issues.go`: - -```go -package tools - -import ( - "context" - "fmt" - "strings" - - "github.com/ankitpokhrel/jira-cli/api" -) - -// SearchIssuesInput is the input schema for the search_issues tool. -type SearchIssuesInput struct { - JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. Passed through verbatim unless project is also set, in which case the JQL is wrapped as 'project = X AND (your JQL)'. If you set project alongside JQL, your JQL must not contain its own ORDER BY clause."` - Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project when JQL is omitted; when JQL is provided, only set this if you want the JQL scoped to a specific project)"` - Status string `json:"status,omitempty" jsonschema:"filter by status name, e.g. \"To Do\""` - Assignee string `json:"assignee,omitempty" jsonschema:"filter by assignee. Use \"me\" for the configured user, \"none\" for unassigned, or a username/account id."` - Limit int `json:"limit,omitempty" jsonschema:"maximum number of issues to return (default 50, clamped to 100)"` -} - -// SearchIssuesOutput is the structured result of the search_issues tool. -type SearchIssuesOutput struct { - // Returned is the number of issues in this response page. The Jira v3 - // /search/jql endpoint does not return a total match count; callers that - // need to know whether more results exist should rerun with a larger Limit. - Returned int `json:"returned"` - Issues []IssueBrief `json:"issues"` -} - -// IssueBrief is a lean projection of jira.Issue used for list-style outputs. -type IssueBrief struct { - Key string `json:"key"` - Summary string `json:"summary"` - Status string `json:"status"` - Type string `json:"type"` - Priority string `json:"priority"` - Assignee string `json:"assignee"` - Reporter string `json:"reporter"` - Created string `json:"created"` - Updated string `json:"updated"` - URL string `json:"url"` -} - -// SearchIssues runs the search_issues tool. -func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssuesOutput, error) { - limit := in.Limit - if limit <= 0 { - limit = 50 - } - if limit > 100 { - limit = 100 - } - - jql := strings.TrimSpace(in.JQL) - - if jql == "" { - // No JQL → compose one from the simple filters, scoped to the resolved - // (default-or-explicit) project so plain "list my open issues" calls - // stay inside the configured project. - jql = composeJQL(d.ResolveProject(in.Project), in.Status, in.Assignee) - } else if in.Project != "" { - // JQL is a power-user escape hatch: pass it through unmodified by - // default. Wrap with a project clause only when the caller explicitly - // opted in by setting Project on the input. Per the input schema, the - // caller is responsible for not including ORDER BY in their JQL when - // they opt into wrapping (Jira disallows ORDER BY inside parentheses). - jql = fmt.Sprintf(`project = %q AND (%s)`, in.Project, jql) - } - - res, err := api.ProxySearch(d.Client, jql, 0, uint(limit)) - if err != nil { - return SearchIssuesOutput{}, err - } - - out := SearchIssuesOutput{Issues: make([]IssueBrief, 0, len(res.Issues))} - for _, iss := range res.Issues { - out.Issues = append(out.Issues, IssueBrief{ - Key: iss.Key, - Summary: iss.Fields.Summary, - Status: iss.Fields.Status.Name, - Type: iss.Fields.IssueType.Name, - Priority: iss.Fields.Priority.Name, - Assignee: iss.Fields.Assignee.Name, - Reporter: iss.Fields.Reporter.Name, - Created: iss.Fields.Created, - Updated: iss.Fields.Updated, - URL: d.IssueURL(iss.Key), - }) - } - out.Returned = len(out.Issues) - return out, nil -} - -func composeJQL(project, status, assignee string) string { - var clauses []string - if project != "" { - clauses = append(clauses, fmt.Sprintf(`project = %q`, project)) - } - if status != "" { - clauses = append(clauses, fmt.Sprintf(`status = %q`, status)) - } - switch strings.ToLower(assignee) { - case "": - // no clause - case "me": - clauses = append(clauses, "assignee = currentUser()") - case "none", "x": - clauses = append(clauses, "assignee is EMPTY") - default: - clauses = append(clauses, fmt.Sprintf(`assignee = %q`, assignee)) - } - if len(clauses) == 0 { - return "" - } - return strings.Join(clauses, " AND ") + " ORDER BY created DESC" -} -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestSearchIssues -v -``` - -Expected: PASS, all 5 subtests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/search_issues.go internal/mcp/tools/search_issues_test.go -git commit -m "feat(mcp): add search_issues tool" -``` - ---- - -## Task 3: Add the `bodyToMarkdown` helper for ADF/string conversion - -The `Description` and comment `Body` fields in `pkg/jira` are typed as `interface{}`: `*adf.ADF` after a v3 fetch (post-`ifaceToADF`), `string` after a v2 fetch. The MCP tools need a single helper to render either to markdown. - -**Files:** -- Create: `internal/mcp/tools/markdown.go` -- Create: `internal/mcp/tools/markdown_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/markdown_test.go`: - -```go -package tools - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/ankitpokhrel/jira-cli/pkg/adf" -) - -func TestBodyToMarkdown_String(t *testing.T) { - assert.Equal(t, "hello", bodyToMarkdown("hello")) -} - -func TestBodyToMarkdown_Nil(t *testing.T) { - assert.Equal(t, "", bodyToMarkdown(nil)) -} - -func TestBodyToMarkdown_ADF(t *testing.T) { - doc := &adf.ADF{ - Version: 1, - DocType: "doc", - Content: []*adf.Node{ - { - NodeType: "paragraph", - Content: []*adf.Node{ - {NodeType: "text", NodeValue: adf.NodeValue{Text: "Hello world"}}, - }, - }, - }, - } - got := bodyToMarkdown(doc) - assert.Contains(t, got, "Hello world") -} - -func TestBodyToMarkdown_UnknownType(t *testing.T) { - assert.Equal(t, "", bodyToMarkdown(123)) -} -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestBodyToMarkdown -v -``` - -Expected: FAIL — `bodyToMarkdown` undefined. - -- [ ] **Step 3: Implement the helper** - -Create `internal/mcp/tools/markdown.go`: - -```go -package tools - -import ( - "github.com/ankitpokhrel/jira-cli/pkg/adf" -) - -// bodyToMarkdown renders a Jira description-or-comment body field to markdown. -// The body is interface{} because v3 returns *adf.ADF and v2 returns string. -func bodyToMarkdown(body any) string { - switch v := body.(type) { - case nil: - return "" - case string: - return v - case *adf.ADF: - if v == nil { - return "" - } - return adf.NewTranslator(v, adf.NewMarkdownTranslator()).Translate() - default: - return "" - } -} -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestBodyToMarkdown -v -``` - -Expected: PASS, all 4 subtests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/markdown.go internal/mcp/tools/markdown_test.go -git commit -m "feat(mcp): add bodyToMarkdown helper for ADF/string description bodies" -``` - ---- - -## Task 4: Add the `get_issue` tool - -**Files:** -- Create: `internal/mcp/tools/get_issue.go` -- Create: `internal/mcp/tools/get_issue_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/get_issue_test.go`: - -```go -package tools - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -const getIssueResponseV3 = `{ - "key": "TEST-1", - "fields": { - "summary": "Sample bug", - "status": {"name": "In Progress"}, - "issueType": {"name": "Bug"}, - "priority": {"name": "High"}, - "assignee": {"displayName": "Alice"}, - "reporter": {"displayName": "Bob"}, - "labels": ["backend", "urgent"], - "components": [{"name": "API"}], - "fixVersions": [{"name": "v2.0"}], - "created": "2026-01-01T10:00:00.000+0000", - "updated": "2026-01-02T10:00:00.000+0000", - "description": { - "version": 1, - "type": "doc", - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "Repro steps"}]} - ] - }, - "comment": { - "total": 1, - "comments": [ - { - "id": "100", - "author": {"displayName": "Carol", "emailAddress": "carol@example.com", "active": true}, - "body": { - "version": 1, - "type": "doc", - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "Looking into it"}]} - ] - }, - "created": "2026-01-03T10:00:00.000+0000" - } - ] - } - } -}` - -func newIssueTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { - t.Helper() - server := httptest.NewServer(handler) - client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) - - prevInstall := viper.GetString("installation") - viper.Set("installation", jira.InstallationTypeCloud) - - deps := &Deps{ - Client: client, - Server: server.URL, - DefaultProject: "TEST", - Installation: jira.InstallationTypeCloud, - } - return deps, func() { - server.Close() - viper.Set("installation", prevInstall) - } -} - -func TestGetIssue_Cloud(t *testing.T) { - deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(getIssueResponseV3)) - }) - defer cleanup() - - out, err := GetIssue(context.Background(), deps, GetIssueInput{Key: "TEST-1"}) - require.NoError(t, err) - - assert.Equal(t, "TEST-1", out.Key) - assert.Equal(t, "Sample bug", out.Summary) - assert.Equal(t, "In Progress", out.Status) - assert.Equal(t, "Bug", out.Type) - assert.Equal(t, "High", out.Priority) - assert.Equal(t, "Alice", out.Assignee) - assert.Equal(t, "Bob", out.Reporter) - assert.Equal(t, []string{"backend", "urgent"}, out.Labels) - assert.Equal(t, []string{"API"}, out.Components) - assert.Equal(t, []string{"v2.0"}, out.FixVersions) - assert.Contains(t, out.Description, "Repro steps") - require.Len(t, out.Comments, 1) - assert.Equal(t, "Carol", out.Comments[0].Author) - assert.Contains(t, out.Comments[0].Body, "Looking into it") - assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) -} - -func TestGetIssue_RequiresKey(t *testing.T) { - deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("server should not be called when key is missing") - w.WriteHeader(500) - }) - defer cleanup() - - _, err := GetIssue(context.Background(), deps, GetIssueInput{Key: ""}) - require.Error(t, err) - assert.Contains(t, err.Error(), "key is required") -} - -func TestGetIssue_RespectsCommentLimit(t *testing.T) { - deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(getIssueResponseV3)) - }) - defer cleanup() - - out, err := GetIssue(context.Background(), deps, GetIssueInput{ - Key: "TEST-1", - IncludeComments: boolPtr(false), - }) - require.NoError(t, err) - assert.Empty(t, out.Comments) -} - -func boolPtr(b bool) *bool { return &b } -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestGetIssue -v -``` - -Expected: FAIL — `GetIssue`, `GetIssueInput` undefined. - -- [ ] **Step 3: Implement the tool** - -Create `internal/mcp/tools/get_issue.go`: - -```go -package tools - -import ( - "context" - "errors" - - "github.com/ankitpokhrel/jira-cli/api" - issuefilter "github.com/ankitpokhrel/jira-cli/pkg/jira/filter/issue" -) - - - -// GetIssueInput is the input schema for the get_issue tool. -type GetIssueInput struct { - Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` - IncludeComments *bool `json:"include_comments,omitempty" jsonschema:"include recent comments in the response (default true)"` - CommentLimit int `json:"comment_limit,omitempty" jsonschema:"maximum number of recent comments to include (default 10)"` -} - -// GetIssueOutput is the structured result of the get_issue tool. -type GetIssueOutput struct { - Key string `json:"key"` - Summary string `json:"summary"` - Status string `json:"status"` - Type string `json:"type"` - Priority string `json:"priority"` - Assignee string `json:"assignee"` - Reporter string `json:"reporter"` - Labels []string `json:"labels"` - Components []string `json:"components"` - FixVersions []string `json:"fix_versions"` - Parent string `json:"parent,omitempty"` - Created string `json:"created"` - Updated string `json:"updated"` - Description string `json:"description"` - Comments []CommentBrief `json:"comments,omitempty"` - URL string `json:"url"` -} - -// CommentBrief is a lean projection of an issue comment. -type CommentBrief struct { - ID string `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - Created string `json:"created"` -} - -// GetIssue runs the get_issue tool. -func GetIssue(_ context.Context, d *Deps, in GetIssueInput) (GetIssueOutput, error) { - if in.Key == "" { - return GetIssueOutput{}, errors.New("key is required") - } - - includeComments := true - if in.IncludeComments != nil { - includeComments = *in.IncludeComments - } - commentLimit := in.CommentLimit - if commentLimit <= 0 { - commentLimit = 10 - } - - iss, err := api.ProxyGetIssue(d.Client, in.Key, issuefilter.NewNumCommentsFilter(uint(commentLimit))) - if err != nil { - return GetIssueOutput{}, err - } - - out := GetIssueOutput{ - Key: iss.Key, - Summary: iss.Fields.Summary, - Status: iss.Fields.Status.Name, - Type: iss.Fields.IssueType.Name, - Priority: iss.Fields.Priority.Name, - Assignee: iss.Fields.Assignee.Name, - Reporter: iss.Fields.Reporter.Name, - Labels: iss.Fields.Labels, - Created: iss.Fields.Created, - Updated: iss.Fields.Updated, - Description: bodyToMarkdown(iss.Fields.Description), - URL: d.IssueURL(iss.Key), - } - if iss.Fields.Parent != nil { - out.Parent = iss.Fields.Parent.Key - } - for _, c := range iss.Fields.Components { - out.Components = append(out.Components, c.Name) - } - for _, v := range iss.Fields.FixVersions { - out.FixVersions = append(out.FixVersions, v.Name) - } - - if includeComments && iss.Fields.Comment.Total > 0 { - comments := iss.Fields.Comment.Comments - // Take the last commentLimit comments (newest), preserving original chronological order. - start := 0 - if len(comments) > commentLimit { - start = len(comments) - commentLimit - } - for i := start; i < len(comments); i++ { - c := comments[i] - out.Comments = append(out.Comments, CommentBrief{ - ID: c.ID, - Author: c.Author.DisplayName, - Body: bodyToMarkdown(c.Body), - Created: c.Created, - }) - } - } - - return out, nil -} -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestGetIssue -v -``` - -Expected: PASS, all 3 subtests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/get_issue.go internal/mcp/tools/get_issue_test.go -git commit -m "feat(mcp): add get_issue tool" -``` - ---- - -## Task 5: Add the `create_issue` tool - -**Files:** -- Create: `internal/mcp/tools/create_issue.go` -- Create: `internal/mcp/tools/create_issue_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/create_issue_test.go`: - -```go -package tools - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -func newCreateTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { - t.Helper() - server := httptest.NewServer(handler) - client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) - - prevInstall := viper.GetString("installation") - viper.Set("installation", jira.InstallationTypeCloud) - - deps := &Deps{ - Client: client, - Server: server.URL, - DefaultProject: "TEST", - Installation: jira.InstallationTypeCloud, - } - return deps, func() { - server.Close() - viper.Set("installation", prevInstall) - } -} - -func TestCreateIssue_Success(t *testing.T) { - var capturedBody map[string]any - deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/3/issue", r.URL.Path) - assert.Equal(t, http.MethodPost, r.Method) - raw, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(raw, &capturedBody) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id": "10001", "key": "TEST-42"}`)) - }) - defer cleanup() - - out, err := CreateIssue(context.Background(), deps, CreateIssueInput{ - Summary: "New thing", - Type: "Task", - }) - require.NoError(t, err) - assert.Equal(t, "TEST-42", out.Key) - assert.Equal(t, deps.IssueURL("TEST-42"), out.URL) - - fields, _ := capturedBody["fields"].(map[string]any) - require.NotNil(t, fields) - project, _ := fields["project"].(map[string]any) - assert.Equal(t, "TEST", project["key"]) - assert.Equal(t, "New thing", fields["summary"]) -} - -func TestCreateIssue_RequiresSummary(t *testing.T) { - deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("server should not be called") - w.WriteHeader(500) - }) - defer cleanup() - - _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Type: "Task"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "summary is required") -} - -func TestCreateIssue_RequiresType(t *testing.T) { - deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("server should not be called") - w.WriteHeader(500) - }) - defer cleanup() - - _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Summary: "x"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "type is required") -} - -func TestCreateIssue_OverridesProject(t *testing.T) { - var capturedProject string - deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - var body map[string]any - _ = json.Unmarshal(raw, &body) - fields, _ := body["fields"].(map[string]any) - project, _ := fields["project"].(map[string]any) - capturedProject, _ = project["key"].(string) - - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id": "10001", "key": "OTHER-1"}`)) - }) - defer cleanup() - - _, err := CreateIssue(context.Background(), deps, CreateIssueInput{ - Summary: "x", Type: "Task", Project: "OTHER", - }) - require.NoError(t, err) - assert.Equal(t, "OTHER", capturedProject) -} -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestCreateIssue -v -``` - -Expected: FAIL — `CreateIssue`, `CreateIssueInput` undefined. - -- [ ] **Step 3: Implement the tool** - -Create `internal/mcp/tools/create_issue.go`: - -```go -package tools - -import ( - "context" - "errors" - - "github.com/ankitpokhrel/jira-cli/api" - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -// CreateIssueInput is the input schema for the create_issue tool. -// -// v1 intentionally omits parent/epic linking: the underlying pkg/jira.CreateRequest -// routes that through project-type-aware fields (EpicField, SubtaskField) that the -// MCP layer doesn't currently resolve, so exposing it here would silently drop the -// link for classic projects. Add back when pkg/jira grows a first-class linker. -type CreateIssueInput struct { - Summary string `json:"summary" jsonschema:"issue summary (required)"` - Type string `json:"type" jsonschema:"issue type, e.g. \"Task\", \"Bug\", \"Story\" (required)"` - Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project)"` - Description string `json:"description,omitempty" jsonschema:"issue description in markdown"` - Priority string `json:"priority,omitempty" jsonschema:"priority name, e.g. \"High\""` - Labels []string `json:"labels,omitempty"` - Components []string `json:"components,omitempty"` - Assignee string `json:"assignee,omitempty" jsonschema:"assignee account id (Cloud) or username (Local)"` -} - -// CreateIssueOutput is the structured result of the create_issue tool. -type CreateIssueOutput struct { - Key string `json:"key"` - URL string `json:"url"` -} - -// CreateIssue runs the create_issue tool. -func CreateIssue(_ context.Context, d *Deps, in CreateIssueInput) (CreateIssueOutput, error) { - if in.Summary == "" { - return CreateIssueOutput{}, errors.New("summary is required") - } - if in.Type == "" { - return CreateIssueOutput{}, errors.New("type is required") - } - - project := d.ResolveProject(in.Project) - if project == "" { - return CreateIssueOutput{}, errors.New("project is required (no default project configured)") - } - - req := &jira.CreateRequest{ - Project: project, - IssueType: in.Type, - Summary: in.Summary, - Body: in.Description, - Priority: in.Priority, - Labels: in.Labels, - Components: in.Components, - Assignee: in.Assignee, - } - req.ForInstallationType(d.Installation) - - resp, err := api.ProxyCreate(d.Client, req) - if err != nil { - return CreateIssueOutput{}, err - } - return CreateIssueOutput{Key: resp.Key, URL: d.IssueURL(resp.Key)}, nil -} -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestCreateIssue -v -``` - -Expected: PASS, all 4 subtests. If a test fails because `jira.CreateRequest.Body` cannot accept a string for v3 (it's `interface{}` per the type def, but the V3 endpoint may require ADF), keep `Body` as the input string for now — the V3 API will accept plain text in many cases; the existing CLI also passes raw strings here. Adjust only if the test against the fake server actually fails on assertion of the body shape. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/create_issue.go internal/mcp/tools/create_issue_test.go -git commit -m "feat(mcp): add create_issue tool" -``` - ---- - -## Task 6: Add the `add_comment` tool - -**Files:** -- Create: `internal/mcp/tools/add_comment.go` -- Create: `internal/mcp/tools/add_comment_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/add_comment_test.go`: - -```go -package tools - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -func newCommentTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { - t.Helper() - server := httptest.NewServer(handler) - client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) - - prevInstall := viper.GetString("installation") - viper.Set("installation", jira.InstallationTypeCloud) - - deps := &Deps{ - Client: client, - Server: server.URL, - DefaultProject: "TEST", - Installation: jira.InstallationTypeCloud, - } - return deps, func() { - server.Close() - viper.Set("installation", prevInstall) - } -} - -func TestAddComment_Success(t *testing.T) { - deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/rest/api/2/issue/TEST-1/comment", r.URL.Path) - assert.Equal(t, http.MethodPost, r.Method) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id": "999"}`)) - }) - defer cleanup() - - out, err := AddComment(context.Background(), deps, AddCommentInput{ - Key: "TEST-1", - Body: "Hello world", - }) - require.NoError(t, err) - assert.Equal(t, "TEST-1", out.Key) - assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) -} - -func TestAddComment_RequiresKey(t *testing.T) { - deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("server should not be called") - w.WriteHeader(500) - }) - defer cleanup() - - _, err := AddComment(context.Background(), deps, AddCommentInput{Body: "x"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "key is required") -} - -func TestAddComment_RequiresBody(t *testing.T) { - deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("server should not be called") - w.WriteHeader(500) - }) - defer cleanup() - - _, err := AddComment(context.Background(), deps, AddCommentInput{Key: "TEST-1"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "body is required") -} -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestAddComment -v -``` - -Expected: FAIL — `AddComment`, `AddCommentInput` undefined. - -- [ ] **Step 3: Implement the tool** - -Create `internal/mcp/tools/add_comment.go`: - -```go -package tools - -import ( - "context" - "errors" -) - -// AddCommentInput is the input schema for the add_comment tool. -type AddCommentInput struct { - Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` - Body string `json:"body" jsonschema:"comment body in markdown (required)"` - Internal bool `json:"internal,omitempty" jsonschema:"mark as an internal (service-desk) comment"` -} - -// AddCommentOutput is the structured result of the add_comment tool. -type AddCommentOutput struct { - Key string `json:"key"` - URL string `json:"url"` -} - -// AddComment runs the add_comment tool. -func AddComment(_ context.Context, d *Deps, in AddCommentInput) (AddCommentOutput, error) { - if in.Key == "" { - return AddCommentOutput{}, errors.New("key is required") - } - if in.Body == "" { - return AddCommentOutput{}, errors.New("body is required") - } - if err := d.Client.AddIssueComment(in.Key, in.Body, in.Internal); err != nil { - return AddCommentOutput{}, err - } - return AddCommentOutput{Key: in.Key, URL: d.IssueURL(in.Key)}, nil -} -``` - -Note: `AddIssueComment` does not return the created comment ID, so the spec's `comment_id` field is dropped from the output. If a future spec revision needs it, add a thin wrapper that captures the response body. - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestAddComment -v -``` - -Expected: PASS, all 3 subtests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/add_comment.go internal/mcp/tools/add_comment_test.go -git commit -m "feat(mcp): add add_comment tool" -``` - ---- - -## Task 7: Add the `transition_issue` tool - -**Files:** -- Create: `internal/mcp/tools/transition_issue.go` -- Create: `internal/mcp/tools/transition_issue_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/mcp/tools/transition_issue_test.go`: - -```go -package tools - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -const transitionsResponse = `{ - "transitions": [ - {"id": "11", "name": "To Do", "isAvailable": true}, - {"id": "21", "name": "In Progress", "isAvailable": true}, - {"id": "31", "name": "Done", "isAvailable": true} - ] -}` - -func newTransitionTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { - t.Helper() - server := httptest.NewServer(handler) - client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) - - prevInstall := viper.GetString("installation") - viper.Set("installation", jira.InstallationTypeCloud) - - deps := &Deps{ - Client: client, - Server: server.URL, - DefaultProject: "TEST", - Installation: jira.InstallationTypeCloud, - } - return deps, func() { - server.Close() - viper.Set("installation", prevInstall) - } -} - -func TestTransitionIssue_Success(t *testing.T) { - var postedID string - deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - assert.Equal(t, "/rest/api/3/issue/TEST-1/transitions", r.URL.Path) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(transitionsResponse)) - case http.MethodPost: - assert.Equal(t, "/rest/api/2/issue/TEST-1/transitions", r.URL.Path) - raw, _ := io.ReadAll(r.Body) - var body map[string]any - _ = json.Unmarshal(raw, &body) - tr, _ := body["transition"].(map[string]any) - postedID, _ = tr["id"].(string) - w.WriteHeader(http.StatusNoContent) - default: - t.Fatalf("unexpected method %s", r.Method) - } - }) - defer cleanup() - - out, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ - Key: "TEST-1", - Transition: "In Progress", - }) - require.NoError(t, err) - assert.Equal(t, "21", postedID) - assert.Equal(t, "TEST-1", out.Key) - assert.Equal(t, "In Progress", out.ToStatus) - assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) -} - -func TestTransitionIssue_CaseInsensitiveMatch(t *testing.T) { - var postedID string - deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(transitionsResponse)) - return - } - raw, _ := io.ReadAll(r.Body) - var body map[string]any - _ = json.Unmarshal(raw, &body) - tr, _ := body["transition"].(map[string]any) - postedID, _ = tr["id"].(string) - w.WriteHeader(http.StatusNoContent) - }) - defer cleanup() - - _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ - Key: "TEST-1", - Transition: "in progress", - }) - require.NoError(t, err) - assert.Equal(t, "21", postedID) -} - -func TestTransitionIssue_UnknownTransition(t *testing.T) { - deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method, "POST should not happen for unknown transition") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(transitionsResponse)) - }) - defer cleanup() - - _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ - Key: "TEST-1", - Transition: "Doing", - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown transition") - assert.Contains(t, err.Error(), "To Do") - assert.Contains(t, err.Error(), "In Progress") - assert.Contains(t, err.Error(), "Done") -} - -func TestTransitionIssue_RequiresKeyAndTransition(t *testing.T) { - deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("server should not be called") - w.WriteHeader(500) - }) - defer cleanup() - - _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{Transition: "Done"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "key is required") - - _, err = TransitionIssue(context.Background(), deps, TransitionIssueInput{Key: "TEST-1"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "transition is required") -} -``` - -- [ ] **Step 2: Run the test and verify it fails** - -```bash -go test ./internal/mcp/tools/ -run TestTransitionIssue -v -``` - -Expected: FAIL — `TransitionIssue`, `TransitionIssueInput` undefined. - -- [ ] **Step 3: Implement the tool** - -Create `internal/mcp/tools/transition_issue.go`: - -```go -package tools - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/ankitpokhrel/jira-cli/api" - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -// TransitionIssueInput is the input schema for the transition_issue tool. -// -// v1 omits Assignee-during-transition because pkg/jira.TransitionRequestFields.Assignee -// only accepts a `{name: ...}` body, which Jira Cloud ignores for account-id users. Users -// who need to reassign on Cloud should do it in a separate step. Revisit when pkg/jira -// grows accountId support on the transition endpoint. -type TransitionIssueInput struct { - Key string `json:"key" jsonschema:"issue key (required)"` - Transition string `json:"transition" jsonschema:"target transition name, e.g. \"In Progress\" (required, case-insensitive)"` - Comment string `json:"comment,omitempty" jsonschema:"optional comment to add as part of the transition (workflow must allow it)"` - Resolution string `json:"resolution,omitempty" jsonschema:"optional resolution name to set, e.g. \"Fixed\""` -} - -// TransitionIssueOutput is the structured result of the transition_issue tool. -type TransitionIssueOutput struct { - Key string `json:"key"` - ToStatus string `json:"to_status"` - URL string `json:"url"` -} - -// TransitionIssue runs the transition_issue tool. -func TransitionIssue(_ context.Context, d *Deps, in TransitionIssueInput) (TransitionIssueOutput, error) { - if in.Key == "" { - return TransitionIssueOutput{}, errors.New("key is required") - } - if in.Transition == "" { - return TransitionIssueOutput{}, errors.New("transition is required") - } - - transitions, err := api.ProxyTransitions(d.Client, in.Key) - if err != nil { - return TransitionIssueOutput{}, err - } - - var match *jira.Transition - target := strings.ToLower(strings.TrimSpace(in.Transition)) - available := make([]string, 0, len(transitions)) - for _, t := range transitions { - available = append(available, t.Name) - if strings.ToLower(t.Name) == target { - match = t - } - } - if match == nil { - return TransitionIssueOutput{}, fmt.Errorf( - "unknown transition %q for %s. Valid transitions: %s", - in.Transition, in.Key, strings.Join(available, ", "), - ) - } - - req := &jira.TransitionRequest{ - Transition: &jira.TransitionRequestData{ - ID: match.ID.String(), - Name: match.Name, - }, - } - if in.Comment != "" { - req.Update = &jira.TransitionRequestUpdate{} - req.Update.Comment = append(req.Update.Comment, struct { - Add struct { - Body string `json:"body"` - } `json:"add"` - }{ - Add: struct { - Body string `json:"body"` - }{Body: in.Comment}, - }) - } - if in.Resolution != "" { - req.Fields = &jira.TransitionRequestFields{ - Resolution: &struct { - Name string `json:"name"` - }{Name: in.Resolution}, - } - } - - if _, err := d.Client.Transition(in.Key, req); err != nil { - return TransitionIssueOutput{}, err - } - return TransitionIssueOutput{ - Key: in.Key, - ToStatus: match.Name, - URL: d.IssueURL(in.Key), - }, nil -} -``` - -- [ ] **Step 4: Run the test and verify it passes** - -```bash -go test ./internal/mcp/tools/ -run TestTransitionIssue -v -``` - -Expected: PASS, all 4 subtests. - -- [ ] **Step 5: Commit** - -```bash -git add internal/mcp/tools/transition_issue.go internal/mcp/tools/transition_issue_test.go -git commit -m "feat(mcp): add transition_issue tool" -``` - ---- - -## Task 8: Build the MCP server (registration + in-memory round trip test) - -**Files:** -- Modify: `go.mod`, `go.sum` -- Create: `internal/mcp/server.go` -- Create: `internal/mcp/server_test.go` - -The server constructor takes a `*tools.Deps`, builds a `*mcp.Server`, and registers all five tools using the SDK's `mcp.AddTool` generic helper. Each registration adapts the `(d, in) -> (out, err)` tool function to the SDK's `(ctx, *CallToolRequest, In) -> (*CallToolResult, Out, error)` signature. - -This is the first task whose source actually imports the MCP Go SDK, so the SDK dependency is added here. (Adding it earlier would have left `go.mod` in a state that `go mod tidy` would revert.) - -- [ ] **Step 1: Add the official MCP Go SDK dependency** - -```bash -go get github.com/modelcontextprotocol/go-sdk@v1.5.0 -``` - -Expected: `go get` completes; `go.mod` gains `github.com/modelcontextprotocol/go-sdk v1.5.0`. Once Steps 2–5 below add the source files that import the SDK, `go mod tidy` will keep the requirement and (if previously listed as `// indirect`) promote it to a direct require. If `v1.5.0` is no longer the latest stable, use the latest stable v1.x. - -- [ ] **Step 2: Write the failing test** - -Create `internal/mcp/server_test.go`: - -```go -package mcp - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" - "github.com/ankitpokhrel/jira-cli/pkg/jira" -) - -func TestServer_ListsAllTools(t *testing.T) { - prevInstall := viper.GetString("installation") - viper.Set("installation", jira.InstallationTypeCloud) - defer viper.Set("installation", prevInstall) - - jiraServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(200) - _, _ = w.Write([]byte(`{"issues": []}`)) - })) - defer jiraServer.Close() - - deps := &tools.Deps{ - Client: jira.NewClient(jira.Config{Server: jiraServer.URL}, jira.WithTimeout(3*time.Second)), - Server: jiraServer.URL, - DefaultProject: "TEST", - Installation: jira.InstallationTypeCloud, - } - - srv := NewServer(deps) - require.NotNil(t, srv) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - serverT, clientT := mcp.NewInMemoryTransports() - - serverDone := make(chan error, 1) - go func() { serverDone <- srv.Run(ctx, serverT) }() - - client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) - session, err := client.Connect(ctx, clientT, nil) - require.NoError(t, err) - defer session.Close() - - listed, err := session.ListTools(ctx, &mcp.ListToolsParams{}) - require.NoError(t, err) - - names := make(map[string]bool) - for _, tool := range listed.Tools { - names[tool.Name] = true - } - for _, expected := range []string{ - "search_issues", "get_issue", "create_issue", "add_comment", "transition_issue", - } { - assert.True(t, names[expected], "expected tool %q to be registered", expected) - } - - res, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: "search_issues", - Arguments: map[string]any{"jql": "project = TEST"}, - }) - require.NoError(t, err) - assert.False(t, res.IsError, "search_issues should succeed against the fake server") - - // Validation errors must come back as IsError tool results, not transport errors. - res, err = session.CallTool(ctx, &mcp.CallToolParams{ - Name: "get_issue", - Arguments: map[string]any{}, // missing required "key" - }) - require.NoError(t, err) - assert.True(t, res.IsError, "missing required key should produce a tool error result") -} -``` - -- [ ] **Step 3: Run the test and verify it fails** - -```bash -go test ./internal/mcp/ -run TestServer -v -``` - -Expected: FAIL — `NewServer` undefined. - -- [ ] **Step 4: Implement the server** - -Create `internal/mcp/server.go`: - -```go -package mcp - -import ( - "context" - "fmt" - "os" - - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" -) - -const ( - // ServerName is the implementation name advertised over MCP. - ServerName = "jira-cli" - // ServerVersion is the MCP server version advertised to clients. - // Bumped independently of the jira-cli release version when the MCP - // surface changes in a backward-incompatible way. - ServerVersion = "0.1.0" -) - -// NewServer constructs a configured *mcp.Server with all jira-cli tools -// registered. The caller is responsible for invoking server.Run with a -// transport. -func NewServer(d *tools.Deps) *mcpsdk.Server { - srv := mcpsdk.NewServer(&mcpsdk.Implementation{ - Name: ServerName, - Version: ServerVersion, - }, nil) - - registerTool(srv, "search_issues", - "Search Jira issues by JQL or simple filters. Defaults to the configured project.", - d, tools.SearchIssues) - - registerTool(srv, "get_issue", - "Get full details of a Jira issue including description and recent comments.", - d, tools.GetIssue) - - registerTool(srv, "create_issue", - "Create a new Jira issue in the given project.", - d, tools.CreateIssue) - - registerTool(srv, "add_comment", - "Add a comment to a Jira issue.", - d, tools.AddComment) - - registerTool(srv, "transition_issue", - "Transition a Jira issue to a new status by name (e.g. \"In Progress\", \"Done\").", - d, tools.TransitionIssue) - - return srv -} - -// registerTool adapts a tools.* handler (which takes Deps + Input and returns -// Output + error) onto the SDK's expected handler signature. It also recovers -// from panics in the handler body so a single bad call cannot kill the server -// mid-session, and converts both errors and panics into MCP tool errors that -// the LLM can read. -func registerTool[In, Out any]( - srv *mcpsdk.Server, - name, description string, - d *tools.Deps, - fn func(context.Context, *tools.Deps, In) (Out, error), -) { - mcpsdk.AddTool(srv, - &mcpsdk.Tool{Name: name, Description: description}, - func(ctx context.Context, _ *mcpsdk.CallToolRequest, in In) (result *mcpsdk.CallToolResult, out Out, err error) { - defer func() { - if r := recover(); r != nil { - var zero Out - fmt.Fprintf(os.Stderr, "mcp: panic in tool %q: %v\n", name, r) - result = &mcpsdk.CallToolResult{ - IsError: true, - Content: []mcpsdk.Content{&mcpsdk.TextContent{ - Text: fmt.Sprintf("internal error in tool %q: %v", name, r), - }}, - } - out = zero - err = nil - } - }() - - out, callErr := fn(ctx, d, in) - if callErr != nil { - var zero Out - return &mcpsdk.CallToolResult{ - IsError: true, - Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: callErr.Error()}}, - }, zero, nil - } - return nil, out, nil - }, - ) -} -``` - -- [ ] **Step 5: Run the test and verify it passes** - -```bash -go test ./internal/mcp/ -run TestServer -v -``` - -Expected: PASS. If the SDK's exact symbol names differ slightly between v1.5.0 minor versions (e.g. `mcp.NewInMemoryTransports` vs `mcp.NewInMemoryTransport`), check `pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp` for the current API and adjust the test only — the production code in `server.go` follows the README example verbatim. - -- [ ] **Step 6: Run the whole MCP test suite to confirm everything still passes** - -```bash -go test ./internal/mcp/... -v -``` - -Expected: PASS, all suites. - -- [ ] **Step 7: Commit** - -Before committing, run `go mod tidy` to make sure `go.mod`/`go.sum` are minimal and that the SDK requirement is now recorded as a direct require (since `server.go` and `server_test.go` import it). - -```bash -go mod tidy -git add go.mod go.sum internal/mcp/server.go internal/mcp/server_test.go -git commit -m "feat(mcp): add Server constructor and in-memory round-trip test" -``` - ---- - -## Task 9: Wire `jira mcp serve` into the Cobra tree - -**Files:** -- Create: `internal/cmd/mcp/mcp.go` -- Create: `internal/cmd/mcp/serve/serve.go` -- Modify: `internal/cmd/root/root.go` - -The `serve` command is the only place viper is read on the MCP path. It builds `tools.Deps` and runs the server over stdio. **It must not write to stdout**; all logging goes to stderr. - -- [ ] **Step 1: Create the `mcp` parent command** - -Create `internal/cmd/mcp/mcp.go`: - -```go -package mcp - -import ( - "github.com/spf13/cobra" - - "github.com/ankitpokhrel/jira-cli/internal/cmd/mcp/serve" -) - -const helpText = `Run jira-cli as a Model Context Protocol (MCP) server, exposing -Jira operations to MCP-aware hosts (e.g. Cursor, Claude Desktop).` - -// NewCmdMCP is the parent command for MCP-related subcommands. -func NewCmdMCP() *cobra.Command { - cmd := &cobra.Command{ - Use: "mcp", - Short: "Run jira-cli as an MCP server", - Long: helpText, - RunE: func(cmd *cobra.Command, _ []string) error { - return cmd.Help() - }, - } - cmd.AddCommand(serve.NewCmdServe()) - return cmd -} -``` - -- [ ] **Step 2: Create the `serve` subcommand** - -Create `internal/cmd/mcp/serve/serve.go`: - -```go -package serve - -import ( - "fmt" - "os" - "os/signal" - "syscall" - - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/ankitpokhrel/jira-cli/api" - jiramcp "github.com/ankitpokhrel/jira-cli/internal/mcp" - "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" -) - -const helpText = `Start an MCP server over stdio. - -Configure your MCP host (Cursor, Claude Desktop, etc.) like this: - - { - "mcpServers": { - "jira": { - "command": "jira", - "args": ["mcp", "serve"], - "env": { "JIRA_API_TOKEN": "..." } - } - } - } - -The server inherits the same configuration as every other jira-cli command: -JIRA_CONFIG_FILE, ~/.config/.jira/.config.yml, .netrc, and keychain all work -unchanged. The server reads from stdin and writes JSON-RPC frames to stdout; -all logs go to stderr.` - -// NewCmdServe is the `jira mcp serve` command. -func NewCmdServe() *cobra.Command { - return &cobra.Command{ - Use: "serve", - Short: "Start an MCP server over stdio", - Long: helpText, - RunE: run, - } -} - -func run(cmd *cobra.Command, _ []string) error { - server := viper.GetString("server") - if server == "" { - return fmt.Errorf("no Jira server configured. Run 'jira init' to set up the tool") - } - - // Honor browse_server override the same way internal/cmdutil.GenerateServerBrowseURL does, - // so MCP-emitted issue URLs match what the rest of the CLI produces for users whose web - // client and API endpoints differ. - browseServer := server - if v := viper.GetString("browse_server"); v != "" { - browseServer = v - } - - debug := viper.GetBool("debug") - deps := &tools.Deps{ - Client: api.DefaultClient(debug), - Server: browseServer, - DefaultProject: viper.GetString("project.key"), - Installation: viper.GetString("installation"), - } - - srv := jiramcp.NewServer(deps) - - ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) - defer stop() - - fmt.Fprintln(os.Stderr, "jira-cli MCP server: listening on stdio") - if err := srv.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { - return fmt.Errorf("mcp server: %w", err) - } - return nil -} -``` - -- [ ] **Step 3: Register the new command in root** - -Modify `internal/cmd/root/root.go`. In the import block, add: - -```go -"github.com/ankitpokhrel/jira-cli/internal/cmd/mcp" -``` - -In the `addChildCommands` function, add `mcp.NewCmdMCP()` to the call to `cmd.AddCommand`. After the change, the function looks like: - -```go -func addChildCommands(cmd *cobra.Command) { - cmd.AddCommand( - initCmd.NewCmdInit(), - issue.NewCmdIssue(), - epic.NewCmdEpic(), - sprint.NewCmdSprint(), - board.NewCmdBoard(), - project.NewCmdProject(), - open.NewCmdOpen(), - me.NewCmdMe(), - serverinfo.NewCmdServerInfo(), - completion.NewCmdCompletion(), - version.NewCmdVersion(), - release.NewCmdRelease(), - man.NewCmdMan(), - mcp.NewCmdMCP(), - ) -} -``` - -- [ ] **Step 4: Allowlist `mcp` and `serve` in `cmdRequireToken`?** - -No. The MCP server requires a configured Jira instance to do anything useful, and the existing token check (`PersistentPreRun` in root) is exactly what we want to fail early with a clear message. No change needed here. - -- [ ] **Step 5: Verify the binary builds and the help text appears** - -```bash -go build ./... -``` - -Expected: exit 0. - -```bash -go run ./cmd/jira mcp --help -``` - -Expected: prints the parent help text, listing `serve` as a subcommand. - -```bash -go run ./cmd/jira mcp serve --help -``` - -Expected: prints the serve help, including the JSON config snippet. - -- [ ] **Step 6: Commit** - -```bash -git add internal/cmd/mcp/mcp.go internal/cmd/mcp/serve/serve.go internal/cmd/root/root.go -git commit -m "feat(mcp): add 'jira mcp serve' cobra command (stdio transport)" -``` - ---- - -## Task 10: Update README - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Find the insertion point** - -Open `README.md` and locate the `## Scripts` heading. The new section will be inserted *before* `## Scripts`, after the closing of `### Other commands`. - -- [ ] **Step 2: Add the MCP section** - -Insert the following new section immediately after the `### Other commands` block and immediately before the `## Scripts` heading. Use the existing markdown style (no emojis, plain headings). - -```markdown -## MCP server - -`jira-cli` ships an embedded [Model Context Protocol](https://modelcontextprotocol.io) server so MCP-aware hosts (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the same config, auth, and Jira API client as the rest of the CLI. - -Start it from your MCP host configuration: - -```json -{ - "mcpServers": { - "jira": { - "command": "jira", - "args": ["mcp", "serve"], - "env": { "JIRA_API_TOKEN": "..." } - } - } -} -``` - -The server speaks stdio and exposes the following tools: - -| Tool | Purpose | -| --- | --- | -| `search_issues` | Search by raw JQL or simple `status`/`assignee` filters. | -| `get_issue` | Full issue details including description and recent comments. | -| `create_issue` | Create a new issue in a project. | -| `add_comment` | Add a comment to an issue. | -| `transition_issue` | Move an issue to a new status by name. | - -Every tool that returns an issue also returns its browser URL so the LLM can cite or link to it directly. -``` - -- [ ] **Step 3: Verify the README renders cleanly** - -Open `README.md` in any markdown previewer (or just re-read the diff) and confirm: -- The new section appears between `### Other commands` and `## Scripts`. -- The fenced JSON block renders. -- The table renders. - -- [ ] **Step 4: Commit** - -```bash -git add README.md -git commit -m "docs: document the jira-cli MCP server" -``` - ---- - -## Task 11: Final verification - -- [ ] **Step 1: Run all tests** - -```bash -go test ./... -``` - -Expected: PASS across the whole repo. No new failures in pre-existing packages. - -- [ ] **Step 2: Run the project's CI recipe** - -```bash -make ci -``` - -Expected: lint + tests pass. If golangci-lint flags style issues in new files (e.g. missing comments on exported symbols), fix them. - -- [ ] **Step 3: Smoke-test the binary end-to-end** - -```bash -go install ./cmd/jira -jira mcp serve --help -``` - -Expected: help text appears, including the JSON snippet. - -Optionally, with a configured Jira instance: - -```bash -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual","version":"0"}}}' | jira mcp serve -``` - -Expected: a JSON-RPC `initialize` response on stdout, server log lines on stderr, then the process exits when stdin closes. - -- [ ] **Step 4: Confirm the git log is clean** - -```bash -git log --oneline | head -15 -``` - -Expected: a sequence of small, focused commits matching this plan's tasks. Nothing else. - ---- - -## Notes for the implementer - -- **Stdout discipline:** Search the new code for any accidental `fmt.Println`, `fmt.Printf`, or `os.Stdout` writes before merging. Stdout belongs exclusively to JSON-RPC. -- **Context cancellation is best-effort in v1:** `pkg/jira` high-level methods do not currently accept a `context.Context`. The MCP handlers receive a context from the SDK but cannot thread it into outbound HTTP calls. A 15-second client timeout still bounds individual requests. Adding context-accepting variants to `pkg/jira` is explicitly deferred per the spec. -- **`api` package and viper:** Tools call `api.Proxy*` functions, which read `viper.GetString("installation")` internally. Tests must set `viper.Set("installation", jira.InstallationTypeCloud)` (or `InstallationTypeLocal`) and restore the previous value. The helper functions in each test file already do this. -- **SDK API drift:** The plan targets `v1.5.0`. If the implementer pulls a newer version with breaking changes, the canonical reference is the README example at https://github.com/modelcontextprotocol/go-sdk — adapt only `server.go` and `server_test.go`; the rest of the code is SDK-independent. diff --git a/docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md b/docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md deleted file mode 100644 index 114863bd..00000000 --- a/docs/superpowers/specs/2026-04-17-jira-mcp-server-design.md +++ /dev/null @@ -1,165 +0,0 @@ -# Jira CLI MCP Server — Design - -**Status:** Approved (brainstorm) — pending implementation plan -**Date:** 2026-04-17 -**Scope:** v1 of an MCP server integrated into `jira-cli`, intended to be upstreamed to `ankitpokhrel/jira-cli`. - -## Goal - -Add a Model Context Protocol (MCP) server to `jira-cli` so that an LLM running in an MCP host (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the CLI's existing config, auth, and Jira API client. - -## Non-goals (v1) - -- Multi-user / hosted deployment. -- HTTP/SSE transport (stdio only for v1; design leaves room to add later). -- Mirroring the entire CLI surface (see "Tool surface" for the v1 set). -- MCP resources or prompts (tools only). -- Any read-only mode flag, dry-run mode, or extra confirmation gate beyond what the MCP host already provides. - -## Use case - -Single-user, IDE-integrated coding assistant. The user runs `jira mcp serve` from an MCP host configured to spawn it over stdio. The LLM uses the tools to look up tickets it's working on, file new ones, comment, and transition state — all gated by the host's own per-tool prompts. - -## Approach - -**Thin MCP layer over `pkg/jira`.** A new `internal/mcp/` package builds an MCP server using the official `github.com/modelcontextprotocol/go-sdk`. Each tool is a small adapter that calls the existing `pkg/jira` client. No refactor of the existing `internal/cmd/...` layer; no shelling out to the `jira` binary. - -This keeps the diff small, isolates MCP code from the TUI/Cobra/Survey machinery in `internal/cmd/...`, and makes the tool layer trivially unit-testable. - -## Package layout - -``` -internal/cmd/mcp/ // NEW: cobra command surface - mcp.go // `jira mcp` parent cmd - serve/serve.go // `jira mcp serve` (wires SDK + tools, stdio) -internal/mcp/ // NEW: MCP server core - server.go // builds *mcp.Server, registers tools - tools/ - deps.go // shared Deps struct + helpers - search_issues.go - get_issue.go - create_issue.go - add_comment.go - transition_issue.go - _test.go // per-tool unit tests - server_test.go // in-memory transport round-trip test -``` - -Wiring: -- `internal/cmd/root` registers the new `mcp` parent command alongside `issue`, `epic`, etc. -- `internal/cmd/mcp/serve` constructs `tools.Deps` from `viper` + `api.DefaultClient` and hands it to `internal/mcp.NewServer(deps)`, then runs the server over stdio. - -Dependency rules: -- `internal/mcp/tools/*` may import `pkg/jira`, `pkg/adf`, and standard library only. No `cobra`, `viper`, `survey`, or `tui`. -- All viper reads live in `internal/cmd/mcp/serve`. Tools receive their dependencies through `tools.Deps`. - -## New external dependency - -`github.com/modelcontextprotocol/go-sdk` — the official MCP Go SDK (Tier 1, maintained with Google). Used for the server skeleton, schema derivation from Go structs, and the stdio transport. One new top-level dep; acceptable for upstream. - -## Tool surface (v1) - -All inputs are JSON-Schema-derived from Go structs via the SDK's reflection. All outputs are JSON for structured fields, with the issue browser URL included where applicable so the LLM can cite/link. - -### `search_issues` - -- **Input:** `{ jql?: string, project?: string, status?: string, assignee?: string, limit?: int (default 50, max 100) }` -- **Behavior:** If `jql` is provided, use it verbatim, scoped to `project` if set (else the configured project). Otherwise compose JQL from the other filters. Calls `client.Search`. -- **Output:** `{ total: int, issues: [{ key, summary, status, type, priority, assignee, reporter, created, updated, url }] }`. Lean rows — no description or comments. - -### `get_issue` - -- **Input:** `{ key: string (required), include_comments?: bool (default true), comment_limit?: int (default 10) }` -- **Behavior:** `client.GetIssue(key)` plus comment fetch. ADF description converted to markdown via `pkg/adf`. -- **Output:** Full issue: `key, summary, status, type, priority, assignee, reporter, labels, components, fix_versions, parent, links, description (markdown), comments [{ author, body, created }], url`. - -### `create_issue` - -- **Input:** `{ summary: string (required), type: string (required, e.g. "Task"|"Bug"|"Story"), project?: string, description?: string, priority?: string, labels?: string[], components?: string[], assignee?: string, parent?: string }` -- **Behavior:** Selects `client.Create` vs `client.CreateV2` based on installation type, matching the existing create command's logic. -- **Output:** `{ key, url }`. - -### `add_comment` - -- **Input:** `{ key: string (required), body: string (required), internal?: bool }` -- **Behavior:** Calls the appropriate add-comment method on the client based on installation type. -- **Output:** `{ key, comment_id, url }`. - -### `transition_issue` - -- **Input:** `{ key: string (required), transition: string (required, e.g. "In Progress"|"Done"), comment?: string, resolution?: string, assignee?: string }` -- **Behavior:** Resolves transition name → id via `client.GetTransitions`. If the name is unknown, returns a tool error listing valid transitions. Then calls `client.Transition`. -- **Output:** `{ key, from_status, to_status, url }`. - -## Cross-cutting behavior - -- **Project defaulting:** When a tool's `project` field is omitted, fall back to `viper.GetString("project.key")`. Only required to be passed for cross-project work. -- **URLs:** Every output that references an issue includes `{server}/browse/{key}` so the LLM can cite/link in chat. -- **Concurrency:** The SDK invokes tool handlers concurrently. The `pkg/jira` client is already safe for concurrent use (HTTP client under the hood). No additional locking. -- **Cancellation:** Each handler receives a `context.Context` from the SDK. It is threaded into outbound HTTP calls so a host-cancelled tool call also cancels the upstream Jira request. If `pkg/jira` does not currently accept a context on the relevant methods, add a context-accepting variant (smallest possible change) rather than refactoring the existing API. - -## Lifecycle and transport - -`jira mcp serve`: - -1. Cobra command parses inherited globals (`--config`, `--debug`); no MCP-specific flags in v1. -2. Reads viper config the same way every other `jira` subcommand does, so `JIRA_API_TOKEN`, `JIRA_CONFIG_FILE`, `~/.config/.jira/.config.yml`, `.netrc`, and keychain auth all keep working unchanged. -3. Constructs `api.DefaultClient(debug)` once, builds `tools.Deps{ Client, Project, Server, Installation }`. -4. Builds `mcp.NewServer(...)`, registers the five tools, and calls `server.Run(ctx, mcp.NewStdioTransport())`. -5. Blocks until stdin closes or SIGINT/SIGTERM is received. - -**Stdout discipline:** With stdio transport, stdout is reserved exclusively for JSON-RPC frames. The MCP code path must not call any of the CLI's stdout printers; it returns structured tool results as values and emits all logs to stderr. - -**Failing fast:** If config is missing at startup (e.g. no config file and no `JIRA_API_TOKEN`), the command fails before the MCP handshake with a message pointing at `jira init`. Better than appearing connected and breaking on first call. - -## Configuration in MCP hosts - -Documented snippet for Cursor / Claude Desktop: - -```json -{ - "mcpServers": { - "jira": { - "command": "jira", - "args": ["mcp", "serve"], - "env": { "JIRA_API_TOKEN": "..." } - } - } -} -``` - -This snippet appears in both the README and `jira mcp serve --help`. - -## Error handling - -Three categories, each handled differently: - -1. **Bad input from the LLM** (missing required field, unknown transition name, malformed JQL): returned as a structured MCP tool error (`isError: true`) with a clear message and, where useful, the list of valid options. Example: `transition_issue` with `transition: "Doing"` returns `"Unknown transition 'Doing' for ISSUE-1. Valid transitions: To Do, In Progress, Done"`. The LLM can self-correct on the next turn. -2. **Jira API errors** (4xx/5xx from upstream): pass the upstream error message through to the tool result so the LLM can reason about it. Auth failures get a one-liner pointing at `JIRA_API_TOKEN` so the user can fix their setup. -3. **Server-internal errors**: logged to stderr with full context. Tool handlers wrap their body in `defer recover()` that converts panics into tool errors so a single bad call cannot kill the server mid-session. - -## Testing - -Mirroring the existing repo's pattern (`*_test.go` next to source, `testify`): - -- **Per-tool unit tests** in `internal/mcp/tools/_test.go`. Each test uses a fake `pkg/jira` client (matching whatever fake/`httptest` pattern `pkg/jira/*_test.go` already uses). Coverage targets: happy path, required-field validation, default-project fallback, error passthrough, and (for `transition_issue`) the unknown-transition case. -- **One integration test** in `internal/mcp/server_test.go` using `mcp.NewInMemoryTransports()` to exercise the round-trip `tools/list` plus a `tools/call` for one read tool and one write tool. Catches schema-derivation and SDK-API drift. -- No live-Jira tests in CI (matches the rest of the repo). - -Coverage density should match `internal/cmd/issue/*_test.go`. - -## Documentation - -- **README:** New top-level "MCP server" section after "Scripts", with the host config snippet, the list of five tools, and a short example transcript. -- **`jira mcp serve --help`:** Includes the host config snippet inline so users can find it from the CLI. - -## Rollout - -Single PR containing the new packages, tests, and README update. Self-contained; the upstream maintainer can evaluate it as a unit. No changes to existing commands or to the public API of `pkg/jira` except the targeted addition of context-accepting variants if needed for cancellation. - -## Future work (explicitly deferred) - -- HTTP/SSE transport via a `--http :PORT` flag on `serve`. -- Additional tools: edit, assign, link/unlink, list sprints/epics, list projects/boards, get current user, delete (with extra gating), worklog. -- MCP resources (e.g. "current sprint") and prompts (e.g. "summarize my open issues"). -- A `--read-only` startup flag and per-tool `dry_run` if real-world use surfaces a need. From ae4b6e98372402b8b54965824df0b46d7a45e230 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Mon, 20 Apr 2026 07:52:28 -0700 Subject: [PATCH 26/27] chore(mcp): apply golangci-lint fixes and add cmd:main annotation - Add Annotations{cmd:main: true} to the `jira mcp` parent so it appears under MAIN COMMANDS in root --help, matching the convention used by issue, epic, sprint, board, project, open, and release. - Wrap session.Close() in defer funcs in server_test.go (errcheck). - Extract the 50/100 magic numbers in search_issues.go's limit clamp to defaultSearchLimit / maxSearchLimit named constants (mnd). golangci-lint run ./... now reports 0 issues (v2.6.2, GOTOOLCHAIN=go1.25.6). Made-with: Cursor --- internal/cmd/mcp/mcp.go | 7 ++++--- internal/mcp/server_test.go | 4 ++-- internal/mcp/tools/search_issues.go | 14 +++++++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go index 289de7ea..74b194df 100644 --- a/internal/cmd/mcp/mcp.go +++ b/internal/cmd/mcp/mcp.go @@ -12,9 +12,10 @@ Jira operations to MCP-aware hosts (e.g. Cursor, Claude Desktop).` // NewCmdMCP is the parent command for MCP-related subcommands. func NewCmdMCP() *cobra.Command { cmd := &cobra.Command{ - Use: "mcp", - Short: "Run jira-cli as an MCP server", - Long: helpText, + Use: "mcp", + Short: "Run jira-cli as an MCP server", + Long: helpText, + Annotations: map[string]string{"cmd:main": "true"}, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index b9fbcded..87497c68 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -48,7 +48,7 @@ func TestServer_ListsAllTools(t *testing.T) { client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) session, err := client.Connect(ctx, clientT, nil) require.NoError(t, err) - defer session.Close() + defer func() { _ = session.Close() }() listed, err := session.ListTools(ctx, &mcp.ListToolsParams{}) require.NoError(t, err) @@ -101,7 +101,7 @@ func TestRegisterTool_RecoversFromPanic(t *testing.T) { client := mcp.NewClient(&mcp.Implementation{Name: "panic-client", Version: "v0"}, nil) session, err := client.Connect(ctx, clientT, nil) require.NoError(t, err) - defer session.Close() + defer func() { _ = session.Close() }() res, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: "panic_tool", diff --git a/internal/mcp/tools/search_issues.go b/internal/mcp/tools/search_issues.go index 6a0f8b8c..424c5a8b 100644 --- a/internal/mcp/tools/search_issues.go +++ b/internal/mcp/tools/search_issues.go @@ -8,6 +8,14 @@ import ( "github.com/ankitpokhrel/jira-cli/api" ) +const ( + // defaultSearchLimit is used when the caller omits Limit (or passes <= 0). + defaultSearchLimit = 50 + // maxSearchLimit is the hard cap we enforce on Limit. The Jira v3 + // /search/jql endpoint accepts up to 100; we mirror that here. + maxSearchLimit = 100 +) + // SearchIssuesInput is the input schema for the search_issues tool. type SearchIssuesInput struct { JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. Passed through verbatim unless project is also set, in which case the JQL is wrapped as 'project = X AND (your JQL)'. If you set project alongside JQL, your JQL must not contain its own ORDER BY clause."` @@ -44,10 +52,10 @@ type IssueBrief struct { func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssuesOutput, error) { limit := in.Limit if limit <= 0 { - limit = 50 + limit = defaultSearchLimit } - if limit > 100 { - limit = 100 + if limit > maxSearchLimit { + limit = maxSearchLimit } jql := strings.TrimSpace(in.JQL) From 7ddb4c57421b5b4c2133750a8cf8616feb6ba308 Mon Sep 17 00:00:00 2001 From: Zander Pyle Date: Mon, 20 Apr 2026 08:19:19 -0700 Subject: [PATCH 27/27] fix(mcp): route root's debug "Using config file" line to stderr The print fires from cobra.OnInitialize, which runs before any subcommand's RunE, so the defensive force-disable of debug in `jira mcp serve` happens too late to prevent the message from corrupting the stdio JSON-RPC stream if the user invokes `jira mcp serve --debug`. Stderr is the correct destination for debug/log output in every command anyway, so the change is a strict improvement for non-MCP users too. Also add a doc comment on tools.Deps.Installation clarifying that it's only consumed by CreateIssue; the other tools still dispatch v2/v3 via viper inside api.Proxy*, and tests exercising non-Cloud paths must set both fields. Made-with: Cursor --- internal/cmd/root/root.go | 2 +- internal/mcp/tools/deps.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 7f008951..ee6de1b2 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -65,7 +65,7 @@ func init() { viper.SetEnvPrefix("jira") if err := viper.ReadInConfig(); err == nil && debug { - fmt.Printf("Using config file: %s\n", viper.ConfigFileUsed()) + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) } }) } diff --git a/internal/mcp/tools/deps.go b/internal/mcp/tools/deps.go index 265e79cf..528a362c 100644 --- a/internal/mcp/tools/deps.go +++ b/internal/mcp/tools/deps.go @@ -13,7 +13,12 @@ type Deps struct { Client *jira.Client Server string DefaultProject string - Installation string + // Installation is the configured Jira installation type ("Cloud" or + // "Local"). Currently consumed only by CreateIssue (for + // CreateRequest.ForInstallationType). The v2/v3 dispatch of api.Proxy* + // helpers still reads viper directly, so tests must also + // viper.Set("installation", ...) when exercising non-Cloud paths. + Installation string } // IssueURL returns the browser URL for a given issue key.