Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ Three config methods (checked in priority order):
4. `ASC_COMPANY_1_KEY_ID`, `ASC_COMPANY_2_KEY_ID`... (multi-company env vars)
5. `ASC_KEY_ID` + `ASC_ISSUER_ID` + `ASC_PRIVATE_KEY_PATH` (single company env vars)

Each company needs: `keyID`, `issuerID`, `privateKeyPath` (path to `.p8` file).
Each company needs: `keyID`, `privateKeyPath` (or `privateKeyContent`); `issuerID` is required for Team Keys and omitted for Individual Keys.

**Individual API Keys**: Omit `ASC_ISSUER_ID` / `ASC_COMPANY_N_ISSUER_ID` (env) or `issuer_id` (JSON) to configure an Individual API Key. Such companies have `Company.issuerID == nil` and `Company.isIndividualKey == true`. JWTService emits `sub: "user"` instead of `iss` per Apple's spec. Individual keys cannot access Provisioning, Sales/Finance, or notaryTool endpoints — calls will fail with 403 at the API level (no pre-validation).
## Architecture

### Core Components
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,54 @@ The server resolves configuration in this order:
5. `ASC_COMPANY_1_KEY_ID` ... (multi-company env vars)
6. `ASC_KEY_ID` + `ASC_ISSUER_ID` (single-company env vars)

### Individual API Keys

App Store Connect supports two types of API keys:

- **Team Keys** — created under *Users and Access → Integrations → Team Keys* (requires Admin role). Access scoped to the team with the key's assigned role.
- **Individual Keys** — created under *user profile → Individual API Key* (available to any team member). Access scoped to the user's own role.

Individual Keys use a different JWT payload per [Apple's specification](https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests): they send `sub: "user"` instead of `iss: <issuerID>`. This server handles the difference transparently — just omit the issuer ID in your configuration.

#### Configure via environment variables

```bash
# Single Individual Key (no ASC_ISSUER_ID)
export ASC_KEY_ID=ABC123DEF4
export ASC_PRIVATE_KEY_PATH=/path/to/AuthKey_ABC123DEF4.p8

# Multi-company: Team + Individual
export ASC_COMPANY_1_KEY_ID=TEAM_KEY_ID
export ASC_COMPANY_1_ISSUER_ID=57246542-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export ASC_COMPANY_1_KEY_PATH=/path/to/TeamKey.p8

export ASC_COMPANY_2_KEY_ID=INDIVIDUAL_KEY_ID
# No ASC_COMPANY_2_ISSUER_ID → treated as Individual Key
export ASC_COMPANY_2_KEY_PATH=/path/to/IndividualKey.p8
```

#### Configure via `companies.json`

Simply omit the `issuer_id` field:

```json
{
"id": "my-individual",
"name": "My Personal Account",
"key_id": "ABC123DEF4",
"key_path": "/path/to/AuthKey_ABC123DEF4.p8"
}
```

#### Limitations of Individual Keys

Per Apple's documentation, Individual Keys **cannot** access:

- Provisioning endpoints (Bundle IDs, Certificates, Profiles, Devices) → `provisioning_*` tools
- Sales and Finance reports → `analytics_*` tools that fetch sales/finance data
- `notaryTool` endpoints

Calls to these endpoints with an Individual Key will fail with HTTP 403 from Apple. Use a Team Key for workflows that require them. This server does not pre-validate — errors surface from the App Store Connect API directly.
### 3. MCP Host Configuration

<details>
Expand Down
16 changes: 10 additions & 6 deletions Sources/asc-mcp/EntryPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ struct ASCMCPApp {
if CommandLine.arguments.contains("--test") {
print("Test mode activated", to: &standardError)

if CommandLine.arguments.contains("--test-metadata") {
try await testAppMetadata()
} else if CommandLine.arguments.contains("--test-switch") {
try await testCompanySwitching()
} else {
try await testCompanySwitching()
do {
if CommandLine.arguments.contains("--test-metadata") {
try await testAppMetadata()
} else if CommandLine.arguments.contains("--test-switch") {
try await testCompanySwitching()
} else {
try await testCompanySwitching()
}
} catch {
print("\n⚠️ Test mode exited early: \(error.localizedDescription)", to: &standardError)
}
Comment on lines +28 to 30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-throw failures in debug test mode

Catching all errors here and then returning makes asc-mcp --test exit successfully even when testAppMetadata() or testCompanySwitching() fails. That regresses test-mode behavior from fail-fast to silent success, which can mask real breakages in local/CI smoke checks that rely on process exit status. Log the error if needed, but propagate it (or call exit(1)) so failed test runs are observable.

Useful? React with 👍 / 👎.

return
}
Expand Down
21 changes: 18 additions & 3 deletions Sources/asc-mcp/Models/Company.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ public struct Company: Codable, Sendable, Identifiable, Equatable {
public let id: String
public let name: String
public let keyID: String
public let issuerID: String
public let issuerID: String?
public let privateKeyPath: String
/// Private key content (PEM string). If set, takes priority over `privateKeyPath`.
public let privateKeyContent: String?
/// Vendor number for sales/financial reports (found in ASC Sales and Trends)
public let vendorNumber: String?

/// True if this company uses an Individual API Key (no issuer ID).
/// Individual keys cannot access Provisioning, Sales/Finance, or notarytool endpoints.
public var isIndividualKey: Bool { issuerID == nil }

enum CodingKeys: String, CodingKey {
case id
case name
Expand All @@ -27,15 +31,15 @@ public struct Company: Codable, Sendable, Identifiable, Equatable {
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
keyID = try container.decode(String.self, forKey: .keyID)
issuerID = try container.decode(String.self, forKey: .issuerID)
issuerID = try container.decodeIfPresent(String.self, forKey: .issuerID)
privateKeyPath = try container.decodeIfPresent(String.self, forKey: .privateKeyPath) ?? ""
privateKeyContent = try container.decodeIfPresent(String.self, forKey: .privateKeyContent)
vendorNumber = try container.decodeIfPresent(String.self, forKey: .vendorNumber)
}

public init(
id: String, name: String,
keyID: String, issuerID: String,
keyID: String, issuerID: String? = nil,
privateKeyPath: String = "",
privateKeyContent: String? = nil,
vendorNumber: String? = nil
Expand All @@ -48,6 +52,17 @@ public struct Company: Codable, Sendable, Identifiable, Equatable {
self.privateKeyContent = privateKeyContent
self.vendorNumber = vendorNumber
}

public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(keyID, forKey: .keyID)
try c.encodeIfPresent(issuerID, forKey: .issuerID)
try c.encode(privateKeyPath, forKey: .privateKeyPath)
try c.encodeIfPresent(privateKeyContent, forKey: .privateKeyContent)
try c.encodeIfPresent(vendorNumber, forKey: .vendorNumber)
}
}

/// Container for all companies
Expand Down
21 changes: 10 additions & 11 deletions Sources/asc-mcp/Services/CompaniesManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ public actor CompaniesManager {
}
print("You can configure asc-mcp using:", to: &standardError)
print(" 1. Environment variables (single company):", to: &standardError)
print(" ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH (or ASC_PRIVATE_KEY)", to: &standardError)
print(" ASC_KEY_ID, ASC_ISSUER_ID (optional for Individual API Keys), ASC_PRIVATE_KEY_PATH (or ASC_PRIVATE_KEY)", to: &standardError)
print(" 2. Environment variables (multiple companies):", to: &standardError)
print(" ASC_COMPANY_1_KEY_ID, ASC_COMPANY_1_ISSUER_ID, ASC_COMPANY_1_KEY_PATH, ...", to: &standardError)
print(" ASC_COMPANY_1_KEY_ID, ASC_COMPANY_1_ISSUER_ID (optional for Individual API Keys), ASC_COMPANY_1_KEY_PATH, ...", to: &standardError)
print(" 3. Config file: --companies /path/to/companies.json", to: &standardError)
print(" 4. Environment: ASC_MCP_COMPANIES=/path/to/companies.json", to: &standardError)
print(" 5. Default path: ~/.config/asc-mcp/companies.json", to: &standardError)
Expand Down Expand Up @@ -107,14 +107,14 @@ public actor CompaniesManager {
/// 1. Multi-company: ASC_COMPANY_1_KEY_ID, ASC_COMPANY_2_KEY_ID, ...
/// 2. Single-company: ASC_KEY_ID, ASC_ISSUER_ID
/// - Returns: CompaniesConfig if env vars found, nil otherwise
private static func loadFromEnvironment() -> CompaniesConfig? {
let env = ProcessInfo.processInfo.environment

static func loadFromEnvironment(
env: [String: String] = ProcessInfo.processInfo.environment
) -> CompaniesConfig? {
// Multi-company mode: ASC_COMPANY_N_KEY_ID
var companies: [Company] = []
var index = 1
while let keyID = env["ASC_COMPANY_\(index)_KEY_ID"],
let issuerID = env["ASC_COMPANY_\(index)_ISSUER_ID"] {
while let keyID = env["ASC_COMPANY_\(index)_KEY_ID"] {
let issuerID = env["ASC_COMPANY_\(index)_ISSUER_ID"]
let keyPath = env["ASC_COMPANY_\(index)_KEY_PATH"] ?? ""
let keyContent = env["ASC_COMPANY_\(index)_KEY"]
let name = env["ASC_COMPANY_\(index)_NAME"] ?? "Company \(index)"
Expand All @@ -138,9 +138,9 @@ public actor CompaniesManager {
return CompaniesConfig(companies: companies)
}

// Single-company mode: ASC_KEY_ID, ASC_ISSUER_ID
guard let keyID = env["ASC_KEY_ID"],
let issuerID = env["ASC_ISSUER_ID"] else { return nil }
// Single-company mode: ASC_KEY_ID, optional ASC_ISSUER_ID
guard let keyID = env["ASC_KEY_ID"] else { return nil }
let issuerID = env["ASC_ISSUER_ID"]
let keyPath = env["ASC_PRIVATE_KEY_PATH"] ?? ""
let keyContent = env["ASC_PRIVATE_KEY"]
guard !keyPath.isEmpty || keyContent != nil else { return nil }
Expand Down Expand Up @@ -209,4 +209,3 @@ public enum CompanyError: LocalizedError, Sendable {
}
}
}

32 changes: 25 additions & 7 deletions Sources/asc-mcp/Services/JWTService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ private struct DecodedJWTPayload: Decodable {
let exp: Int // Unix timestamp
let iat: Int?
let iss: String?
let sub: String?
}

/// JWT token service for App Store Connect API
Expand Down Expand Up @@ -140,12 +141,17 @@ public actor JWTService {
)

// Payload
let payload = JWTPayload(
iss: company.issuerID,
iat: Int(now.timeIntervalSince1970),
exp: Int(expiration.timeIntervalSince1970),
aud: "appstoreconnect-v1"
)
let iat = Int(now.timeIntervalSince1970)
let exp = Int(expiration.timeIntervalSince1970)
let payload: JWTPayload
if let issuerID = company.issuerID {
// Team Key: use iss claim
payload = JWTPayload(iss: issuerID, sub: nil, iat: iat, exp: exp, aud: "appstoreconnect-v1")
} else {
// Individual Key: use sub="user" per Apple spec
// https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
payload = JWTPayload(iss: nil, sub: "user", iat: iat, exp: exp, aud: "appstoreconnect-v1")
}

// Encode header and payload as Base64URL
let headerData = try JSONEncoder().encode(header)
Expand Down Expand Up @@ -215,10 +221,22 @@ private struct JWTHeader: Codable {
}

private struct JWTPayload: Codable {
let iss: String
let iss: String?
let sub: String?
let iat: Int
let exp: Int
let aud: String

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(iss, forKey: .iss)
try container.encodeIfPresent(sub, forKey: .sub)
try container.encode(iat, forKey: .iat)
try container.encode(exp, forKey: .exp)
try container.encode(aud, forKey: .aud)
}

private enum CodingKeys: String, CodingKey { case iss, sub, iat, exp, aud }
}

// MARK: - Base64URL Extension
Expand Down
31 changes: 22 additions & 9 deletions Sources/asc-mcp/Tests/TestCompanySwitching.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ public func testCompanySwitching() async throws {
print("\n🧪 TEST: Company switching\n", to: &standardError)

// 1. Create workers using factory method
let companiesManager = try CompaniesManager()
let companiesManager: CompaniesManager
do {
companiesManager = try CompaniesManager()
} catch {
print("⚠️ Skipping company switching test: \(error.localizedDescription)", to: &standardError)
return
}
let companiesWorker = CompaniesWorker(manager: companiesManager)
let _ = try await WorkerManager.createForProduction(companiesWorker: companiesWorker)

Expand All @@ -26,11 +32,15 @@ public func testCompanySwitching() async throws {
let company1 = companies[0]
print("✅ Active company: \(company1.name)", to: &standardError)
print(" Key ID: \(company1.keyID)", to: &standardError)
print(" Issuer ID: \(company1.issuerID)", to: &standardError)
if let issuerID = company1.issuerID {
print(" Key Type: Team Key (Issuer: ****\(issuerID.suffix(4)))", to: &standardError)
} else {
print(" Key Type: Individual Key", to: &standardError)
}

// Create AuthWorker for first company
let jwtService1 = try JWTService(company: company1)
let authWorker1 = AuthWorker(jwtService: jwtService1)
_ = AuthWorker(jwtService: jwtService1)
print("✅ AuthWorker created for company 1", to: &standardError)

// Test app listing for first company
Expand All @@ -45,7 +55,7 @@ public func testCompanySwitching() async throws {
)

let result1 = try await appsWorker1.listApps(listParams1)
if case .text(let text) = result1.content.first {
if case .text(text: let text, annotations: _, _meta: _) = result1.content.first {
print(text, to: &standardError)
}

Expand All @@ -56,11 +66,15 @@ public func testCompanySwitching() async throws {
let company2 = try await companiesWorker.manager.getCurrentCompany()
print("✅ Active company: \(company2.name)", to: &standardError)
print(" Key ID: \(company2.keyID)", to: &standardError)
print(" Issuer ID: \(company2.issuerID)", to: &standardError)
if let issuerID = company2.issuerID {
print(" Key Type: Team Key (Issuer: ****\(issuerID.suffix(4)))", to: &standardError)
} else {
print(" Key Type: Individual Key", to: &standardError)
}

// Create AuthWorker for second company
let jwtService2 = try JWTService(company: company2)
let authWorker2 = AuthWorker(jwtService: jwtService2)
_ = AuthWorker(jwtService: jwtService2)
print("✅ AuthWorker created for company 2", to: &standardError)

// Verify configurations are different
Expand All @@ -81,15 +95,15 @@ public func testCompanySwitching() async throws {
)

let result2 = try await appsWorker2.listApps(listParams2)
if case .text(let text) = result2.content.first {
if case .text(text: let text, annotations: _, _meta: _) = result2.content.first {
print(text, to: &standardError)
}

// Test WorkerManager switching
print("\n🔄 TEST 5: Test switching via WorkerManager", to: &standardError)

// Simulate company_switch call via WorkerManager
let switchParams = CallTool.Parameters(
_ = CallTool.Parameters(
name: "company_switch",
arguments: ["company_id": .string(companies[0].id)]
)
Expand All @@ -103,4 +117,3 @@ public func testCompanySwitching() async throws {

print("\n✅ ALL TESTS COMPLETED", to: &standardError)
}

Loading