Skip to content
Open
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
51 changes: 51 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
name: Build & Publish Release
runs-on: macos-15
steps:
- uses: actions/checkout@v5

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_26.2.app

- name: Build universal binary (arm64 + x86_64)
run: swift build -c release --arch arm64 --arch x86_64

- name: Package binaries
id: pkg
run: |
BIN_DIR=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)
file "$BIN_DIR/asc-mcp"

mkdir -p stage && cp "$BIN_DIR/asc-mcp" stage/asc-mcp
ARM="asc-mcp-${GITHUB_REF_NAME}-darwin-arm64.tar.gz"
X64="asc-mcp-${GITHUB_REF_NAME}-darwin-x86_64.tar.gz"
tar -czf "$ARM" -C stage asc-mcp
cp "$ARM" "$X64"
shasum -a 256 "$ARM" > "${ARM}.sha256"
shasum -a 256 "$X64" > "${X64}.sha256"
echo "arm=$ARM" >> "$GITHUB_OUTPUT"
echo "x64=$X64" >> "$GITHUB_OUTPUT"

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
${{ steps.pkg.outputs.arm }}
${{ steps.pkg.outputs.arm }}.sha256
${{ steps.pkg.outputs.x64 }}
${{ steps.pkg.outputs.x64 }}.sha256
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 +19 to 21
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