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: 0 additions & 113 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"))
Expand Down
69 changes: 0 additions & 69 deletions Packages/CrowCore/Sources/CrowCore/AtlassianMCPResolver.swift

This file was deleted.

55 changes: 55 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/JiraCredentialResolver.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
Loading
Loading