Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,119 @@ 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)
Expand Down
11 changes: 5 additions & 6 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ public actor ClaudeLauncher {
/// "study the ticket" fetch command.
/// - codeProvider: the **code** provider (where the PR lives) — drives the
/// "open a PR/MR" step. Defaults to `provider` when `nil`. For a Jira-task
/// + GitHub-code session these differ: the ticket is fetched with `acli`
/// while the PR is still opened with `gh` (ADR 0005 cross-backend pairing).
/// + GitHub-code session these differ: the ticket is fetched via the
/// Atlassian MCP server while the PR is still opened with `gh` (ADR 0005
/// cross-backend pairing; CROW-522 migrated Jira off `acli`).
public func generatePrompt(
session: Session,
worktrees: [SessionWorktree],
Expand Down Expand Up @@ -54,11 +55,9 @@ public actor ClaudeLauncher {
case .jira:
lines.append("")
if let key = Validation.jiraKey(from: url) {
lines.append("```bash")
lines.append("acli jira workitem view \(key) --fields summary,status,description,comment")
lines.append("```")
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.")
} else {
lines.append("URL: \(url)")
lines.append("URL: \(url) — fetch it via the Atlassian MCP server (`getJiraIssue`).")
}
case .corveil, nil:
lines.append("URL: \(url)")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ import Testing

@Test func generatePromptWithJiraTaskAndGitHubCode() async {
// Cross-backend session (ADR 0005): Jira task + GitHub code. The ticket is
// fetched with `acli`, but the PR step still uses `gh`.
// fetched via the Atlassian MCP server (CROW-522), but the PR step still
// uses `gh`.
let launcher = ClaudeLauncher()
let session = Session(name: "test-session", ticketNumber: 7)

Expand All @@ -69,8 +70,10 @@ import Testing
codeProvider: .github
)

// Ticket step routes through acli with the extracted key.
#expect(prompt.contains("acli jira workitem view PROJ-7"))
// Ticket step routes through the Atlassian MCP with the extracted key — not acli.
#expect(prompt.contains("getJiraIssue"))
#expect(prompt.contains("PROJ-7"))
#expect(!prompt.contains("acli jira workitem"))
#expect(!prompt.contains("gh issue view"))
// PR step routes through the code backend (GitHub).
#expect(prompt.contains("gh pr create"))
Expand Down
69 changes: 69 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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)")
}
}
Loading
Loading