Keep built-in MCP server settings local

This commit is contained in:
Kaede Akino committed 2026-09-12 12:03:02 +08:00
1 parent e7406ea292
commit 5ee3cded0a
12 files changed
+239 -21

No files matched your search

+9 -5
View File
@@ -591,7 +591,11 @@ the application models without entering the local mutation path. Web Search and
Terminal preferences are migrated from UserDefaults once, then mirrored there
for existing runtime readers. Active agent runs retain their configuration snapshots.
Conversations, approval mode, local MCP listener settings and other app preferences
are excluded. Only explicitly selected settings are projected, never all defaults.
are excluded. The listener's enabled state, port and exposed-tool selection remain in
device-local `UserDefaults`; its bearer token uses a dedicated this-device-only Data
Protection Keychain path. Older synchronized listener tokens are copied and verified
locally before their iCloud Keychain copy is removed. Only explicitly selected settings
are projected, never all defaults.
The journal retains tombstones and unresolved references across partial fetches.
Preexisting defaults use baseline versions and yield to cloud records. Built-in
@@ -599,14 +603,14 @@ skills use stable IDs; migration preserves customized skills and records deleted
built-ins. Duplicate skill names or MCP namespaces retain their data but only the
lowest UUID is eligible for execution until the conflict is resolved.
AI credentials use `AISecretStore`. First Settings sync checks the iCloud account
before migrating local Keychain entries. A verified cloud copy and durable local
AI provider and remote MCP client credentials use `AISecretStore`. First Settings sync
checks the iCloud account before migrating local Keychain entries. A verified cloud copy and durable local
storage marker precede local-copy cleanup. Migrated entries remain in iCloud
Keychain when configuration sync pauses. Production reads do not cache secrets;
missing synchronized credentials may arrive later. Pending credential deletion
markers prevent delayed Keychain deliveries from reviving a cleared value.
Explicit credentials stay out of CloudKit; custom MCP headers and custom terminal
configuration are ordinary user-authored settings. The private-account sync
The built-in MCP listener token never enters this path. Explicit credentials stay out
of CloudKit; custom MCP headers and custom terminal configuration are ordinary user-authored settings. The private-account sync
adapter is the only additional trusted source of skill-library snapshots.
## Mobile presentation and workspace activities
+3
View File
@@ -27,6 +27,9 @@ Before shipping the app and privacy policy 1.4 together:
independent. Repeat with the master disabled and after Pro expiration.
- Check light/dark themes, fonts, custom terminal text and shell-integration
changes on existing windows; ensure conversations and approval mode stay local.
- With different built-in MCP listener settings on both Macs, verify that enabled
state, port, exposed tools and bearer token remain independent. An older synchronized
listener token must be verified locally before its iCloud Keychain copy is removed.
- Match the privacy policy effective date to the actual release date before
publishing the website. The prepared date is September 10, 2026.
+4 -1
View File
@@ -17,6 +17,7 @@ struct AppDependencies {
let settingsSync: SettingsSyncCoordinator?
let vaultSync: VaultSyncCoordinator?
let forwardingDefaults: UserDefaults?
let mcpServerSecrets: any SecretStoring
init(
hostRepository: any HostRepository, outboxRunner: OutboxRunner?,
@@ -25,7 +26,8 @@ struct AppDependencies {
agentRunRepository: (any AgentRunPersisting)? = nil,
vaultSync: VaultSyncCoordinator? = nil,
settingsSync: SettingsSyncCoordinator? = nil,
forwardingDefaults: UserDefaults? = nil
forwardingDefaults: UserDefaults? = nil,
mcpServerSecrets: any SecretStoring = InMemorySecretStore()
) {
self.hostRepository = hostRepository
self.outboxRunner = outboxRunner
@@ -35,6 +37,7 @@ struct AppDependencies {
self.vaultSync = vaultSync
self.settingsSync = settingsSync
self.forwardingDefaults = forwardingDefaults
self.mcpServerSecrets = mcpServerSecrets
}
static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self {
+2 -1
View File
@@ -203,7 +203,8 @@ final class AppModel {
)
outboxRunner = dependencies.outboxRunner
mcpServer = MCPServerController(
ai: ai, hosts: agentHosts, metrics: serverMetrics, connections: agentConnections,
secrets: dependencies.mcpServerSecrets, hosts: agentHosts, metrics: serverMetrics,
connections: agentConnections,
authentication: agentAuthentication, background: agentBackgroundCommands,
externalAuthentication: externalAuthentication, history: externalToolCalls,
canUsePro: { proAccess.canUse(.mcpServer) },
+1
View File
@@ -18,6 +18,7 @@ nonisolated enum DesktopSettingsKey {
/// App-wide appearance. `system` inherits changes from macOS.
static let appAppearance = "app.appearance"
// Built-in MCP listener preferences are device-local and never enter Settings sync.
static let mcpServerEnabled = "mcpServer.enabled"
static let mcpServerPort = "mcpServer.port"
static let mcpServerDisabledTools = "mcpServer.disabledTools"
+25 -12
View File
@@ -6,8 +6,6 @@ import TermindRemoteInfrastructure
@MainActor
@Observable
final class MCPServerController {
static let tokenReference = "termind.ai.mcpserver.token"
enum Status: Equatable {
case stopped, starting
case ready(port: UInt16)
@@ -25,7 +23,7 @@ final class MCPServerController {
}
private let defaults: UserDefaults
private let ai: AIConfigurationModel
private let tokenStore: MCPServerTokenStore
private let metrics: any MetricsSnapshotReading
private let hosts: AgentHostResolver
private let connections: AgentConnectionPool
@@ -43,9 +41,11 @@ final class MCPServerController {
private(set) var isEnabled: Bool
private(set) var port: Int
private(set) var disabledTools: Set<String>
private(set) var token = ""
private(set) var tokenStorageError: String?
init(
ai: AIConfigurationModel, hosts: AgentHostResolver, metrics: any MetricsSnapshotReading,
secrets: any SecretStoring, hosts: AgentHostResolver, metrics: any MetricsSnapshotReading,
connections: AgentConnectionPool,
authentication: AgentSSHAuthenticationRegistry,
background: AgentBackgroundCommands,
@@ -55,7 +55,7 @@ final class MCPServerController {
requestPro: @escaping @MainActor () -> Bool = { true },
defaults: UserDefaults = .standard
) {
self.ai = ai
tokenStore = MCPServerTokenStore(secrets: secrets)
self.metrics = metrics
self.hosts = hosts
self.connections = connections
@@ -79,6 +79,7 @@ final class MCPServerController {
history: history, disabledTools: initialDisabledTools
)
protocolServer = MCPServerProtocol(tools: tools)
reloadToken()
Task { [weak self, protocolServer] in
await protocolServer.setClientCountHandler { [weak self] count in
Task { @MainActor in self?.connectedClients = count }
@@ -87,10 +88,6 @@ final class MCPServerController {
if isEnabled, canUsePro() { Task { [weak self] in await self?.start() } }
}
var token: String {
ai.secret(for: Self.tokenReference) ?? ""
}
func setEnabled(_ enabled: Bool) {
if enabled, !requestPro() { return }
isEnabled = enabled
@@ -135,9 +132,13 @@ final class MCPServerController {
status = .failed(String(localized: "Unable to generate a secure token."))
return
}
ai.setSecret(generated, for: Self.tokenReference)
guard ai.secretStorageError == nil else {
status = .failed(ai.secretStorageError ?? String(localized: "Unable to store the token."))
do {
try tokenStore.save(generated)
token = generated
tokenStorageError = nil
} catch {
tokenStorageError = error.localizedDescription
status = .failed(error.localizedDescription)
return
}
Task { [weak self] in
@@ -148,6 +149,8 @@ final class MCPServerController {
}
func ensureToken() -> String? {
if token.isEmpty { reloadToken() }
guard tokenStorageError == nil else { return nil }
if !token.isEmpty { return token }
rotateToken()
return token.isEmpty ? nil : token
@@ -204,6 +207,16 @@ final class MCPServerController {
.intersection(MCPServerTools.exportedNames)
}
private func reloadToken() {
do {
token = try tokenStore.load() ?? ""
tokenStorageError = nil
} catch {
tokenStorageError = error.localizedDescription
status = .failed(error.localizedDescription)
}
}
private static func makeToken() throws -> String {
var bytes = [UInt8](repeating: 0, count: 32)
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
+1 -1
View File
@@ -104,7 +104,7 @@ struct TermindApp: App {
context: container.mainContext
),
vaultSync: vaultSync, settingsSync: settingsSync,
forwardingDefaults: .standard
forwardingDefaults: .standard, mcpServerSecrets: secrets
),
proAccess: proAccess,
appReview: .live()
@@ -70,6 +70,11 @@ struct MCPServerSettings: View {
.foregroundStyle(.secondary)
}
}
if let error = server.tokenStorageError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
clientSetupSection
@@ -0,0 +1,54 @@
import Foundation
import TermindApplication
import TermindDomain
/// Keeps the built-in MCP server credential on this Mac, independently of AI
/// provider and remote MCP client credential sync.
nonisolated struct MCPServerTokenStore: Sendable {
static let reference = "termind.ai.mcpserver.token"
private let secrets: any SecretStoring
init(secrets: any SecretStoring) {
self.secrets = secrets
}
/// Moves the value written by older releases out of iCloud Keychain.
///
/// A local value always wins when both locations contain a token. When only
/// the synchronized copy exists, it is verified locally before that source
/// is removed. A failed copy or delete is safe to retry on the next read.
func load() throws -> String? {
let local = try secrets.string(for: Self.reference, storage: .thisDevice)
guard let synchronized = try secrets.string(for: Self.reference, storage: .iCloud) else {
return local
}
let value: String
if let local {
value = local
} else {
try secrets.setString(synchronized, for: Self.reference, storage: .thisDevice)
guard try secrets.string(for: Self.reference, storage: .thisDevice) == synchronized else {
throw CocoaError(.coderInvalidValue)
}
value = synchronized
}
try secrets.setString(nil, for: Self.reference, storage: .iCloud)
return value
}
func save(_ value: String) throws {
let previous = try load()
do {
try secrets.setString(value, for: Self.reference, storage: .thisDevice)
guard try secrets.string(for: Self.reference, storage: .thisDevice) == value else {
throw CocoaError(.coderInvalidValue)
}
} catch {
try? secrets.setString(previous, for: Self.reference, storage: .thisDevice)
throw error
}
}
}
+8 -1
View File
@@ -87,9 +87,16 @@ struct ArchitectureBoundaryTests {
let records = try String(
contentsOf: sourceRoot.appending(path: "../Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift"),
encoding: .utf8)
for excluded in ["AgentPermissionMode", "AgentRun", "AgentConversation", "mcpServerPort", "mcpServerEnabled"] {
for excluded in [
"AgentPermissionMode", "AgentRun", "AgentConversation", "mcpServerPort", "mcpServerEnabled",
"mcpServerDisabledTools", "mcpserver.token",
] {
#expect(!records.contains(excluded))
}
let localServer = try String(
contentsOf: sourceRoot.appending(path: "App/MCPServerController.swift"), encoding: .utf8)
#expect(localServer.contains("MCPServerTokenStore"))
#expect(!localServer.contains("AIConfigurationModel"))
let terminal = try String(
contentsOf: sourceRoot.appending(
path: "../Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift"),
+108
View File
@@ -0,0 +1,108 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@Suite
struct MCPServerTokenStoreTests {
@Test
func savesNewTokensOnlyOnThisDevice() throws {
let secrets = MCPServerTokenTestSecrets()
let store = MCPServerTokenStore(secrets: secrets)
try store.save("local-token")
#expect(secrets.value(storage: .thisDevice) == "local-token")
#expect(secrets.value(storage: .iCloud) == nil)
#expect(secrets.synchronizedValuesWritten.isEmpty)
}
@Test
func migratesACloudTokenOnlyAfterVerifyingTheLocalCopy() throws {
let secrets = MCPServerTokenTestSecrets(cloud: "legacy-token")
let store = MCPServerTokenStore(secrets: secrets)
#expect(try store.load() == "legacy-token")
#expect(secrets.value(storage: .thisDevice) == "legacy-token")
#expect(secrets.value(storage: .iCloud) == nil)
}
@Test
func localTokenWinsWhenBothLocationsContainValues() throws {
let secrets = MCPServerTokenTestSecrets(local: "device-token", cloud: "shared-token")
let store = MCPServerTokenStore(secrets: secrets)
#expect(try store.load() == "device-token")
#expect(secrets.value(storage: .thisDevice) == "device-token")
#expect(secrets.value(storage: .iCloud) == nil)
}
@Test
func failedLocalCopyKeepsTheOnlyCloudToken() {
let secrets = MCPServerTokenTestSecrets(cloud: "legacy-token")
secrets.failLocalWrites = true
let store = MCPServerTokenStore(secrets: secrets)
#expect(throws: (any Error).self) { try store.load() }
#expect(secrets.value(storage: .thisDevice) == nil)
#expect(secrets.value(storage: .iCloud) == "legacy-token")
}
@Test
func failedCloudCleanupRetainsBothCopiesAndCanRetry() throws {
let secrets = MCPServerTokenTestSecrets(cloud: "legacy-token")
secrets.failCloudDeletes = true
let store = MCPServerTokenStore(secrets: secrets)
#expect(throws: (any Error).self) { try store.load() }
#expect(secrets.value(storage: .thisDevice) == "legacy-token")
#expect(secrets.value(storage: .iCloud) == "legacy-token")
secrets.failCloudDeletes = false
#expect(try store.load() == "legacy-token")
#expect(secrets.value(storage: .iCloud) == nil)
}
}
private nonisolated final class MCPServerTokenTestSecrets: SecretStoring, @unchecked Sendable {
private let lock = NSLock()
private var values: [PasswordStorage: String] = [:]
private(set) var synchronizedValuesWritten: [String] = []
var failLocalWrites = false
var failCloudDeletes = false
init(local: String? = nil, cloud: String? = nil) {
values[.thisDevice] = local
values[.iCloud] = cloud
}
func value(storage: PasswordStorage) -> String? {
lock.withLock { values[storage] }
}
func string(for account: String, storage: PasswordStorage) throws -> String? {
lock.withLock { values[storage] }
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
try lock.withLock {
if storage == .thisDevice, value != nil, failLocalWrites {
throw CocoaError(.fileWriteNoPermission)
}
if storage == .iCloud, value == nil, failCloudDeletes {
throw CocoaError(.fileWriteNoPermission)
}
values[storage] = value
if storage == .iCloud, let value { synchronizedValuesWritten.append(value) }
}
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { nil }
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {}
func removePassword(_ account: String, storage: PasswordStorage) throws {
try setString(nil, for: account, storage: storage)
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {}
}
+19
View File
@@ -204,6 +204,7 @@ struct SettingsSyncTests {
let json = String(decoding: data, as: UTF8.self)
#expect(!json.contains("permissionMode"))
#expect(!json.contains("lastConnectedAt"))
#expect(!json.contains(MCPServerTokenStore.reference))
let record = try SettingsSyncCoordinator.makeRecord(
SettingsOutgoingEntry(envelope: value),
recordID: CKRecord.ID(
@@ -426,6 +427,24 @@ struct SettingsSyncTests {
#expect(try secrets.string(for: ref) == nil)
}
@Test
func remoteMCPTokenMigratesWithoutSelectingTheBuiltInServerToken() throws {
let (container, _, _) = try fixture()
let keychain = SyncTestSecrets()
let secrets = AISecretStore(context: container.mainContext, keychain: keychain)
let remoteReference = "termind.ai.mcp.remote"
try secrets.setString("remote", for: remoteReference)
try keychain.setString(
"listener", for: MCPServerTokenStore.reference, storage: .thisDevice)
secrets.uploadsAuthorized = true
try secrets.migrate([remoteReference])
#expect(try keychain.string(for: remoteReference, storage: .iCloud) == "remote")
#expect(try keychain.string(for: MCPServerTokenStore.reference, storage: .thisDevice) == "listener")
#expect(try keychain.string(for: MCPServerTokenStore.reference, storage: .iCloud) == nil)
}
@Test
func remoteKeyCanArriveAfterConfigurationWithoutUsingStaleLocalCopy() throws {
let (container, _, _) = try fixture()