From f1c58443e3bb6f903aa478b786e0e30e284bf685 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 19 Jun 2026 14:40:01 -0500 Subject: [PATCH] Swap the Atlassian MCP for the working jira MCP (sooperset/mcp-atlassian) (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Atlassian Remote MCP Server added in #522/#524 (mcp.atlassian.com, server name `atlassian`, HTTP Basic via ATLASSIAN_MCP_AUTHORIZATION) never worked in our setup. Route everything Jira through the `jira` MCP (sooperset/mcp-atlassian, Docker stdio) that is already configured globally in ~/.claude.json and confirmed working. Decision: rely on the global `jira` server. Because it lives in ~/.claude.json's top-level mcpServers, it is auto-loaded and trusted in every Claude Code session (worktrees, Manager, cron) — so Crow injects nothing. - setup.sh / bundled template: drop resolve_atlassian_mcp_env, the settings.local.json ATLASSIAN_MCP_AUTHORIZATION + enabledMcpjsonServers injection, write_mcp_json, and the .mcp.json git-exclude. - App: delete ClaudeHookConfigWriter.writeAtlassianMcpConfig, the SessionService injection call sites + resolver helpers, and the MCP injection test. The Swift app process can't use the MCP, so the #523 "Fetch from Jira" status button keeps a small REST credential: rename AtlassianMCPConfig -> JiraCredential (username + op:// token, no endpoint) and AtlassianMCPResolver -> JiraCredentialResolver (Basic header). AppConfig field atlassianMCP -> jiraCredential, with backward-compat decode of the old block. Settings "Atlassian MCP" section -> "Jira (status fetch)". - Skills + docs: rewrite the Jira sections to the real jira_* tools (jira_get_issue/create_issue/update_issue/transition_issue/get_transitions/ add_comment/get_user_profile), drop the cloudId / getAccessibleAtlassianResources step, and document the two-step transition flow (get_transitions -> transition_id). - Scaffolder allowlist mcp__atlassian* -> mcp__jira*. Removed the stale atlassian entry from the devRoot .mcp.json + settings.local.json. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: AB7A2CCD-FF91-44FB-99B1-6A06F1380859 --- .../CrowClaude/ClaudeHookConfigWriter.swift | 113 ----------------- .../Sources/CrowClaude/ClaudeLauncher.swift | 4 +- .../ClaudeHookConfigWriterMCPTests.swift | 101 --------------- .../CrowClaudeTests/ClaudeLauncherTests.swift | 6 +- .../CrowCore/AtlassianMCPResolver.swift | 69 ----------- .../CrowCore/JiraCredentialResolver.swift | 55 +++++++++ .../Sources/CrowCore/JiraStatusFetcher.swift | 10 +- .../Sources/CrowCore/Models/AppConfig.swift | 115 ++++++++++-------- .../AtlassianMCPResolverTests.swift | 72 ----------- .../JiraCredentialResolverTests.swift | 76 ++++++++++++ .../CrowUI/AutomationSettingsView.swift | 75 +++++------- .../CrowUI/Sources/CrowUI/SettingsView.swift | 20 +-- .../Sources/CrowUI/WorkspaceFormView.swift | 4 +- .../crow-batch-workspace-SKILL.md.template | 2 +- .../crow-create-ticket-SKILL.md.template | 32 ++--- Resources/crow-workspace-SKILL.md.template | 47 ++++--- Resources/crow-workspace-setup.sh.template | 115 ++---------------- Sources/Crow/App/Scaffolder.swift | 4 +- Sources/Crow/App/SessionService.swift | 42 ------- docs/automation.md | 15 +-- docs/configuration.md | 42 +++---- skills/crow-batch-workspace/SKILL.md | 2 +- skills/crow-create-ticket/SKILL.md | 32 ++--- skills/crow-workspace/SKILL.md | 47 ++++--- skills/crow-workspace/setup.sh | 115 ++---------------- 25 files changed, 375 insertions(+), 840 deletions(-) delete mode 100644 Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift delete mode 100644 Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift create mode 100644 Packages/CrowCore/Sources/CrowCore/JiraCredentialResolver.swift delete mode 100644 Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift create mode 100644 Packages/CrowCore/Tests/CrowCoreTests/JiraCredentialResolverTests.swift diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift index 3a91d559..e6c8e084 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift @@ -152,119 +152,6 @@ public struct ClaudeHookConfigWriter: HookConfigWriter { } } - // MARK: - Atlassian MCP (CROW-522) - - /// MCP server name we register for the Atlassian Remote MCP Server. - static let atlassianMcpServerName = "atlassian" - /// `env` key carrying the resolved `Authorization` header value. The - /// `.mcp.json` header references it via `${…}` expansion so the secret lives - /// only in the owner-only `settings.local.json`, never in `.mcp.json`. - static let atlassianMcpAuthEnvKey = "ATLASSIAN_MCP_AUTHORIZATION" - - /// Register (or clear) the Atlassian Remote MCP Server for a launched session - /// (CROW-522). Writes a project-root `.mcp.json` with an `http` server whose - /// `Authorization` header expands from `${ATLASSIAN_MCP_AUTHORIZATION}`, pre- - /// trusts it via `enabledMcpjsonServers`, and stores the resolved header value - /// in the `settings.local.json` `env` block (chmod 0600). Pass `nil` to remove - /// all three so toggling the MCP off — or a non-Jira session — leaves nothing - /// stale behind. - /// - /// `dirPath` is the worktree path for work/job/review sessions, or the dev - /// root for the Manager session — the same directory the session launches in, - /// so Claude Code reads both files. - public static func writeAtlassianMcpConfig(dirPath: String, resolved: AtlassianMCPResolver.Resolved?) { - let claudeDir = (dirPath as NSString).appendingPathComponent(".claude") - let settingsPath = (claudeDir as NSString).appendingPathComponent("settings.local.json") - let mcpPath = (dirPath as NSString).appendingPathComponent(".mcp.json") - - // --- settings.local.json: env + enabledMcpjsonServers --- - var settings: [String: Any] = [:] - if let data = FileManager.default.contents(atPath: settingsPath), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - settings = parsed - } - - var env = settings["env"] as? [String: Any] ?? [:] - var enabled = settings["enabledMcpjsonServers"] as? [String] ?? [] - if let resolved { - env[atlassianMcpAuthEnvKey] = resolved.authorization - if !enabled.contains(atlassianMcpServerName) { enabled.append(atlassianMcpServerName) } - } else { - env.removeValue(forKey: atlassianMcpAuthEnvKey) - enabled.removeAll { $0 == atlassianMcpServerName } - } - - if env.isEmpty { settings.removeValue(forKey: "env") } else { settings["env"] = env } - if enabled.isEmpty { - settings.removeValue(forKey: "enabledMcpjsonServers") - } else { - settings["enabledMcpjsonServers"] = enabled - } - - if settings.isEmpty { - try? FileManager.default.removeItem(atPath: settingsPath) - } else { - do { - try FileManager.default.createDirectory(atPath: claudeDir, withIntermediateDirectories: true) - let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: URL(fileURLWithPath: settingsPath)) - // The env block carries the resolved Basic credential, so restrict - // the file to owner-only — matching ConfigStore's 0600 on config.json. - try? FileManager.default.setAttributes( - [.posixPermissions: 0o600], ofItemAtPath: settingsPath) - } catch { - NSLog("[ClaudeHookConfigWriter] Failed to write MCP settings to %@: %@", - settingsPath, error.localizedDescription) - } - } - - // --- .mcp.json: the server definition (no secret — references ${env}) --- - var mcp: [String: Any] = [:] - if let data = FileManager.default.contents(atPath: mcpPath), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - mcp = parsed - } - var servers = mcp["mcpServers"] as? [String: Any] ?? [:] - - guard let resolved else { - // Teardown: remove only OUR server entry (its `${env}` reference was - // just cleared above), and preserve any user-authored servers. Mirror - // the env/enabledMcpjsonServers handling — only delete the file when - // nothing of ours or theirs remains. Leaving a dangling `atlassian` - // entry behind would warn on next launch (missing env var). - guard servers[atlassianMcpServerName] != nil else { return } - servers.removeValue(forKey: atlassianMcpServerName) - if servers.isEmpty { - mcp.removeValue(forKey: "mcpServers") - } else { - mcp["mcpServers"] = servers - } - if mcp.isEmpty { - try? FileManager.default.removeItem(atPath: mcpPath) - } else { - if let data = try? JSONSerialization.data(withJSONObject: mcp, options: [.prettyPrinted, .sortedKeys]) { - try? data.write(to: URL(fileURLWithPath: mcpPath)) - } - } - return - } - - servers[atlassianMcpServerName] = [ - "type": "http", - "url": resolved.endpoint, - "headers": ["Authorization": "${\(atlassianMcpAuthEnvKey)}"], - ] as [String: Any] - mcp["mcpServers"] = servers - - do { - let data = try JSONSerialization.data(withJSONObject: mcp, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: URL(fileURLWithPath: mcpPath)) - } catch { - NSLog("[ClaudeHookConfigWriter] Failed to write .mcp.json to %@: %@", - mcpPath, error.localizedDescription) - } - } - /// Remove our hook entries from a worktree's settings.local.json, preserving user settings. public func removeHookConfig(worktreePath: String) { let settingsPath = (worktreePath as NSString) diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift index 4a6c47f4..ea09a199 100644 --- a/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift @@ -55,9 +55,9 @@ public actor ClaudeLauncher { case .jira: lines.append("") if let key = Validation.jiraKey(from: url) { - lines.append("Fetch this work item via the **Atlassian MCP server** (pre-configured for this session): resolve your cloudId with `getAccessibleAtlassianResources`, then call `getJiraIssue` for key `\(key)`. Use the MCP tools — not `acli` — for any Jira create/assign/transition/comment as well.") + lines.append("Fetch this work item via the **`jira` MCP server** (available in this session): call `jira_get_issue` for key `\(key)`. Use the `jira_*` MCP tools — not `acli` — for any Jira create/assign/transition/comment as well.") } else { - lines.append("URL: \(url) — fetch it via the Atlassian MCP server (`getJiraIssue`).") + lines.append("URL: \(url) — fetch it via the `jira` MCP server (`jira_get_issue`).") } case .corveil, nil: lines.append("URL: \(url)") diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift deleted file mode 100644 index d26ec1e5..00000000 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookConfigWriterMCPTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import Testing -@testable import CrowClaude -@testable import CrowCore - -// MARK: - writeAtlassianMcpConfig (CROW-522) - -private func makeTempDir() -> String { - let dir = (NSTemporaryDirectory() as NSString) - .appendingPathComponent("crow-mcp-test-\(UUID().uuidString)") - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - return dir -} - -private func readJSON(_ path: String) -> [String: Any]? { - guard let data = FileManager.default.contents(atPath: path) else { return nil } - return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] -} - -@Test func writeAtlassianMcpConfigWritesServerEnvAndTrust() throws { - let dir = makeTempDir() - defer { try? FileManager.default.removeItem(atPath: dir) } - let resolved = AtlassianMCPResolver.Resolved( - endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic abc123") - - ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: dir, resolved: resolved) - - // .mcp.json registers the http server with an env-reference header (no secret). - let mcp = readJSON((dir as NSString).appendingPathComponent(".mcp.json")) - let server = (mcp?["mcpServers"] as? [String: Any])?["atlassian"] as? [String: Any] - #expect(server?["type"] as? String == "http") - #expect(server?["url"] as? String == "https://mcp.atlassian.com/v1/mcp") - let auth = (server?["headers"] as? [String: Any])?["Authorization"] as? String - #expect(auth == "${ATLASSIAN_MCP_AUTHORIZATION}") - - // settings.local.json carries the resolved credential + the pre-trust entry. - let settingsPath = (dir as NSString).appendingPathComponent(".claude/settings.local.json") - let settings = readJSON(settingsPath) - #expect((settings?["env"] as? [String: Any])?["ATLASSIAN_MCP_AUTHORIZATION"] as? String == "Basic abc123") - #expect((settings?["enabledMcpjsonServers"] as? [String]) == ["atlassian"]) - - // settings.local.json is owner-only (0600). - let attrs = try FileManager.default.attributesOfItem(atPath: settingsPath) - #expect((attrs[.posixPermissions] as? NSNumber)?.intValue == 0o600) -} - -@Test func writeAtlassianMcpConfigPreservesUserServerOnWrite() throws { - let dir = makeTempDir() - defer { try? FileManager.default.removeItem(atPath: dir) } - let mcpPath = (dir as NSString).appendingPathComponent(".mcp.json") - let userFile: [String: Any] = ["mcpServers": ["my-server": ["type": "http", "url": "https://x"]]] - try JSONSerialization.data(withJSONObject: userFile).write(to: URL(fileURLWithPath: mcpPath)) - - ClaudeHookConfigWriter.writeAtlassianMcpConfig( - dirPath: dir, - resolved: .init(endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic z")) - - let servers = readJSON(mcpPath)?["mcpServers"] as? [String: Any] - #expect(servers?["atlassian"] != nil) - #expect(servers?["my-server"] != nil) // user's server untouched -} - -@Test func teardownRemovesOnlyOurServerAndKeepsUserServer() throws { - let dir = makeTempDir() - defer { try? FileManager.default.removeItem(atPath: dir) } - let mcpPath = (dir as NSString).appendingPathComponent(".mcp.json") - - // First write ours alongside a user-authored server. - let userFile: [String: Any] = ["mcpServers": ["my-server": ["type": "http", "url": "https://x"]]] - try JSONSerialization.data(withJSONObject: userFile).write(to: URL(fileURLWithPath: mcpPath)) - ClaudeHookConfigWriter.writeAtlassianMcpConfig( - dirPath: dir, - resolved: .init(endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic z")) - - // Teardown (resolved nil) must drop ONLY our entry — the dangling-server bug. - ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: dir, resolved: nil) - - let servers = readJSON(mcpPath)?["mcpServers"] as? [String: Any] - #expect(servers?["atlassian"] == nil, "our server entry must be removed on teardown") - #expect(servers?["my-server"] != nil, "user's server must survive teardown") - - // And our env var must be gone (so nothing references a missing secret). - let settings = readJSON((dir as NSString).appendingPathComponent(".claude/settings.local.json")) - #expect((settings?["env"] as? [String: Any])?["ATLASSIAN_MCP_AUTHORIZATION"] == nil) - #expect((settings?["enabledMcpjsonServers"] as? [String])?.contains("atlassian") != true) -} - -@Test func teardownDeletesMcpJsonWhenOnlyOursRemained() throws { - let dir = makeTempDir() - defer { try? FileManager.default.removeItem(atPath: dir) } - let mcpPath = (dir as NSString).appendingPathComponent(".mcp.json") - - ClaudeHookConfigWriter.writeAtlassianMcpConfig( - dirPath: dir, - resolved: .init(endpoint: "https://mcp.atlassian.com/v1/mcp", authorization: "Basic z")) - #expect(FileManager.default.fileExists(atPath: mcpPath)) - - ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: dir, resolved: nil) - #expect(!FileManager.default.fileExists(atPath: mcpPath), - "a .mcp.json that held only our server should be removed entirely") -} diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift index 0f85511c..3632552a 100644 --- a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLauncherTests.swift @@ -57,7 +57,7 @@ import Testing @Test func generatePromptWithJiraTaskAndGitHubCode() async { // Cross-backend session (ADR 0005): Jira task + GitHub code. The ticket is - // fetched via the Atlassian MCP server (CROW-522), but the PR step still + // fetched via the `jira` MCP server (CROW-528), but the PR step still // uses `gh`. let launcher = ClaudeLauncher() let session = Session(name: "test-session", ticketNumber: 7) @@ -70,8 +70,8 @@ import Testing codeProvider: .github ) - // Ticket step routes through the Atlassian MCP with the extracted key — not acli. - #expect(prompt.contains("getJiraIssue")) + // Ticket step routes through the `jira` MCP with the extracted key — not acli. + #expect(prompt.contains("jira_get_issue")) #expect(prompt.contains("PROJ-7")) #expect(!prompt.contains("acli jira workitem")) #expect(!prompt.contains("gh issue view")) diff --git a/Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift b/Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift deleted file mode 100644 index 26eb8673..00000000 --- a/Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -/// Resolves an ``AtlassianMCPConfig`` into the launch-ready value for the -/// Atlassian Remote MCP Server's `Authorization` header (CROW-522). -/// -/// The token (`tokenRef`) may be a plaintext string or an `op://…` 1Password -/// reference; references are resolved at launch via the `op` CLI so the secret -/// never lands at rest in `config.json`. The resolved output is an HTTP Basic -/// credential — `Basic base64("\(email):\(token)")` — matching Atlassian's -/// personal-API-token auth for the Rovo MCP Server. -/// -/// Resolved secret values are never logged. -public enum AtlassianMCPResolver { - /// Launch-ready values for injecting the Atlassian MCP server. - public struct Resolved: Equatable, Sendable { - /// The MCP endpoint URL (e.g. `https://mcp.atlassian.com/v1/mcp`). - public var endpoint: String - /// The full `Authorization` header value (e.g. `Basic ZW1haWw6dG9rZW4=`). - public var authorization: String - - public init(endpoint: String, authorization: String) { - self.endpoint = endpoint - self.authorization = authorization - } - } - - /// Resolve a config's token (resolving an `op://…` reference) and build the - /// Basic-auth `Authorization` header. Returns `nil` for an empty config, or - /// when the email/token is missing or the secret reference fails to resolve - /// (caller should then *not* inject the server, rather than inject a broken - /// credential). - /// - /// - Parameter resolveSecret: Injected for testability; defaults to `op read`. - public static func resolve( - _ config: AtlassianMCPConfig, - resolveSecret: (String) -> String? = GatewayResolver.opRead - ) -> Resolved? { - guard !config.isEmpty else { return nil } - - let email = config.email.trimmingCharacters(in: .whitespaces) - guard !email.isEmpty else { - NSLog("[AtlassianMCPResolver] No account email set; skipping MCP injection") - return nil - } - - let tokenRef = config.tokenRef.trimmingCharacters(in: .whitespaces) - guard !tokenRef.isEmpty else { - NSLog("[AtlassianMCPResolver] No API token set; skipping MCP injection") - return nil - } - - let token: String - if tokenRef.hasPrefix("op://") { - guard let secret = resolveSecret(tokenRef) else { - NSLog("[AtlassianMCPResolver] Failed to resolve API token reference (op read failed or op not signed in); skipping MCP injection") - return nil - } - token = secret - } else { - token = tokenRef - } - - let credential = "\(email):\(token)" - guard let encoded = credential.data(using: .utf8)?.base64EncodedString() else { - return nil - } - return Resolved(endpoint: config.resolvedEndpoint, authorization: "Basic \(encoded)") - } -} diff --git a/Packages/CrowCore/Sources/CrowCore/JiraCredentialResolver.swift b/Packages/CrowCore/Sources/CrowCore/JiraCredentialResolver.swift new file mode 100644 index 00000000..d574569c --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/JiraCredentialResolver.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Resolves a ``JiraCredential`` into the HTTP Basic `Authorization` header used +/// by the in-app Jira REST status fetch (CROW-528). +/// +/// The token (`tokenRef`) may be a plaintext string or an `op://…` 1Password +/// reference; references are resolved via the `op` CLI so the secret never lands +/// at rest in `config.json`. The output is an HTTP Basic credential — +/// `Basic base64("\(username):\(token)")` — matching Jira's personal-API-token +/// auth. +/// +/// Resolved secret values are never logged. +public enum JiraCredentialResolver { + /// Resolve a credential's token (resolving an `op://…` reference) and build + /// the Basic-auth `Authorization` header value. Returns `nil` for an empty + /// credential, or when the username/token is missing or the secret reference + /// fails to resolve. + /// + /// - Parameter resolveSecret: Injected for testability; defaults to `op read`. + public static func resolve( + _ credential: JiraCredential, + resolveSecret: (String) -> String? = GatewayResolver.opRead + ) -> String? { + guard !credential.isEmpty else { return nil } + + let username = credential.username.trimmingCharacters(in: .whitespaces) + guard !username.isEmpty else { + NSLog("[JiraCredentialResolver] No username set; cannot build Jira auth header") + return nil + } + + let tokenRef = credential.tokenRef.trimmingCharacters(in: .whitespaces) + guard !tokenRef.isEmpty else { + NSLog("[JiraCredentialResolver] No API token set; cannot build Jira auth header") + return nil + } + + let token: String + if tokenRef.hasPrefix("op://") { + guard let secret = resolveSecret(tokenRef) else { + NSLog("[JiraCredentialResolver] Failed to resolve API token reference (op read failed or op not signed in)") + return nil + } + token = secret + } else { + token = tokenRef + } + + let credentialString = "\(username):\(token)" + guard let encoded = credentialString.data(using: .utf8)?.base64EncodedString() else { + return nil + } + return "Basic \(encoded)" + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift index 5f8ebce9..7c29e5ad 100644 --- a/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift +++ b/Packages/CrowCore/Sources/CrowCore/JiraStatusFetcher.swift @@ -4,11 +4,11 @@ import Foundation /// Settings status-mapping UI can offer live names instead of free-text (#523). /// /// `acli` has no transition/status *list* command and the in-app UI can't reach -/// the agent-side Atlassian MCP, so this calls the Jira Cloud REST endpoint +/// the session-side `jira` MCP, so this calls the Jira Cloud REST endpoint /// `GET /rest/api/3/project/{projectKey}/statuses` directly, authenticated with -/// the same Atlassian email + API token used for the MCP server (HTTP Basic). -/// That endpoint returns every status across the project's issue types — no -/// sample issue needed. +/// the Jira username + API token from Settings → Automation (HTTP Basic). That +/// endpoint returns every status across the project's issue types — no sample +/// issue needed. public enum JiraStatusFetcher { public enum FetchError: Error, Equatable { case badSite @@ -53,7 +53,7 @@ public enum JiraStatusFetcher { /// Fetch the distinct status names for `projectKey` on `site`, authenticated /// with `authorization` (a full header value, e.g. `Basic …` from - /// ``AtlassianMCPResolver``). Injectable transport for testing. + /// ``JiraCredentialResolver``). Injectable transport for testing. public static func fetchStatusNames( site: String, projectKey: String, diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 5cc63a06..1add73d7 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -65,15 +65,13 @@ public struct AppConfig: Codable, Sendable, Equatable { /// global `~/.zshrc` export doesn't bleed in). Per-workspace `gateway` blocks /// apply to non-Manager sessions only (CROW-402). public var managerGateway: WorkspaceGateway? - /// Optional Atlassian Remote MCP Server credential, shared org-wide (one - /// Atlassian account). When set and a session's task provider is Jira — or - /// for the Manager/cron sessions — Crow injects the Atlassian MCP server - /// into the launched session's `.mcp.json` and pre-trusts it, replacing the - /// `acli` agent flow for create/assign/transition/fetch (CROW-522). The API - /// token is stored as an `op://` reference (resolved at launch) so it never - /// lands at rest in `config.json`. When nil, no MCP is injected and Jira - /// sessions fall back to `acli`. - public var atlassianMCP: AtlassianMCPConfig? + /// Optional Jira REST credential, shared org-wide (one Jira account), used + /// only by the in-app status fetch (the #523 workspace status-map dropdown). + /// Claude Code sessions get Jira via the global `jira` MCP in `~/.claude.json`, + /// so Crow no longer injects any MCP (CROW-528). The API token is stored as an + /// `op://` reference (resolved on demand) so it never lands at rest in + /// `config.json`. When nil, the "Fetch from Jira" status button is disabled. + public var jiraCredential: JiraCredential? /// Effective review-exclude patterns: the global `defaults.excludeReviewRepos` /// unioned with every workspace's per-workspace `excludeReviewRepos`. A repo @@ -102,7 +100,7 @@ public struct AppConfig: Codable, Sendable, Equatable { defaultAgentKind: AgentKind = .claudeCode, agentsByKind: [String: AgentKind] = [:], managerGateway: WorkspaceGateway? = nil, - atlassianMCP: AtlassianMCPConfig? = nil + jiraCredential: JiraCredential? = nil ) { self.workspaces = workspaces self.defaults = defaults @@ -122,7 +120,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.defaultAgentKind = defaultAgentKind self.agentsByKind = agentsByKind self.managerGateway = managerGateway - self.atlassianMCP = atlassianMCP + self.jiraCredential = jiraCredential } public init(from decoder: Decoder) throws { @@ -145,11 +143,44 @@ public struct AppConfig: Codable, Sendable, Equatable { defaultAgentKind = try container.decodeIfPresent(AgentKind.self, forKey: .defaultAgentKind) ?? .claudeCode agentsByKind = try container.decodeIfPresent([String: AgentKind].self, forKey: .agentsByKind) ?? [:] managerGateway = try container.decodeIfPresent(WorkspaceGateway.self, forKey: .managerGateway) - atlassianMCP = try container.decodeIfPresent(AtlassianMCPConfig.self, forKey: .atlassianMCP) + if let cred = try container.decodeIfPresent(JiraCredential.self, forKey: .jiraCredential) { + jiraCredential = cred + } else { + // Backward-compat: migrate the pre-CROW-528 `atlassianMCP` block + // (email/tokenRef) into the new Jira REST credential. Decoded from a + // separate container so the legacy key stays out of `CodingKeys` + // (which also drives the synthesized `encode(to:)`). + let legacyContainer = try decoder.container(keyedBy: LegacyCodingKeys.self) + if let legacy = try legacyContainer.decodeIfPresent(LegacyAtlassianMCP.self, forKey: .atlassianMCP), + !legacy.email.isEmpty || !legacy.tokenRef.isEmpty { + jiraCredential = JiraCredential(username: legacy.email, tokenRef: legacy.tokenRef) + } else { + jiraCredential = nil + } + } + } + + /// Pre-CROW-528 shape of the now-removed `atlassianMCP` config, decoded only + /// to migrate an existing `config.json` forward to `jiraCredential`. + private struct LegacyAtlassianMCP: Decodable { + var email: String = "" + var tokenRef: String = "" + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + email = try c.decodeIfPresent(String.self, forKey: .email) ?? "" + tokenRef = try c.decodeIfPresent(String.self, forKey: .tokenRef) ?? "" + } + enum CodingKeys: String, CodingKey { case email, tokenRef } + } + + /// Decode-only keys for legacy/migrated fields that no longer have a stored + /// property (so they must stay out of `CodingKeys`, which drives encoding). + private enum LegacyCodingKeys: String, CodingKey { + case atlassianMCP } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, jobsAutoPermissionMode, telemetry, autoRespond, attributionTrailers, autoMergeWatcherEnabled, autoCreateWatcherEnabled, autoRebaseWatcherEnabled, cleanup, jobs, defaultAgentKind, agentsByKind, managerGateway, atlassianMCP + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, jobsAutoPermissionMode, telemetry, autoRespond, attributionTrailers, autoMergeWatcherEnabled, autoCreateWatcherEnabled, autoRebaseWatcherEnabled, cleanup, jobs, defaultAgentKind, agentsByKind, managerGateway, jiraCredential } /// Resolve the agent that should drive a newly-created session of the @@ -242,59 +273,45 @@ extension WorkspaceGateway { } } -/// Atlassian Remote MCP Server credential (CROW-522). Drives the agent-side Jira -/// flow (create-with-assignee, assign, transition, fetch, link) via the official -/// MCP server instead of `acli`, in launched sessions, the Manager, and cron jobs. -/// -/// Auth is a **personal API token** sent as HTTP Basic: the launch-time resolver -/// builds `Authorization: Basic base64("\(email):\(token)")`. `tokenRef` is an -/// `op://…` 1Password reference (resolved at launch via `op read`) so the token -/// never lands at rest in `config.json`; a non-`op://` value is treated as a -/// plaintext token (stored in `config.json`, so warn in the UI). +/// Jira REST credential used only by the in-app status fetch (CROW-528). The +/// Crow app process calls Jira's REST API directly (e.g. the #523 workspace +/// status-map dropdown via ``JiraStatusFetcher``); it cannot use the `jira` MCP, +/// which only serves Claude Code sessions. Those sessions instead inherit the +/// global `jira` MCP server from `~/.claude.json`, so Crow no longer injects or +/// provisions any Jira MCP itself. /// -/// Note: the Atlassian org admin must first **enable API-token auth for the Rovo -/// MCP Server**, otherwise the headless calls 401. -public struct AtlassianMCPConfig: Codable, Sendable, Equatable { - /// The remote MCP endpoint. Defaults to Atlassian's recommended `/v1/mcp`. - public var endpoint: String - /// The Atlassian account email used for HTTP Basic auth. - public var email: String +/// Auth is a **personal API token** sent as HTTP Basic: the resolver builds +/// `Authorization: Basic base64("\(username):\(token)")`. `tokenRef` is an +/// `op://…` 1Password reference (resolved via `op read`) so the token never +/// lands at rest in `config.json`; a non-`op://` value is treated as a plaintext +/// token (stored in `config.json`, so warn in the UI). The Jira site comes from +/// the workspace's `jiraSite`, so no endpoint is stored here. +public struct JiraCredential: Codable, Sendable, Equatable { + /// The Jira account email/username used for HTTP Basic auth (`JIRA_USERNAME`). + public var username: String /// The API token, as an `op://…` reference (preferred) or plaintext. public var tokenRef: String - /// Atlassian's recommended streamable-HTTP endpoint (the legacy `/v1/sse` - /// is deprecated after 2026-06-30). - public static let defaultEndpoint = "https://mcp.atlassian.com/v1/mcp" - - public init(endpoint: String = AtlassianMCPConfig.defaultEndpoint, email: String, tokenRef: String) { - self.endpoint = endpoint - self.email = email + public init(username: String, tokenRef: String) { + self.username = username self.tokenRef = tokenRef } - /// Whether this config has enough to inject a server. Both an email and a - /// token are required for Basic auth; a blank endpoint falls back to the - /// default at resolve time. + /// Whether this credential has enough to authenticate. Both a username and a + /// token are required for Basic auth. public var isEmpty: Bool { - email.trimmingCharacters(in: .whitespaces).isEmpty + username.trimmingCharacters(in: .whitespaces).isEmpty && tokenRef.trimmingCharacters(in: .whitespaces).isEmpty } - /// The effective endpoint, falling back to the default when blank. - public var resolvedEndpoint: String { - let trimmed = endpoint.trimmingCharacters(in: .whitespaces) - return trimmed.isEmpty ? Self.defaultEndpoint : trimmed - } - public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? Self.defaultEndpoint - email = try container.decodeIfPresent(String.self, forKey: .email) ?? "" + username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" tokenRef = try container.decodeIfPresent(String.self, forKey: .tokenRef) ?? "" } private enum CodingKeys: String, CodingKey { - case endpoint, email, tokenRef + case username, tokenRef } } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift deleted file mode 100644 index 64b0946d..00000000 --- a/Packages/CrowCore/Tests/CrowCoreTests/AtlassianMCPResolverTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Testing -@testable import CrowCore - -@Test func atlassianMCPResolverBuildsBasicAuthFromPlaintextToken() throws { - let config = AtlassianMCPConfig(email: "me@example.com", tokenRef: "tok123") - // resolveSecret must NOT be consulted for a plaintext token. - let resolved = AtlassianMCPResolver.resolve(config) { _ in - Issue.record("op read should not be called for a plaintext token") - return nil - } - let expected = "Basic " + Data("me@example.com:tok123".utf8).base64EncodedString() - #expect(resolved?.authorization == expected) - #expect(resolved?.endpoint == AtlassianMCPConfig.defaultEndpoint) -} - -@Test func atlassianMCPResolverResolvesOpReference() throws { - let config = AtlassianMCPConfig( - endpoint: "https://mcp.atlassian.com/v1/mcp", - email: "me@example.com", - tokenRef: "op://Private/Atlassian/api_token") - var requestedRef: String? - let resolved = AtlassianMCPResolver.resolve(config) { ref in - requestedRef = ref - return "secretToken" - } - #expect(requestedRef == "op://Private/Atlassian/api_token") - let expected = "Basic " + Data("me@example.com:secretToken".utf8).base64EncodedString() - #expect(resolved?.authorization == expected) -} - -@Test func atlassianMCPResolverReturnsNilForEmptyConfig() throws { - let empty = AtlassianMCPConfig(email: "", tokenRef: "") - #expect(empty.isEmpty) - #expect(AtlassianMCPResolver.resolve(empty) { _ in "unused" } == nil) -} - -@Test func atlassianMCPResolverReturnsNilWhenSecretResolutionFails() throws { - let config = AtlassianMCPConfig(email: "me@example.com", tokenRef: "op://Vault/Item/missing") - // A failed op read must NOT inject a broken credential. - #expect(AtlassianMCPResolver.resolve(config) { _ in nil } == nil) -} - -@Test func atlassianMCPResolverReturnsNilWhenHalfConfigured() throws { - // Email without a token (or vice versa) can't form Basic auth. - let emailOnly = AtlassianMCPConfig(email: "me@example.com", tokenRef: "") - #expect(AtlassianMCPResolver.resolve(emailOnly) { _ in "unused" } == nil) - let tokenOnly = AtlassianMCPConfig(email: "", tokenRef: "tok") - #expect(AtlassianMCPResolver.resolve(tokenOnly) { _ in "unused" } == nil) -} - -@Test func atlassianMCPConfigDefaultsBlankEndpointToAtlassian() throws { - let config = AtlassianMCPConfig(endpoint: " ", email: "me@example.com", tokenRef: "tok") - #expect(config.resolvedEndpoint == AtlassianMCPConfig.defaultEndpoint) - #expect(AtlassianMCPResolver.resolve(config) { _ in nil }?.endpoint == AtlassianMCPConfig.defaultEndpoint) -} - -@Test func appConfigRoundTripsAtlassianMCP() throws { - let config = AppConfig(atlassianMCP: AtlassianMCPConfig( - email: "me@example.com", tokenRef: "op://Private/Atlassian/api_token")) - let data = try JSONEncoder().encode(config) - let decoded = try JSONDecoder().decode(AppConfig.self, from: data) - #expect(decoded.atlassianMCP?.email == "me@example.com") - #expect(decoded.atlassianMCP?.tokenRef == "op://Private/Atlassian/api_token") - #expect(decoded.atlassianMCP?.endpoint == AtlassianMCPConfig.defaultEndpoint) -} - -@Test func appConfigDecodesMissingAtlassianMCPAsNil() throws { - let json = "{}".data(using: .utf8)! - let decoded = try JSONDecoder().decode(AppConfig.self, from: json) - #expect(decoded.atlassianMCP == nil) -} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/JiraCredentialResolverTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/JiraCredentialResolverTests.swift new file mode 100644 index 00000000..0a5378e1 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/JiraCredentialResolverTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func jiraCredentialResolverBuildsBasicAuthFromPlaintextToken() throws { + let cred = JiraCredential(username: "me@example.com", tokenRef: "tok123") + // resolveSecret must NOT be consulted for a plaintext token. + let authorization = JiraCredentialResolver.resolve(cred) { _ in + Issue.record("op read should not be called for a plaintext token") + return nil + } + let expected = "Basic " + Data("me@example.com:tok123".utf8).base64EncodedString() + #expect(authorization == expected) +} + +@Test func jiraCredentialResolverResolvesOpReference() throws { + let cred = JiraCredential( + username: "me@example.com", + tokenRef: "op://Private/Jira/api_token") + var requestedRef: String? + let authorization = JiraCredentialResolver.resolve(cred) { ref in + requestedRef = ref + return "secretToken" + } + #expect(requestedRef == "op://Private/Jira/api_token") + let expected = "Basic " + Data("me@example.com:secretToken".utf8).base64EncodedString() + #expect(authorization == expected) +} + +@Test func jiraCredentialResolverReturnsNilForEmptyCredential() throws { + let empty = JiraCredential(username: "", tokenRef: "") + #expect(empty.isEmpty) + #expect(JiraCredentialResolver.resolve(empty) { _ in "unused" } == nil) +} + +@Test func jiraCredentialResolverReturnsNilWhenSecretResolutionFails() throws { + let cred = JiraCredential(username: "me@example.com", tokenRef: "op://Vault/Item/missing") + // A failed op read must NOT produce a broken credential. + #expect(JiraCredentialResolver.resolve(cred) { _ in nil } == nil) +} + +@Test func jiraCredentialResolverReturnsNilWhenHalfConfigured() throws { + // Username without a token (or vice versa) can't form Basic auth. + let usernameOnly = JiraCredential(username: "me@example.com", tokenRef: "") + #expect(JiraCredentialResolver.resolve(usernameOnly) { _ in "unused" } == nil) + let tokenOnly = JiraCredential(username: "", tokenRef: "tok") + #expect(JiraCredentialResolver.resolve(tokenOnly) { _ in "unused" } == nil) +} + +@Test func appConfigRoundTripsJiraCredential() throws { + let config = AppConfig(jiraCredential: JiraCredential( + username: "me@example.com", tokenRef: "op://Private/Jira/api_token")) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.jiraCredential?.username == "me@example.com") + #expect(decoded.jiraCredential?.tokenRef == "op://Private/Jira/api_token") +} + +@Test func appConfigDecodesMissingJiraCredentialAsNil() throws { + let json = "{}".data(using: .utf8)! + let decoded = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(decoded.jiraCredential == nil) +} + +@Test func appConfigMigratesLegacyAtlassianMCP() throws { + // A pre-CROW-528 config.json with the old `atlassianMCP` block (email + + // tokenRef) must migrate forward to `jiraCredential` on decode. + let json = """ + { "atlassianMCP": { "endpoint": "https://mcp.atlassian.com/v1/mcp", + "email": "legacy@example.com", + "tokenRef": "op://Private/Atlassian/api_token" } } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(decoded.jiraCredential?.username == "legacy@example.com") + #expect(decoded.jiraCredential?.tokenRef == "op://Private/Atlassian/api_token") +} diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index 03300d37..4865239f 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -9,7 +9,7 @@ public struct AutomationSettingsView: View { @Binding var remoteControlEnabled: Bool @Binding var managerAutoPermissionMode: Bool @Binding var managerGateway: WorkspaceGateway? - @Binding var atlassianMCP: AtlassianMCPConfig? + @Binding var jiraCredential: JiraCredential? @Binding var autoRespond: AutoRespondSettings @Binding var attributionTrailers: Bool @Binding var autoMergeWatcherEnabled: Bool @@ -20,16 +20,15 @@ public struct AutomationSettingsView: View { @State private var managerGatewayBaseURL: String @State private var managerGatewayHeadersText: String - @State private var atlassianMCPEndpoint: String - @State private var atlassianMCPEmail: String - @State private var atlassianMCPToken: String + @State private var jiraCredentialUsername: String + @State private var jiraCredentialToken: String public init( defaults: Binding, remoteControlEnabled: Binding, managerAutoPermissionMode: Binding, managerGateway: Binding, - atlassianMCP: Binding, + jiraCredential: Binding, autoRespond: Binding, attributionTrailers: Binding, autoMergeWatcherEnabled: Binding, @@ -41,7 +40,7 @@ public struct AutomationSettingsView: View { self._remoteControlEnabled = remoteControlEnabled self._managerAutoPermissionMode = managerAutoPermissionMode self._managerGateway = managerGateway - self._atlassianMCP = atlassianMCP + self._jiraCredential = jiraCredential self._autoRespond = autoRespond self._attributionTrailers = attributionTrailers self._autoMergeWatcherEnabled = autoMergeWatcherEnabled @@ -52,9 +51,8 @@ public struct AutomationSettingsView: View { self._managerGatewayHeadersText = State(initialValue: managerGateway.wrappedValue.map { WorkspaceGateway.headerLines(from: $0.customHeaders) } ?? "") - self._atlassianMCPEndpoint = State(initialValue: atlassianMCP.wrappedValue?.endpoint ?? AtlassianMCPConfig.defaultEndpoint) - self._atlassianMCPEmail = State(initialValue: atlassianMCP.wrappedValue?.email ?? "") - self._atlassianMCPToken = State(initialValue: atlassianMCP.wrappedValue?.tokenRef ?? "") + self._jiraCredentialUsername = State(initialValue: jiraCredential.wrappedValue?.username ?? "") + self._jiraCredentialToken = State(initialValue: jiraCredential.wrappedValue?.tokenRef ?? "") } /// Reject a half-filled Manager gateway (base URL xor headers), matching the @@ -82,30 +80,26 @@ public struct AutomationSettingsView: View { onSave?() } - /// Reject a half-filled Atlassian MCP block (email xor token). Basic auth - /// needs both; the endpoint falls back to the default when blank. - private var atlassianMCPValidationError: String? { - let hasEmail = !atlassianMCPEmail.trimmingCharacters(in: .whitespaces).isEmpty - let hasToken = !atlassianMCPToken.trimmingCharacters(in: .whitespaces).isEmpty - if hasEmail && !hasToken { return "Add an API token, or clear the account email." } - if hasToken && !hasEmail { return "Add the account email, or clear the API token." } + /// Reject a half-filled Jira credential (username xor token). Basic auth + /// needs both. + private var jiraCredentialValidationError: String? { + let hasUsername = !jiraCredentialUsername.trimmingCharacters(in: .whitespaces).isEmpty + let hasToken = !jiraCredentialToken.trimmingCharacters(in: .whitespaces).isEmpty + if hasUsername && !hasToken { return "Add an API token, or clear the username." } + if hasToken && !hasUsername { return "Add the username, or clear the API token." } return nil } - /// Push the editor fields back into the `atlassianMCP` binding (nil when both - /// email and token are empty, or while half-filled so an invalid block isn't - /// persisted). - private func commitAtlassianMCP() { - let email = atlassianMCPEmail.trimmingCharacters(in: .whitespaces) - let token = atlassianMCPToken.trimmingCharacters(in: .whitespaces) - let endpoint = atlassianMCPEndpoint.trimmingCharacters(in: .whitespaces) - if email.isEmpty && token.isEmpty { - atlassianMCP = nil - } else if atlassianMCPValidationError == nil { - atlassianMCP = AtlassianMCPConfig( - endpoint: endpoint.isEmpty ? AtlassianMCPConfig.defaultEndpoint : endpoint, - email: email, - tokenRef: token) + /// Push the editor fields back into the `jiraCredential` binding (nil when both + /// username and token are empty, or while half-filled so an invalid credential + /// isn't persisted). + private func commitJiraCredential() { + let username = jiraCredentialUsername.trimmingCharacters(in: .whitespaces) + let token = jiraCredentialToken.trimmingCharacters(in: .whitespaces) + if username.isEmpty && token.isEmpty { + jiraCredential = nil + } else if jiraCredentialValidationError == nil { + jiraCredential = JiraCredential(username: username, tokenRef: token) } else { return // half-filled — don't persist until valid } @@ -192,32 +186,27 @@ public struct AutomationSettingsView: View { .foregroundStyle(.secondary) } - Section("Atlassian MCP (Jira)") { - TextField("Endpoint", text: $atlassianMCPEndpoint) + Section("Jira (status fetch)") { + TextField("Username (JIRA_USERNAME, e.g. you@corp.com)", text: $jiraCredentialUsername) .textFieldStyle(.roundedBorder) .autocorrectionDisabled() - .onChange(of: atlassianMCPEndpoint) { _, _ in commitAtlassianMCP() } + .onChange(of: jiraCredentialUsername) { _, _ in commitJiraCredential() } - TextField("Account email", text: $atlassianMCPEmail) + TextField("API token (op:// reference or plaintext)", text: $jiraCredentialToken) .textFieldStyle(.roundedBorder) .autocorrectionDisabled() - .onChange(of: atlassianMCPEmail) { _, _ in commitAtlassianMCP() } - - TextField("API token (op:// reference or plaintext)", text: $atlassianMCPToken) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - .onChange(of: atlassianMCPToken) { _, _ in commitAtlassianMCP() } - Text("Personal API token from id.atlassian.com, sent as HTTP Basic (base64 of email:token). A value starting with `op://` is resolved at launch via the 1Password CLI and kept out of config.json (the resolved Authorization header is cached owner-only in settings.local.json); any other value is stored in plain text in config.json — prefer an `op://` reference.") + .onChange(of: jiraCredentialToken) { _, _ in commitJiraCredential() } + Text("Personal API token from id.atlassian.com, sent as HTTP Basic (base64 of username:token). A value starting with `op://` is resolved on demand via the 1Password CLI and kept out of config.json; any other value is stored in plain text in config.json — prefer an `op://` reference.") .font(.caption) .foregroundStyle(.secondary) - if let error = atlassianMCPValidationError { + if let error = jiraCredentialValidationError { Text(error) .font(.caption) .foregroundStyle(.red) } - Text("When set, Crow injects the official Atlassian Remote MCP Server into launched Jira-task sessions (and the Manager + cron jobs) so they create/assign/transition/fetch work items via MCP instead of acli. Requires an Atlassian org admin to first enable API-token auth for the Rovo MCP Server. Takes effect on next session launch.") + Text("Used only by the in-app \"Fetch from Jira\" button under Workspaces, which queries a project's workflow statuses for the status map (the app can't use the MCP). Claude Code sessions get Jira via the global `jira` MCP server in ~/.claude.json, so nothing is injected here.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 8c47b3b3..123784ec 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -65,23 +65,23 @@ public struct SettingsView: View { } /// Fetch a Jira project's live workflow status names for the workspace - /// status-mapping dropdown (#523), authenticating with the Atlassian MCP - /// credential from Settings → Automation. Runs off the main actor (resolving - /// an `op://` token shells out) and maps failures to user-facing copy. + /// status-mapping dropdown (#523), authenticating with the Jira credential + /// from Settings → Automation. Runs off the main actor (resolving an `op://` + /// token shells out) and maps failures to user-facing copy. private func fetchJiraStatuses(site: String, projectKey: String) async -> JiraStatusFetchResult { - let atlassian = config.atlassianMCP + let credential = config.jiraCredential return await Task.detached { () -> JiraStatusFetchResult in - guard let cfg = atlassian, !cfg.isEmpty, - let resolved = AtlassianMCPResolver.resolve(cfg) else { - return .failure("Add an Atlassian MCP credential in Settings → Automation first.") + guard let cred = credential, !cred.isEmpty, + let authorization = JiraCredentialResolver.resolve(cred) else { + return .failure("Add a Jira credential in Settings → Automation first.") } switch await JiraStatusFetcher.fetchStatusNames( - site: site, projectKey: projectKey, authorization: resolved.authorization + site: site, projectKey: projectKey, authorization: authorization ) { case .success(let names): return .success(names) case .failure(.badSite): - return .failure("Invalid Atlassian site or project key.") + return .failure("Invalid Jira site or project key.") case .failure(.http(let code)): return .failure("Jira returned HTTP \(code). Check the credential and project key.") case .failure(.transport(let message)): @@ -101,7 +101,7 @@ public struct SettingsView: View { remoteControlEnabled: $config.remoteControlEnabled, managerAutoPermissionMode: $config.managerAutoPermissionMode, managerGateway: $config.managerGateway, - atlassianMCP: $config.atlassianMCP, + jiraCredential: $config.jiraCredential, autoRespond: $config.autoRespond, attributionTrailers: $config.attributionTrailers, autoMergeWatcherEnabled: $config.autoMergeWatcherEnabled, diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index 0a570f05..900fbdea 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -182,7 +182,7 @@ public struct WorkspaceFormView: View { } if jiraSelected { - TextField("Atlassian Site (e.g., acme.atlassian.net)", text: $jiraSite) + TextField("Jira Site (e.g., acme.atlassian.net)", text: $jiraSite) .textFieldStyle(.roundedBorder) TextField("Project Key (e.g., PROJ)", text: $jiraProjectKey) .textFieldStyle(.roundedBorder) @@ -249,7 +249,7 @@ public struct WorkspaceFormView: View { } Text(canFetchStatuses ? "Populates a dropdown on each row from this project's live workflow." - : "Set the Atlassian Site + Project Key above and an Atlassian MCP credential in Settings → Automation to fetch live statuses.") + : "Set the Jira Site + Project Key above and a Jira credential in Settings → Automation to fetch live statuses.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Resources/crow-batch-workspace-SKILL.md.template b/Resources/crow-batch-workspace-SKILL.md.template index dc481539..5a86f114 100644 --- a/Resources/crow-batch-workspace-SKILL.md.template +++ b/Resources/crow-batch-workspace-SKILL.md.template @@ -79,7 +79,7 @@ For each workspace spec, perform the same resolution as `/crow-workspace`: 5. **Fetch ticket** (provider-specific, with `dangerouslyDisableSandbox: true`): - GitHub: `gh issue view {url} --json title,body,labels` - GitLab: `GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments` - - Jira (task-only): fetch via the `atlassian` MCP server (`getAccessibleAtlassianResources` → `getJiraIssue {key}`); title is the work item summary. (Fallback when MCP unconfigured: `acli jira workitem view {key} --json`, title at `.fields.summary`.) + - Jira (task-only): fetch via the `jira` MCP server (`jira_get_issue {key}`); title is the work item `summary`. 6. **Check for existing PR**: `gh pr list --repo {owner}/{repo} --search "{issue_number}" --state open --json number,title,headRefName,url --limit 5` (with `dangerouslyDisableSandbox: true`). For a Jira-task session this runs against the workspace's configured GitHub/GitLab code repo, not Jira. 7. **Generate names**: slug, branch, worktree path, session name (following `/crow-workspace` naming conventions). **Jira:** resolve `{ticket_number}` as the **numeric suffix** of the key (`MAXX-6859` → `6859`); `{ticket_url}` is the full `…/browse/{key}` URL; the slug uses the lowercased full key (`{repo}-maxx-6859-{slug}`). 8. **Compose prompt**: Use the First Prompt Template from `/crow-workspace` diff --git a/Resources/crow-create-ticket-SKILL.md.template b/Resources/crow-create-ticket-SKILL.md.template index 863cfa0f..0bb33683 100644 --- a/Resources/crow-create-ticket-SKILL.md.template +++ b/Resources/crow-create-ticket-SKILL.md.template @@ -1,11 +1,11 @@ # Crow Create Ticket Create a new ticket (GitHub issue via `gh`, GitLab issue via `glab`, or Jira work item -via the **Atlassian MCP server**) for a repo/project in the current Crow workspace, +via the **`jira` MCP server**) for a repo/project in the current Crow workspace, assigned to the invoking user and labeled `crow:auto`. For GitHub/GitLab, Crow's auto-pickup queue then implements it. (Jira's auto-pickup isn't wired — the label is for parity/board visibility — but the work item is created **with an assignee in one -step**, which `acli` could never do; CROW-522.) +step**, which `acli` could never do; CROW-528.) ## Important: Sandbox Bypass @@ -54,10 +54,10 @@ same file used by `/crow-workspace`. It maps each workspace to a provider/cli/ho |--------------------------|--------------|-----------------------------| | `github` (or unset) | gh | — | | `gitlab` | glab | workspace `host` | -| `jira` | Atlassian MCP | project `jiraProjectKey` | +| `jira` | `jira` MCP | project `jiraProjectKey` | When a workspace sets `taskProvider: "jira"`, tasks live in Jira (code/PRs still on the -workspace's GitHub/GitLab repo). Use the Atlassian MCP tools instead of `gh`/`glab` for +workspace's GitHub/GitLab repo). Use the `jira_*` MCP tools instead of `gh`/`glab` for the steps below. ## Instructions @@ -108,9 +108,10 @@ gh api user --jq .login GITLAB_HOST={host} glab api user --jq '.username' ``` -**Jira (Atlassian MCP):** resolve your own Atlassian `accountId` via the MCP — call -`atlassianUserInfo` (current user) or `lookupJiraAccountId`. You'll pass this -`accountId` as the assignee when creating the work item. +**Jira (`jira` MCP):** the assignee is the Jira server's configured account — +i.e. its `JIRA_USERNAME` email. `jira_create_issue`'s `assignee` param accepts that +email directly, so no separate account-id lookup is needed. (If you need to confirm +the resolved account, call `jira_get_user_profile {email}`.) ### Step 4: Create the issue (assigned + labeled `crow:auto`) @@ -142,13 +143,12 @@ GITLAB_HOST={host} glab issue create --repo {org/repo} \ --yes ``` -**Jira (Atlassian MCP):** call `createJiraIssue` with your resolved `cloudId` -(`getAccessibleAtlassianResources`), the workspace's `jiraProjectKey`, issue type -`Task`, the `TITLE` as summary, `BODY` (including the attribution footer) as -description, the `crow:auto` label, **and the assignee `accountId` from Step 3** so the -work item is created already assigned — the core fix in CROW-522. There is no -missing-label fallback to run (Step 5 is GitHub/GitLab-only); Jira accepts arbitrary -labels. +**Jira (`jira` MCP):** call `jira_create_issue` with `project_key` = +`jiraProjectKey`, `issue_type` `Task`, `summary` = `TITLE`, `description` = `BODY` +(including the attribution footer), `assignee` = the email from Step 3, and +`additional_fields` `{"labels":["crow:auto"]}` — so the work item is created already +assigned, the core fix in CROW-522. There is no missing-label fallback to run (Step 5 +is GitHub/GitLab-only); Jira accepts arbitrary labels. ### Step 4b: Attribution (REQUIRED) @@ -192,6 +192,6 @@ the repo, assignee, and `crow:auto` label so they can confirm Crow will pick it - This skill only creates the ticket. Crow's auto-pickup queue (driven by the `crow:auto` label) is responsible for starting implementation — do not also set up a worktree or session here. Use `/crow-workspace` for that. -- Assignee is resolved dynamically (`gh api user` / `glab api user` / the Atlassian MCP - `atlassianUserInfo` accountId); never hardcode a login or accountId. +- Assignee is resolved dynamically (`gh api user` / `glab api user` / the `jira` MCP + server's configured `JIRA_USERNAME`); never hardcode a login or accountId. - All `gh`, `glab`, and `git` commands require `dangerouslyDisableSandbox: true`. diff --git a/Resources/crow-workspace-SKILL.md.template b/Resources/crow-workspace-SKILL.md.template index 56b47e0d..5f60ccfc 100644 --- a/Resources/crow-workspace-SKILL.md.template +++ b/Resources/crow-workspace-SKILL.md.template @@ -92,30 +92,31 @@ GITLAB_HOST=gitlab.example.com glab issue view {number} --repo {org/repo} --comm GITLAB_HOST=gitlab.example.com glab mr view {number} --repo {org/repo} --comments ``` -### Jira (Atlassian MCP) -Jira work items are fetched via the **official Atlassian Remote MCP Server**, not -`acli` (CROW-522). When a workspace is configured for Jira and the Atlassian MCP -credential is set in Settings → Automation, Crow pre-registers and auto-trusts the -`atlassian` MCP server in the launched session. Resolve your `cloudId` once with -`getAccessibleAtlassianResources`, then call `getJiraIssue` for the full key -(`PROJ-NNN`, e.g. `MAXX-6859`). The summary field is the `{ticket_title}`. Use the -same MCP tools (`createJiraIssue`, `editJiraIssue`, `transitionJiraIssue`, -`lookupJiraAccountId`) for any create/assign/transition. (If the MCP isn't -configured in this environment, fall back to `acli jira workitem view {key} --json`.) - -**Status names when transitioning (CROW-523):** Jira workflow status names are -configurable per project, so before `transitionJiraIssue` consult this workspace's -`jiraStatusMap` in `{devRoot}/.claude/config.json` — it maps Crow's pipeline states +### Jira (`jira` MCP) +Jira work items are driven via the **`jira` MCP server** (`sooperset/mcp-atlassian`), +not `acli` (CROW-528). The `jira` server is configured globally in `~/.claude.json`, +so it's available and trusted in every launched session — Crow injects nothing. +Call `jira_get_issue` for the full key (`PROJ-NNN`, e.g. `MAXX-6859`); the `summary` +field is the `{ticket_title}`. Use the sibling `jira_*` tools for any +create/assign/update — `jira_create_issue`, `jira_update_issue` (assignee/edits), +`jira_add_comment`, `jira_get_user_profile` — and the two-step transition flow below. + +**Transitioning (CROW-523):** `jira_transition_issue` takes a numeric +`transition_id`, not a status name, and Jira workflow status names are configurable +per project. So: (1) consult this workspace's `jiraStatusMap` in +`{devRoot}/.claude/config.json` — it maps Crow's pipeline states (`Backlog` / `Ready` / `In Progress` / `In Review` / `Done`) to this project's actual -status names. Use the mapped name for the target state; fall back to the Crow default -(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. +status names; fall back to the Crow default (`Ready` → `To Do`, all others verbatim) +for any unmapped state. (2) Call `jira_get_transitions {key}` and pick the transition +whose target status matches that mapped name. (3) Call `jira_transition_issue` with +that `transition_id`. ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST | |---|---|---|---| | `github.com` | github | gh | - | -| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | Atlassian MCP | - | +| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | `jira` MCP | - | | `gitlab.example.com` | gitlab | glab | gitlab.example.com | | `gitlab.com` | gitlab | glab | gitlab.com | | `gitlab-il2.example.com` | gitlab | glab | gitlab-il2.example.com | @@ -258,13 +259,11 @@ gh api repos/{owner}/{repo}/issues/{number}/comments GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments ``` -**Jira (Atlassian MCP — task-only):** -Fetch the work item via the `atlassian` MCP server: resolve `cloudId` with -`getAccessibleAtlassianResources`, then `getJiraIssue` for `{key}` (full key, e.g. -`MAXX-6859` — not the numeric suffix). Use the work item's summary as -`{ticket_title}`. The code provider/PR detection below still runs against the -workspace's configured GitHub/GitLab repo, not Jira. (Fallback when MCP is -unconfigured: `acli jira workitem view {key} --json`, summary at `.fields.summary`.) +**Jira (`jira` MCP — task-only):** +Fetch the work item via the `jira` MCP server: call `jira_get_issue` for `{key}` +(full key, e.g. `MAXX-6859` — not the numeric suffix). Use the work item's `summary` +as `{ticket_title}`. The code provider/PR detection below still runs against the +workspace's configured GitHub/GitLab repo, not Jira. **If an existing PR was detected for this ticket**, also fetch the PR view so it can be embedded: ```bash diff --git a/Resources/crow-workspace-setup.sh.template b/Resources/crow-workspace-setup.sh.template index 36e0d7cc..9c455e98 100755 --- a/Resources/crow-workspace-setup.sh.template +++ b/Resources/crow-workspace-setup.sh.template @@ -59,14 +59,6 @@ WS_GATEWAY_RESOLVED=false TASK_PROVIDER="" TASK_PROVIDER_RESOLVED=false -# Resolved Atlassian MCP server for this workspace (populated by -# resolve_atlassian_mcp_env). WS_MCP_AUTH carries the full `Basic …` header -# value; it is written only into the owner-only settings.local.json env block. -WS_MCP_URL="" -WS_MCP_AUTH="" -WS_HAS_MCP=false -WS_MCP_RESOLVED=false - # ─── Helpers ───────────────────────────────────────────────────────────────── log() { echo "[setup.sh] $*" >&2; } @@ -227,49 +219,6 @@ resolve_task_provider() { [[ -n "$tp" && "$tp" != "null" ]] && TASK_PROVIDER="$tp" } -# Populate WS_MCP_URL / WS_MCP_AUTH and set WS_HAS_MCP=true when this workspace -# uses Jira tasks AND an Atlassian MCP credential is configured (CROW-522). -# Builds the HTTP Basic Authorization header from the account email + API token -# (`op://` token references resolved via `op read`). Idempotent — the expensive -# `op read` only runs once. Never logs the resolved token or header. -resolve_atlassian_mcp_env() { - [[ "$WS_MCP_RESOLVED" == true ]] && return 0 - WS_MCP_RESOLVED=true - - resolve_task_provider - [[ "$TASK_PROVIDER" == "jira" ]] || return 0 - - local config_path="$DEV_ROOT/.claude/config.json" - [[ -f "$config_path" ]] || return 0 - command -v jq >/dev/null 2>&1 || { log "jq not found; skipping Atlassian MCP resolution"; return 0; } - - local mcp - mcp=$(jq -c '.atlassianMCP // empty' "$config_path" 2>/dev/null) || return 0 - [[ -n "$mcp" && "$mcp" != "null" ]] || return 0 - - local endpoint email token_ref - endpoint=$(jq -r '.endpoint // "https://mcp.atlassian.com/v1/mcp"' <<< "$mcp") - email=$(jq -r '.email // ""' <<< "$mcp") - token_ref=$(jq -r '.tokenRef // ""' <<< "$mcp") - [[ -n "$email" && -n "$token_ref" ]] || { log "Atlassian MCP: email/token not set; skipping"; return 0; } - - local token="$token_ref" - if [[ "$token_ref" == op://* ]]; then - if ! token=$(op read "$token_ref" 2>/dev/null); then - log "Atlassian MCP: failed to resolve API token reference (op read failed); skipping MCP injection" - return 0 - fi - fi - - # base64 of "email:token" with no embedded newline (base64 wraps by default). - local b64 - b64=$(printf '%s' "$email:$token" | base64 | tr -d '\n') - WS_MCP_URL="$endpoint" - WS_MCP_AUTH="Basic $b64" - WS_HAS_MCP=true - log "Atlassian MCP: injecting $endpoint for this Jira workspace" -} - # Build the shell prefix that applies (or clears) the gateway env vars on the # `claude` launch line — mirrors ClaudeLaunchArgs.gatewayEnvPrefix in Swift. # Gateway absent → `unset … && ` so a no-gateway workspace doesn't inherit a @@ -584,10 +533,9 @@ create_session() { # regardless of --skip-launch, so any worktree the user later opens with Claude # Code picks up both overrides. write_settings_local() { - # Resolve the gateway and Atlassian MCP first so their blocks are written even - # when attribution trailers are disabled. + # Resolve the gateway first so its block is written even when attribution + # trailers are disabled. resolve_gateway_env - resolve_atlassian_mcp_env local want_attribution=false if is_attribution_trailers_enabled && [[ -n "$SESSION_ID" ]]; then @@ -598,8 +546,8 @@ write_settings_local() { log "Attribution trailers disabled via config" fi - if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true && "$WS_HAS_MCP" != true ]]; then - log "No attribution trailer, gateway, or MCP to write; skipping settings.local.json" + if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true ]]; then + log "No attribution trailer or gateway to write; skipping settings.local.json" return fi @@ -641,31 +589,19 @@ Crow-Session: $SESSION_ID" --argjson want_gw "$WS_HAS_GATEWAY" \ --arg base_url "$WS_BASE_URL" \ --arg headers "$WS_CUSTOM_HEADERS" \ - --argjson want_mcp "$WS_HAS_MCP" \ - --arg mcp_auth "$WS_MCP_AUTH" \ '(if $want_attr then .attribution.commit = $commit else . end) | (if $want_gw then .env.ANTHROPIC_BASE_URL = $base_url - | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end) - | (if $want_mcp then .env.ATLASSIAN_MCP_AUTHORIZATION = $mcp_auth - | .enabledMcpjsonServers = ((.enabledMcpjsonServers // []) - + ["atlassian"] | unique) else . end)' \ + | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end)' \ <<< "$base"); then die "settings_local" "jq failed to build settings.local.json" fi printf '%s\n' "$merged" > "$settings_path" - # The env block can carry a resolved bearer token / MCP credential, so restrict - # the file to owner-only — matching ConfigStore's 0600 on config.json. + # The env block can carry a resolved bearer token, so restrict the file to + # owner-only — matching ConfigStore's 0600 on config.json. chmod 600 "$settings_path" 2>/dev/null || true - # CROW-522: write a project-root .mcp.json registering the Atlassian Remote MCP - # Server. The Authorization header references the env var set above via ${…} - # expansion, so the secret stays only in the owner-only settings.local.json. - if [[ "$WS_HAS_MCP" == true ]]; then - write_mcp_json - fi - - if [[ "$WS_HAS_GATEWAY" == true || "$WS_HAS_MCP" == true ]]; then - log "Wrote settings.local.json (attribution + gateway/MCP env) to $settings_path (agent: $display_name)" + if [[ "$WS_HAS_GATEWAY" == true ]]; then + log "Wrote settings.local.json (attribution + gateway env) to $settings_path (agent: $display_name)" else log "Wrote attribution settings to $settings_path (agent: $display_name)" fi @@ -682,37 +618,6 @@ Crow-Session: $SESSION_ID" if ! grep -qxF '.claude/settings.local.json' "$exclude_file" 2>/dev/null; then printf '\n# Added by crow setup.sh\n.claude/settings.local.json\n' >> "$exclude_file" fi - # CROW-522: the Atlassian MCP .mcp.json sits at the worktree root; exclude it - # too so a Jira workspace's MCP registration is never accidentally committed. - if [[ "$WS_HAS_MCP" == true ]] && ! grep -qxF '.mcp.json' "$exclude_file" 2>/dev/null; then - printf '.mcp.json\n' >> "$exclude_file" - fi -} - -# Write a project-root .mcp.json registering the Atlassian Remote MCP Server for -# this Jira workspace (CROW-522). Merges into an existing file, preserving any -# user-defined servers. The Authorization header is a ${…} reference to the env -# var in settings.local.json — the resolved secret never lands in this file. -write_mcp_json() { - command -v jq >/dev/null 2>&1 || { log "jq not found; skipping .mcp.json"; return 0; } - local mcp_path="$WORKTREE_PATH/.mcp.json" - local base="{}" - [[ -f "$mcp_path" ]] && base=$(cat "$mcp_path") - - local merged - if ! merged=$(jq \ - --arg url "$WS_MCP_URL" \ - '.mcpServers.atlassian = { - "type": "http", - "url": $url, - "headers": { "Authorization": "${ATLASSIAN_MCP_AUTHORIZATION}" } - }' \ - <<< "$base"); then - log "Warning: jq failed to build .mcp.json; skipping" - return 0 - fi - printf '%s\n' "$merged" > "$mcp_path" - log "Wrote .mcp.json (Atlassian MCP) to $mcp_path" } # ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ───────────────────────── @@ -878,7 +783,7 @@ github_ops() { # $TICKET_URL is a Jira browse URL, not a GitHub issue — running `gh issue # edit`/project-status against it just logs `auto-assign failed`. Skip GitHub # issue housekeeping entirely when the task provider is Jira (assignment + - # status now happen via the Atlassian MCP in-session). + # status now happen via the jira MCP in-session). resolve_task_provider if [[ "$TASK_PROVIDER" == "jira" ]]; then log "Task provider is Jira; skipping GitHub issue auto-assign/project-status" diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index 95010b83..9c4204de 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -485,8 +485,8 @@ struct Scaffolder { "Bash(acli jira workitem edit:*)", "Bash(acli jira workitem create:*)", "Bash(acli jira auth status:*)", - "mcp__atlassian", - "mcp__atlassian__*", + "mcp__jira", + "mcp__jira__*", "Bash(git -C:*)", "Write(.claude/prompts/**)", "Bash(git fetch:*)", diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 1fffe556..baa47b35 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -560,15 +560,6 @@ final class SessionService { ClaudeHookConfigWriter.writeGatewayEnv( dirPath: worktree.worktreePath, resolved: gatewayResolved) gatewayPrefix = ClaudeLaunchArgs.gatewayEnvPrefix(gatewayResolved) - - // CROW-522: inject the Atlassian Remote MCP Server for Jira-task - // worktrees so the session creates/assigns/transitions/fetches work - // items via MCP instead of acli. Always called (resolved or nil) so a - // non-Jira workspace — or one with the MCP unconfigured — clears any - // stale .mcp.json/env. - ClaudeHookConfigWriter.writeAtlassianMcpConfig( - dirPath: worktree.worktreePath, - resolved: workspaceAtlassianMCPResolved(for: sessionID)) } let rcEnabled = appState.remoteControlEnabled @@ -806,36 +797,6 @@ final class SessionService { private func writeManagerGatewayEnv() { guard let devRoot = ConfigStore.loadDevRoot() else { return } ClaudeHookConfigWriter.writeGatewayEnv(dirPath: devRoot, resolved: managerGatewayResolved()) - // CROW-522: the Manager (and cron jobs running at devRoot) get the - // Atlassian MCP whenever it's configured, since the Manager isn't bound - // to a single workspace. - ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: devRoot, resolved: atlassianMCPResolved()) - } - - /// Resolve the org-wide Atlassian MCP credential (`AppConfig.atlassianMCP`) - /// into a launch-ready Authorization header, or nil when unset/empty - /// (CROW-522). Used for the Manager and cron sessions at devRoot. - private func atlassianMCPResolved() -> AtlassianMCPResolver.Resolved? { - guard let devRoot = ConfigStore.loadDevRoot(), - let config = ConfigStore.loadConfig(devRoot: devRoot), - let mcp = config.atlassianMCP, !mcp.isEmpty - else { return nil } - return AtlassianMCPResolver.resolve(mcp) - } - - /// Resolve the Atlassian MCP credential for a non-Manager session, gated on - /// the worktree's workspace using Jira as its task provider (CROW-522). - /// Returns nil for non-Jira workspaces or when the MCP is unconfigured. - private func workspaceAtlassianMCPResolved(for sessionID: UUID) -> AtlassianMCPResolver.Resolved? { - guard let devRoot = ConfigStore.loadDevRoot(), - let config = ConfigStore.loadConfig(devRoot: devRoot), - let mcp = config.atlassianMCP, !mcp.isEmpty, - let worktree = appState.primaryWorktree(for: sessionID), - let wsName = Self.workspaceName(forWorktreePath: worktree.worktreePath, devRoot: devRoot), - let workspace = config.workspaces.first(where: { $0.name == wsName }), - workspace.derivedTaskProvider == "jira" - else { return nil } - return AtlassianMCPResolver.resolve(mcp) } /// Resolve the Manager's own AI gateway (`AppConfig.managerGateway`) from @@ -895,9 +856,6 @@ final class SessionService { // manual `claude` re-runs in this terminal inherit the same routing. The // Manager's cwd is the devRoot. ClaudeHookConfigWriter.writeGatewayEnv(dirPath: cwd, resolved: managerGatewayResolved()) - // CROW-522: inject the Atlassian MCP into the Manager terminal's devRoot - // so the Manager can drive Jira via MCP (headless). - ClaudeHookConfigWriter.writeAtlassianMcpConfig(dirPath: cwd, resolved: atlassianMCPResolved()) let rawTerminal = SessionTerminal( sessionID: session.id, name: session.name, diff --git a/docs/automation.md b/docs/automation.md index bea4ea63..41fe2784 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -41,21 +41,16 @@ PR #228 split every automation toggle out of General into its own tab. Open **Se Turn this off if your account reports auto mode as unavailable. Worker sessions and CLI-spawned terminals are unaffected. Takes effect on next app launch — the Manager's stored command is rebuilt on hydration. -### Atlassian MCP (Jira) +### Jira MCP -`acli` cannot set a Jira assignee (at create or after) and its transitions are unreliable, so every ticket Crow filed landed unassigned. PR #522 routes the **agent-side** Jira flow (create-with-assignee, assign/reassign, transition, fetch, link) through the official **Atlassian Remote MCP Server** instead. Crow's in-app issue-board polling and auto-complete still use `acli` (that path works); only the agent flow moved. +`acli` cannot set a Jira assignee (at create or after) and its transitions are unreliable, so every ticket Crow filed landed unassigned. The **agent-side** Jira flow (create-with-assignee, assign/reassign, transition, fetch, comment) now routes through the **`jira` MCP server** (`sooperset/mcp-atlassian`, Docker stdio) using the `jira_*` tools. Crow's in-app issue-board polling and auto-complete still use `acli` (that path works); only the agent flow moved. -- **Atlassian MCP (Jira)** — set the endpoint (defaults to `https://mcp.atlassian.com/v1/mcp`), your Atlassian account **email**, and an **API token** (`op://` reference recommended, or plaintext). Crow builds an HTTP Basic `Authorization` header and injects the MCP server into launched Jira-task sessions, the Manager, and cron jobs. - -How injection works: for a session whose workspace uses `taskProvider: "jira"` (and the Manager/cron at the dev root), Crow writes a project-root `.mcp.json` registering an `http` server named `atlassian`, pre-trusts it via `enabledMcpjsonServers` in `.claude/settings.local.json`, and stores the resolved `Authorization` value in that file's `env` block (`ATLASSIAN_MCP_AUTHORIZATION`, chmod `0600`) — the `.mcp.json` header is a `${…}` reference, so no secret lands in it. The launched agent (and the migrated `/crow-create-ticket`, `/crow-workspace`, `/crow-batch-workspace` skills) use the MCP tools — `getJiraIssue`, `createJiraIssue`, `editJiraIssue`, `transitionJiraIssue`, `lookupJiraAccountId` — instead of `acli`. Backed by `AppConfig.atlassianMCP`. `gh`/`glab` GitHub/GitLab task paths are untouched. +The `jira` server is configured **globally** in `~/.claude.json`'s top-level `mcpServers` (Docker stdio with `JIRA_URL` / `JIRA_USERNAME` / `JIRA_API_TOKEN`), so it is auto-loaded and trusted in **every** Claude Code session — worktrees, the Manager, and cron jobs. Crow therefore injects nothing: there is no per-session `.mcp.json` or `enabledMcpjsonServers` entry to write (CROW-528). The launched agent (and the `/crow-create-ticket`, `/crow-workspace`, `/crow-batch-workspace` skills) call the `jira_*` tools — `jira_get_issue`, `jira_create_issue`, `jira_update_issue`, `jira_transition_issue` (+ `jira_get_transitions`), `jira_add_comment`, `jira_get_user_profile` — instead of `acli`. `gh`/`glab` GitHub/GitLab task paths are untouched. -**Headless auth — one-time setup.** The Manager and cron jobs run without a browser, so they use API-token auth (no OAuth consent screen). Two prerequisites: - -1. An Atlassian **org admin enables API-token auth for the Rovo MCP Server** (organization security settings → Rovo MCP Server). Without this, calls return 401. -2. Create a **personal API token** at → Security → API tokens, then store it in **Settings → Automation → Atlassian MCP (Jira)** as an `op://` reference (preferred) or plaintext. +**Headless auth — one-time setup.** The `jira` server authenticates with a **personal API token** via the `JIRA_*` env vars in its `~/.claude.json` entry. Create the token at → Security → API tokens and set `JIRA_URL` (your `https://.atlassian.net`), `JIRA_USERNAME` (account email), and `JIRA_API_TOKEN`. The same global config serves the Manager and cron jobs, so no Crow-side credential is needed for the agent flow. -Crow sends `Authorization: Basic base64(email:token)`. (A service-account `Bearer` key works too if you'd rather not attribute tickets to a personal account, but the Settings UI is geared to the personal-token Basic flow.) +> **In-app status fetch.** The "Fetch from Jira" button in **Settings → Workspaces** (the #523 status map) calls Jira's REST API *directly from the Crow app process*, which cannot use the MCP. That one feature uses a small **Settings → Automation → Jira (status fetch)** credential (`JIRA_USERNAME` + an `op://`/plaintext API token), stored in `config.json` as `jiraCredential`. It is unrelated to the agent-side MCP. ### Auto-respond diff --git a/docs/configuration.md b/docs/configuration.md index 28335bef..146e3b70 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,36 +125,28 @@ The Manager session sits at the dev root and isn't bound to a single workspace, Same shape, same secret-storage rules, same two-way injection (written to `{devRoot}/.claude/settings.local.json`). Configure it under **Settings → Automation → Manager AI Gateway**. Takes effect on the next app launch. -## Atlassian MCP (Jira) +## Jira MCP -For workspaces with `taskProvider: "jira"`, Crow drives the **agent-side** Jira flow (create-with-assignee, assign/reassign, transition, fetch, link) through the official **Atlassian Remote MCP Server** instead of `acli`. `acli` cannot set an assignee at create time, so every ticket it filed landed unassigned; the MCP `createJiraIssue` tool sets the assignee in one step. (Crow's in-app issue-board polling and auto-complete still use `acli` — only the agent flow moved.) +For workspaces with `taskProvider: "jira"`, Crow drives the **agent-side** Jira flow (create-with-assignee, assign/reassign, transition, fetch, comment) through the **`jira` MCP server** (`sooperset/mcp-atlassian`, Docker stdio) using the `jira_*` tools instead of `acli`. `acli` cannot set an assignee at create time, so every ticket it filed landed unassigned; the MCP `jira_create_issue` tool sets the assignee in one step. (Crow's in-app issue-board polling and auto-complete still use `acli` — only the agent flow moved.) -Configure one org-wide credential under **Settings → Automation → Atlassian MCP (Jira)**. It is stored top-level in `config.json`: +The `jira` server lives **globally** in `~/.claude.json`'s top-level `mcpServers`, so it is auto-loaded and trusted in every Claude Code session. Crow injects **nothing** — no per-session `.mcp.json` and no `enabledMcpjsonServers` entry (CROW-528): ```jsonc -{ - "atlassianMCP": { - "endpoint": "https://mcp.atlassian.com/v1/mcp", - "email": "you@example.com", - // op:// reference — resolved at launch via the 1Password CLI; kept out of config.json - "tokenRef": "op://Private/Atlassian/api_token" - } -} +// ~/.claude.json (user-global) — not written by Crow +{ "mcpServers": { "jira": { + "type": "stdio", + "command": "docker", + "args": ["run","-i","--rm","-e","JIRA_URL","-e","JIRA_USERNAME","-e","JIRA_API_TOKEN", + "ghcr.io/sooperset/mcp-atlassian:latest","--transport","stdio"], + "env": { "JIRA_URL": "https://.atlassian.net", + "JIRA_USERNAME": "you@example.com", + "JIRA_API_TOKEN": "${JIRA_API_KEY}" } } } } ``` -- **Auth** is a **personal API token** (from ) sent as HTTP Basic — Crow builds `Authorization: Basic base64(email:token)` at launch. -- **`tokenRef`** follows the same secret rules as gateway keys: an `op://` reference (recommended — never written to `config.json`) or a plaintext token (stored `0600`, with a UI warning). The resolved `Authorization` value is cached in the session's owner-only `.claude/settings.local.json` `env` block (`ATLASSIAN_MCP_AUTHORIZATION`) and never logged. - -When set, Crow injects the server into launched Jira-task sessions (and the Manager + cron jobs at the dev root) by writing a project-root `.mcp.json` and pre-trusting it via `enabledMcpjsonServers`: - -```jsonc -// {worktree-or-devRoot}/.mcp.json — the Authorization references the env var, so no secret lands here -{ "mcpServers": { "atlassian": { "type": "http", - "url": "https://mcp.atlassian.com/v1/mcp", - "headers": { "Authorization": "${ATLASSIAN_MCP_AUTHORIZATION}" } } } } -``` +- **Auth** is a **personal API token** (from ) passed to the container via the `JIRA_*` env vars. The same global config serves worktree sessions, the Manager, and cron jobs. +- **`gh`/`glab` GitHub/GitLab task paths are unaffected.** -> **Prerequisite:** an Atlassian **org admin must enable API-token auth for the Rovo MCP Server** for your org, otherwise the headless calls return 401. See [docs/automation.md](automation.md#atlassian-mcp-headless-auth) for the one-time setup. `gh`/`glab` GitHub/GitLab task paths are unaffected. +> **In-app status fetch.** The "Fetch from Jira" status-map button (below) is the one Jira feature that runs in the **Crow app process**, which can't use the MCP. It uses a separate small credential under **Settings → Automation → Jira (status fetch)**, stored top-level in `config.json` as `jiraCredential` (`username` + an `op://`/plaintext `tokenRef`, same secret rules as gateway keys). Crow builds `Authorization: Basic base64(username:token)` on demand to call Jira's REST API directly; it is never written to a launched session. ### Jira status mapping @@ -180,9 +172,9 @@ Jira workflow **status names are configurable per project**, so a project that r - **Keys** are Crow's pipeline states: `Backlog`, `Ready`, `In Progress`, `In Review`, `Done`. **Values** are the exact Jira workflow status names for that project (case- and spelling-sensitive). - **A missing or blank entry falls back to the built-in default:** `Ready` → `To Do`; every other state uses its own name verbatim (`In Progress`, `In Review`, `Done`, `Backlog`). An entirely unset `jiraStatusMap` keeps today's behavior. -- Both status surfaces consult the map: the in-app **"Mark in review"** transition (`acli`) and the **agent-side** MCP `transitionJiraIssue` flow (the `/crow-workspace` skill reads `jiraStatusMap` from `config.json` before transitioning). +- Both status surfaces consult the map: the in-app **"Mark in review"** transition (`acli`) and the **agent-side** `jira` MCP flow — the `/crow-workspace` skill reads `jiraStatusMap` from `config.json`, then resolves the mapped status name to a `transition_id` via `jira_get_transitions` before calling `jira_transition_issue`. -Edit it under **Settings → Workspaces → (a Jira workspace) → Jira Status Mapping**. Each pipeline state gets a field whose placeholder is the current default — leave it blank to keep the default. If an Atlassian MCP credential is configured, **Fetch from Jira** populates per-row dropdowns from the project's live workflow (`GET /rest/api/3/project/{key}/statuses`); otherwise the fields are free-text. +Edit it under **Settings → Workspaces → (a Jira workspace) → Jira Status Mapping**. Each pipeline state gets a field whose placeholder is the current default — leave it blank to keep the default. If a **Jira (status fetch)** credential is configured (Settings → Automation), **Fetch from Jira** populates per-row dropdowns from the project's live workflow (`GET /rest/api/3/project/{key}/statuses`); otherwise the fields are free-text. ## Manager Terminal diff --git a/skills/crow-batch-workspace/SKILL.md b/skills/crow-batch-workspace/SKILL.md index dc481539..5a86f114 100644 --- a/skills/crow-batch-workspace/SKILL.md +++ b/skills/crow-batch-workspace/SKILL.md @@ -79,7 +79,7 @@ For each workspace spec, perform the same resolution as `/crow-workspace`: 5. **Fetch ticket** (provider-specific, with `dangerouslyDisableSandbox: true`): - GitHub: `gh issue view {url} --json title,body,labels` - GitLab: `GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments` - - Jira (task-only): fetch via the `atlassian` MCP server (`getAccessibleAtlassianResources` → `getJiraIssue {key}`); title is the work item summary. (Fallback when MCP unconfigured: `acli jira workitem view {key} --json`, title at `.fields.summary`.) + - Jira (task-only): fetch via the `jira` MCP server (`jira_get_issue {key}`); title is the work item `summary`. 6. **Check for existing PR**: `gh pr list --repo {owner}/{repo} --search "{issue_number}" --state open --json number,title,headRefName,url --limit 5` (with `dangerouslyDisableSandbox: true`). For a Jira-task session this runs against the workspace's configured GitHub/GitLab code repo, not Jira. 7. **Generate names**: slug, branch, worktree path, session name (following `/crow-workspace` naming conventions). **Jira:** resolve `{ticket_number}` as the **numeric suffix** of the key (`MAXX-6859` → `6859`); `{ticket_url}` is the full `…/browse/{key}` URL; the slug uses the lowercased full key (`{repo}-maxx-6859-{slug}`). 8. **Compose prompt**: Use the First Prompt Template from `/crow-workspace` diff --git a/skills/crow-create-ticket/SKILL.md b/skills/crow-create-ticket/SKILL.md index 863cfa0f..0bb33683 100644 --- a/skills/crow-create-ticket/SKILL.md +++ b/skills/crow-create-ticket/SKILL.md @@ -1,11 +1,11 @@ # Crow Create Ticket Create a new ticket (GitHub issue via `gh`, GitLab issue via `glab`, or Jira work item -via the **Atlassian MCP server**) for a repo/project in the current Crow workspace, +via the **`jira` MCP server**) for a repo/project in the current Crow workspace, assigned to the invoking user and labeled `crow:auto`. For GitHub/GitLab, Crow's auto-pickup queue then implements it. (Jira's auto-pickup isn't wired — the label is for parity/board visibility — but the work item is created **with an assignee in one -step**, which `acli` could never do; CROW-522.) +step**, which `acli` could never do; CROW-528.) ## Important: Sandbox Bypass @@ -54,10 +54,10 @@ same file used by `/crow-workspace`. It maps each workspace to a provider/cli/ho |--------------------------|--------------|-----------------------------| | `github` (or unset) | gh | — | | `gitlab` | glab | workspace `host` | -| `jira` | Atlassian MCP | project `jiraProjectKey` | +| `jira` | `jira` MCP | project `jiraProjectKey` | When a workspace sets `taskProvider: "jira"`, tasks live in Jira (code/PRs still on the -workspace's GitHub/GitLab repo). Use the Atlassian MCP tools instead of `gh`/`glab` for +workspace's GitHub/GitLab repo). Use the `jira_*` MCP tools instead of `gh`/`glab` for the steps below. ## Instructions @@ -108,9 +108,10 @@ gh api user --jq .login GITLAB_HOST={host} glab api user --jq '.username' ``` -**Jira (Atlassian MCP):** resolve your own Atlassian `accountId` via the MCP — call -`atlassianUserInfo` (current user) or `lookupJiraAccountId`. You'll pass this -`accountId` as the assignee when creating the work item. +**Jira (`jira` MCP):** the assignee is the Jira server's configured account — +i.e. its `JIRA_USERNAME` email. `jira_create_issue`'s `assignee` param accepts that +email directly, so no separate account-id lookup is needed. (If you need to confirm +the resolved account, call `jira_get_user_profile {email}`.) ### Step 4: Create the issue (assigned + labeled `crow:auto`) @@ -142,13 +143,12 @@ GITLAB_HOST={host} glab issue create --repo {org/repo} \ --yes ``` -**Jira (Atlassian MCP):** call `createJiraIssue` with your resolved `cloudId` -(`getAccessibleAtlassianResources`), the workspace's `jiraProjectKey`, issue type -`Task`, the `TITLE` as summary, `BODY` (including the attribution footer) as -description, the `crow:auto` label, **and the assignee `accountId` from Step 3** so the -work item is created already assigned — the core fix in CROW-522. There is no -missing-label fallback to run (Step 5 is GitHub/GitLab-only); Jira accepts arbitrary -labels. +**Jira (`jira` MCP):** call `jira_create_issue` with `project_key` = +`jiraProjectKey`, `issue_type` `Task`, `summary` = `TITLE`, `description` = `BODY` +(including the attribution footer), `assignee` = the email from Step 3, and +`additional_fields` `{"labels":["crow:auto"]}` — so the work item is created already +assigned, the core fix in CROW-522. There is no missing-label fallback to run (Step 5 +is GitHub/GitLab-only); Jira accepts arbitrary labels. ### Step 4b: Attribution (REQUIRED) @@ -192,6 +192,6 @@ the repo, assignee, and `crow:auto` label so they can confirm Crow will pick it - This skill only creates the ticket. Crow's auto-pickup queue (driven by the `crow:auto` label) is responsible for starting implementation — do not also set up a worktree or session here. Use `/crow-workspace` for that. -- Assignee is resolved dynamically (`gh api user` / `glab api user` / the Atlassian MCP - `atlassianUserInfo` accountId); never hardcode a login or accountId. +- Assignee is resolved dynamically (`gh api user` / `glab api user` / the `jira` MCP + server's configured `JIRA_USERNAME`); never hardcode a login or accountId. - All `gh`, `glab`, and `git` commands require `dangerouslyDisableSandbox: true`. diff --git a/skills/crow-workspace/SKILL.md b/skills/crow-workspace/SKILL.md index 56b47e0d..5f60ccfc 100644 --- a/skills/crow-workspace/SKILL.md +++ b/skills/crow-workspace/SKILL.md @@ -92,30 +92,31 @@ GITLAB_HOST=gitlab.example.com glab issue view {number} --repo {org/repo} --comm GITLAB_HOST=gitlab.example.com glab mr view {number} --repo {org/repo} --comments ``` -### Jira (Atlassian MCP) -Jira work items are fetched via the **official Atlassian Remote MCP Server**, not -`acli` (CROW-522). When a workspace is configured for Jira and the Atlassian MCP -credential is set in Settings → Automation, Crow pre-registers and auto-trusts the -`atlassian` MCP server in the launched session. Resolve your `cloudId` once with -`getAccessibleAtlassianResources`, then call `getJiraIssue` for the full key -(`PROJ-NNN`, e.g. `MAXX-6859`). The summary field is the `{ticket_title}`. Use the -same MCP tools (`createJiraIssue`, `editJiraIssue`, `transitionJiraIssue`, -`lookupJiraAccountId`) for any create/assign/transition. (If the MCP isn't -configured in this environment, fall back to `acli jira workitem view {key} --json`.) - -**Status names when transitioning (CROW-523):** Jira workflow status names are -configurable per project, so before `transitionJiraIssue` consult this workspace's -`jiraStatusMap` in `{devRoot}/.claude/config.json` — it maps Crow's pipeline states +### Jira (`jira` MCP) +Jira work items are driven via the **`jira` MCP server** (`sooperset/mcp-atlassian`), +not `acli` (CROW-528). The `jira` server is configured globally in `~/.claude.json`, +so it's available and trusted in every launched session — Crow injects nothing. +Call `jira_get_issue` for the full key (`PROJ-NNN`, e.g. `MAXX-6859`); the `summary` +field is the `{ticket_title}`. Use the sibling `jira_*` tools for any +create/assign/update — `jira_create_issue`, `jira_update_issue` (assignee/edits), +`jira_add_comment`, `jira_get_user_profile` — and the two-step transition flow below. + +**Transitioning (CROW-523):** `jira_transition_issue` takes a numeric +`transition_id`, not a status name, and Jira workflow status names are configurable +per project. So: (1) consult this workspace's `jiraStatusMap` in +`{devRoot}/.claude/config.json` — it maps Crow's pipeline states (`Backlog` / `Ready` / `In Progress` / `In Review` / `Done`) to this project's actual -status names. Use the mapped name for the target state; fall back to the Crow default -(`Ready` → `To Do`, all others use the state name verbatim) for any unmapped state. +status names; fall back to the Crow default (`Ready` → `To Do`, all others verbatim) +for any unmapped state. (2) Call `jira_get_transitions {key}` and pick the transition +whose target status matches that mapped name. (3) Call `jira_transition_issue` with +that `transition_id`. ### Provider Detection from URL | URL Contains | Provider | CLI | GITLAB_HOST | |---|---|---|---| | `github.com` | github | gh | - | -| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | Atlassian MCP | - | +| `atlassian.net` / `/browse/` / bare `PROJ-123` | jira | `jira` MCP | - | | `gitlab.example.com` | gitlab | glab | gitlab.example.com | | `gitlab.com` | gitlab | glab | gitlab.com | | `gitlab-il2.example.com` | gitlab | glab | gitlab-il2.example.com | @@ -258,13 +259,11 @@ gh api repos/{owner}/{repo}/issues/{number}/comments GITLAB_HOST={host} glab issue view {number} --repo {org/repo} --comments ``` -**Jira (Atlassian MCP — task-only):** -Fetch the work item via the `atlassian` MCP server: resolve `cloudId` with -`getAccessibleAtlassianResources`, then `getJiraIssue` for `{key}` (full key, e.g. -`MAXX-6859` — not the numeric suffix). Use the work item's summary as -`{ticket_title}`. The code provider/PR detection below still runs against the -workspace's configured GitHub/GitLab repo, not Jira. (Fallback when MCP is -unconfigured: `acli jira workitem view {key} --json`, summary at `.fields.summary`.) +**Jira (`jira` MCP — task-only):** +Fetch the work item via the `jira` MCP server: call `jira_get_issue` for `{key}` +(full key, e.g. `MAXX-6859` — not the numeric suffix). Use the work item's `summary` +as `{ticket_title}`. The code provider/PR detection below still runs against the +workspace's configured GitHub/GitLab repo, not Jira. **If an existing PR was detected for this ticket**, also fetch the PR view so it can be embedded: ```bash diff --git a/skills/crow-workspace/setup.sh b/skills/crow-workspace/setup.sh index 36e0d7cc..9c455e98 100755 --- a/skills/crow-workspace/setup.sh +++ b/skills/crow-workspace/setup.sh @@ -59,14 +59,6 @@ WS_GATEWAY_RESOLVED=false TASK_PROVIDER="" TASK_PROVIDER_RESOLVED=false -# Resolved Atlassian MCP server for this workspace (populated by -# resolve_atlassian_mcp_env). WS_MCP_AUTH carries the full `Basic …` header -# value; it is written only into the owner-only settings.local.json env block. -WS_MCP_URL="" -WS_MCP_AUTH="" -WS_HAS_MCP=false -WS_MCP_RESOLVED=false - # ─── Helpers ───────────────────────────────────────────────────────────────── log() { echo "[setup.sh] $*" >&2; } @@ -227,49 +219,6 @@ resolve_task_provider() { [[ -n "$tp" && "$tp" != "null" ]] && TASK_PROVIDER="$tp" } -# Populate WS_MCP_URL / WS_MCP_AUTH and set WS_HAS_MCP=true when this workspace -# uses Jira tasks AND an Atlassian MCP credential is configured (CROW-522). -# Builds the HTTP Basic Authorization header from the account email + API token -# (`op://` token references resolved via `op read`). Idempotent — the expensive -# `op read` only runs once. Never logs the resolved token or header. -resolve_atlassian_mcp_env() { - [[ "$WS_MCP_RESOLVED" == true ]] && return 0 - WS_MCP_RESOLVED=true - - resolve_task_provider - [[ "$TASK_PROVIDER" == "jira" ]] || return 0 - - local config_path="$DEV_ROOT/.claude/config.json" - [[ -f "$config_path" ]] || return 0 - command -v jq >/dev/null 2>&1 || { log "jq not found; skipping Atlassian MCP resolution"; return 0; } - - local mcp - mcp=$(jq -c '.atlassianMCP // empty' "$config_path" 2>/dev/null) || return 0 - [[ -n "$mcp" && "$mcp" != "null" ]] || return 0 - - local endpoint email token_ref - endpoint=$(jq -r '.endpoint // "https://mcp.atlassian.com/v1/mcp"' <<< "$mcp") - email=$(jq -r '.email // ""' <<< "$mcp") - token_ref=$(jq -r '.tokenRef // ""' <<< "$mcp") - [[ -n "$email" && -n "$token_ref" ]] || { log "Atlassian MCP: email/token not set; skipping"; return 0; } - - local token="$token_ref" - if [[ "$token_ref" == op://* ]]; then - if ! token=$(op read "$token_ref" 2>/dev/null); then - log "Atlassian MCP: failed to resolve API token reference (op read failed); skipping MCP injection" - return 0 - fi - fi - - # base64 of "email:token" with no embedded newline (base64 wraps by default). - local b64 - b64=$(printf '%s' "$email:$token" | base64 | tr -d '\n') - WS_MCP_URL="$endpoint" - WS_MCP_AUTH="Basic $b64" - WS_HAS_MCP=true - log "Atlassian MCP: injecting $endpoint for this Jira workspace" -} - # Build the shell prefix that applies (or clears) the gateway env vars on the # `claude` launch line — mirrors ClaudeLaunchArgs.gatewayEnvPrefix in Swift. # Gateway absent → `unset … && ` so a no-gateway workspace doesn't inherit a @@ -584,10 +533,9 @@ create_session() { # regardless of --skip-launch, so any worktree the user later opens with Claude # Code picks up both overrides. write_settings_local() { - # Resolve the gateway and Atlassian MCP first so their blocks are written even - # when attribution trailers are disabled. + # Resolve the gateway first so its block is written even when attribution + # trailers are disabled. resolve_gateway_env - resolve_atlassian_mcp_env local want_attribution=false if is_attribution_trailers_enabled && [[ -n "$SESSION_ID" ]]; then @@ -598,8 +546,8 @@ write_settings_local() { log "Attribution trailers disabled via config" fi - if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true && "$WS_HAS_MCP" != true ]]; then - log "No attribution trailer, gateway, or MCP to write; skipping settings.local.json" + if [[ "$want_attribution" != true && "$WS_HAS_GATEWAY" != true ]]; then + log "No attribution trailer or gateway to write; skipping settings.local.json" return fi @@ -641,31 +589,19 @@ Crow-Session: $SESSION_ID" --argjson want_gw "$WS_HAS_GATEWAY" \ --arg base_url "$WS_BASE_URL" \ --arg headers "$WS_CUSTOM_HEADERS" \ - --argjson want_mcp "$WS_HAS_MCP" \ - --arg mcp_auth "$WS_MCP_AUTH" \ '(if $want_attr then .attribution.commit = $commit else . end) | (if $want_gw then .env.ANTHROPIC_BASE_URL = $base_url - | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end) - | (if $want_mcp then .env.ATLASSIAN_MCP_AUTHORIZATION = $mcp_auth - | .enabledMcpjsonServers = ((.enabledMcpjsonServers // []) - + ["atlassian"] | unique) else . end)' \ + | .env.ANTHROPIC_CUSTOM_HEADERS = $headers else . end)' \ <<< "$base"); then die "settings_local" "jq failed to build settings.local.json" fi printf '%s\n' "$merged" > "$settings_path" - # The env block can carry a resolved bearer token / MCP credential, so restrict - # the file to owner-only — matching ConfigStore's 0600 on config.json. + # The env block can carry a resolved bearer token, so restrict the file to + # owner-only — matching ConfigStore's 0600 on config.json. chmod 600 "$settings_path" 2>/dev/null || true - # CROW-522: write a project-root .mcp.json registering the Atlassian Remote MCP - # Server. The Authorization header references the env var set above via ${…} - # expansion, so the secret stays only in the owner-only settings.local.json. - if [[ "$WS_HAS_MCP" == true ]]; then - write_mcp_json - fi - - if [[ "$WS_HAS_GATEWAY" == true || "$WS_HAS_MCP" == true ]]; then - log "Wrote settings.local.json (attribution + gateway/MCP env) to $settings_path (agent: $display_name)" + if [[ "$WS_HAS_GATEWAY" == true ]]; then + log "Wrote settings.local.json (attribution + gateway env) to $settings_path (agent: $display_name)" else log "Wrote attribution settings to $settings_path (agent: $display_name)" fi @@ -682,37 +618,6 @@ Crow-Session: $SESSION_ID" if ! grep -qxF '.claude/settings.local.json' "$exclude_file" 2>/dev/null; then printf '\n# Added by crow setup.sh\n.claude/settings.local.json\n' >> "$exclude_file" fi - # CROW-522: the Atlassian MCP .mcp.json sits at the worktree root; exclude it - # too so a Jira workspace's MCP registration is never accidentally committed. - if [[ "$WS_HAS_MCP" == true ]] && ! grep -qxF '.mcp.json' "$exclude_file" 2>/dev/null; then - printf '.mcp.json\n' >> "$exclude_file" - fi -} - -# Write a project-root .mcp.json registering the Atlassian Remote MCP Server for -# this Jira workspace (CROW-522). Merges into an existing file, preserving any -# user-defined servers. The Authorization header is a ${…} reference to the env -# var in settings.local.json — the resolved secret never lands in this file. -write_mcp_json() { - command -v jq >/dev/null 2>&1 || { log "jq not found; skipping .mcp.json"; return 0; } - local mcp_path="$WORKTREE_PATH/.mcp.json" - local base="{}" - [[ -f "$mcp_path" ]] && base=$(cat "$mcp_path") - - local merged - if ! merged=$(jq \ - --arg url "$WS_MCP_URL" \ - '.mcpServers.atlassian = { - "type": "http", - "url": $url, - "headers": { "Authorization": "${ATLASSIAN_MCP_AUTHORIZATION}" } - }' \ - <<< "$base"); then - log "Warning: jq failed to build .mcp.json; skipping" - return 0 - fi - printf '%s\n' "$merged" > "$mcp_path" - log "Wrote .mcp.json (Atlassian MCP) to $mcp_path" } # ─── Per-Worktree prepare-commit-msg Hook (CROW-518) ───────────────────────── @@ -878,7 +783,7 @@ github_ops() { # $TICKET_URL is a Jira browse URL, not a GitHub issue — running `gh issue # edit`/project-status against it just logs `auto-assign failed`. Skip GitHub # issue housekeeping entirely when the task provider is Jira (assignment + - # status now happen via the Atlassian MCP in-session). + # status now happen via the jira MCP in-session). resolve_task_provider if [[ "$TASK_PROVIDER" == "jira" ]]; then log "Task provider is Jira; skipping GitHub issue auto-assign/project-status"