194 lines
9.3 KiB
Swift
194 lines
9.3 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindInfrastructure
|
|
import Testing
|
|
|
|
@Suite @MainActor
|
|
struct MCPImportTests {
|
|
@Test func importsSupportedTransportsInNameOrderWithoutConnecting() throws {
|
|
let document = try parse([
|
|
"Zulu": ["url": "https://example.com/mcp", "type": "sse"],
|
|
"Alpha": ["url": "http://localhost:8080/mcp", "headers": ["X-Tenant": "tenant"]],
|
|
"Bravo": ["url": "https://example.com/mcp", "type": "http"],
|
|
"Charlie": ["url": "https://example.com/mcp", "type": "streamable-http"],
|
|
"Delta": ["url": "https://example.com/mcp", "type": "streamableHTTP"],
|
|
])
|
|
let ai = AIConfigurationModel(persistsSecrets: false)
|
|
let report = ai.importMCP(document)
|
|
#expect(report.importedCount == 5)
|
|
#expect(report.issues.isEmpty)
|
|
#expect(ai.mcpServers.map(\.name) == ["Alpha", "Bravo", "Charlie", "Delta", "Zulu"])
|
|
#expect(ai.mcpServers.last?.transport == .sse)
|
|
#expect(ai.mcpServers.dropLast().allSatisfy { $0.transport == .streamableHTTP })
|
|
#expect(ai.mcpServers.allSatisfy { $0.isEnabled && $0.tools.isEmpty && $0.lastConnectedAt == nil })
|
|
#expect(ai.mcpServers.first?.headers.first?.value == "tenant")
|
|
}
|
|
|
|
@Test func namesAndNamespacesRemainUniqueAcrossRepeatedImports() throws {
|
|
let existing = MCPServer(name: "Demo", url: "https://existing.example/mcp")
|
|
let ai = AIConfigurationModel(mcpServers: [existing], persistsSecrets: false)
|
|
let document = try parse([
|
|
"Demo": ["url": "https://example.com/mcp"],
|
|
"demo": ["url": "https://example.com/mcp"],
|
|
])
|
|
#expect(ai.importMCP(document).importedCount == 2)
|
|
#expect(ai.importMCP(document).importedCount == 2)
|
|
#expect(ai.mcpServers.map(\.name) == ["Demo", "Demo 2", "demo 3", "Demo 4", "demo 5"])
|
|
#expect(Set(ai.mcpServers.map(\.effectiveNamespaceID)).count == 5)
|
|
#expect(ai.mcpServers.first == existing)
|
|
}
|
|
|
|
@Test func mixedDocumentsReportUnsupportedEntriesAndKeepValidServers() throws {
|
|
let document = try parse([
|
|
"a": ["command": "node", "args": ["server.js"]],
|
|
"b": ["url": "file:///tmp/mcp"],
|
|
"c": ["url": "https://example.com", "type": "unknown"],
|
|
"d": ["url": "${MCP_URL}"],
|
|
"e": ["url": "https://example.com", "headers": ["X-Tenant": "${TENANT}"]],
|
|
"f": ["url": "https://example.com"],
|
|
"g": ["url": "https://example.com", "headers": ["X-Tenant": 5]],
|
|
])
|
|
#expect(document.entries.map(\.name) == ["f"])
|
|
#expect(document.issues.map(\.index) == [1, 2, 3, 4, 5, 7])
|
|
#expect(
|
|
document.issues.map(\.problem) == [
|
|
.unsupportedTransport, .invalidURL, .unsupportedTransport, .unresolvedVariable,
|
|
.unresolvedVariable, .invalidEntry,
|
|
])
|
|
#expect(AIConfigurationModel(persistsSecrets: false).importMCP(document).importedCount == 1)
|
|
}
|
|
|
|
@Test func invalidDocumentsFailBeforeAnyWrites() {
|
|
for text in ["{", "[]", "{}", #"{"mcpServers":[]}"#] {
|
|
#expect(throws: MCPImportProblem.invalidDocument) { try MCPImporter.parse(Data(text.utf8)) }
|
|
}
|
|
#expect(throws: MCPImportProblem.invalidDocument) {
|
|
try MCPImporter.parse(Data(repeating: 32, count: MCPImporter.maximumBytes + 1))
|
|
}
|
|
}
|
|
|
|
@Test func bearerMaterialIsStoredSeparatelyFromConfiguration() throws {
|
|
let document = try parse([
|
|
"Secure": [
|
|
"url": "https://example.com/mcp",
|
|
"headers": ["authorization": "Bearer fixture-secret", "X-Tenant": "tenant"],
|
|
]
|
|
])
|
|
let secrets = ImportSecrets()
|
|
let persistence = ImportPersistence()
|
|
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
|
|
#expect(ai.importMCP(document).importedCount == 1)
|
|
let server = try #require(persistence.snapshot.mcpServers.first)
|
|
#expect(server.headers.map(\.name) == ["X-Tenant"])
|
|
#expect(!server.credentialRef.isEmpty)
|
|
#expect(secrets.values[server.credentialRef] == "fixture-secret")
|
|
#expect(!String(describing: persistence.snapshot).contains("fixture-secret"))
|
|
}
|
|
|
|
@Test func unsupportedAuthenticationAndVariablesNeverEnterReports() throws {
|
|
let document = try parse([
|
|
"a": ["url": "https://example.com", "headers": ["Authorization": "Basic fixture-secret"]],
|
|
"b": ["url": "https://example.com", "headers": ["X-API-Key": "fixture-secret"]],
|
|
"c": ["url": "https://example.com", "headers": ["Cookie": "fixture-secret"]],
|
|
"d": ["url": "https://user:fixture-secret@example.com"],
|
|
"e": ["url": "https://example.com?api_key=fixture-secret"],
|
|
"f": ["url": "https://example.com", "bearer_token_env_var": "TOKEN"],
|
|
"g": ["url": "https://example.com", "headers": ["Authorization": "Bearer $TOKEN"]],
|
|
"h": ["url": "https://example.com", "headers": ["X-Tenant": "bad\r\nvalue"]],
|
|
])
|
|
#expect(document.entries.isEmpty)
|
|
let report = AIConfigurationModel(persistsSecrets: false).importMCP(document)
|
|
#expect(report.issues.count == 8)
|
|
#expect(!String(describing: report).contains("fixture-secret"))
|
|
}
|
|
|
|
@Test func persistenceFailureRollsBackOnlyTheFailedServerAndItsCredential() throws {
|
|
let persistence = ImportPersistence()
|
|
persistence.failName = "b"
|
|
let secrets = ImportSecrets()
|
|
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
|
|
let document = try parse([
|
|
"a": ["url": "https://example.com"],
|
|
"b": ["url": "https://example.com", "headers": ["Authorization": "Bearer fixture-secret"]],
|
|
"c": ["url": "https://example.com"],
|
|
])
|
|
let report = ai.importMCP(document)
|
|
#expect(report.importedCount == 2)
|
|
#expect(report.issues.map(\.problem) == [.saveFailed])
|
|
#expect(ai.mcpServers.map(\.name) == ["a", "c"])
|
|
#expect(persistence.snapshot.mcpServers.map(\.name) == ["a", "c"])
|
|
#expect(secrets.values.isEmpty)
|
|
}
|
|
|
|
@Test func credentialFailureDoesNotCreateAnAnonymousServer() throws {
|
|
let secrets = ImportSecrets()
|
|
secrets.failWrite = true
|
|
let ai = AIConfigurationModel(aiSecrets: secrets)
|
|
let document = try parse([
|
|
"a": ["url": "https://example.com", "headers": ["Authorization": "Bearer fixture-secret"]]
|
|
])
|
|
let report = ai.importMCP(document)
|
|
#expect(report.importedCount == 0)
|
|
#expect(report.issues.map(\.problem) == [.secretFailed])
|
|
#expect(ai.mcpServers.isEmpty)
|
|
#expect(secrets.values.isEmpty)
|
|
}
|
|
|
|
@Test func failedCredentialCleanupIsReported() throws {
|
|
let persistence = ImportPersistence()
|
|
persistence.failName = "a"
|
|
let secrets = ImportSecrets()
|
|
secrets.failRemoval = true
|
|
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
|
|
let document = try parse([
|
|
"a": ["url": "https://example.com", "headers": ["Authorization": "Bearer fixture-secret"]]
|
|
])
|
|
let report = ai.importMCP(document)
|
|
#expect(report.issues.map(\.problem) == [.secretCleanupFailed])
|
|
#expect(ai.mcpServers.isEmpty)
|
|
}
|
|
|
|
@Test func readsRegularFilesAndRejectsDirectoriesAndOversizedFiles() throws {
|
|
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let file = directory.appending(path: "mcp.json")
|
|
try Data(#"{"mcpServers":{"demo":{"url":"https://example.com"}}}"#.utf8).write(to: file)
|
|
#expect(try MCPImporter.read(file).entries.count == 1)
|
|
#expect(throws: MCPImportProblem.unreadableFile) { try MCPImporter.read(directory) }
|
|
try Data(repeating: 32, count: MCPImporter.maximumBytes + 1).write(to: file)
|
|
#expect(throws: MCPImportProblem.unreadableFile) { try MCPImporter.read(file) }
|
|
}
|
|
|
|
private func parse(_ servers: [String: Any]) throws -> MCPImportDocument {
|
|
try MCPImporter.parse(JSONSerialization.data(withJSONObject: ["mcpServers": servers]))
|
|
}
|
|
}
|
|
|
|
@MainActor private final class ImportSecrets: AISecretStoring {
|
|
var values: [String: String] = [:]
|
|
var failWrite = false
|
|
var failRemoval = false
|
|
func string(for reference: String) throws -> String? { values[reference] }
|
|
func setString(_ value: String?, for reference: String) throws {
|
|
if value != nil && failWrite || value == nil && failRemoval { throw CocoaError(.fileWriteNoPermission) }
|
|
values[reference] = value
|
|
}
|
|
}
|
|
|
|
@MainActor private final class ImportPersistence: AIConfigurationPersisting {
|
|
var snapshot = AIConfigurationSnapshot(providers: [], roles: [:])
|
|
var failName: String?
|
|
func load() throws -> AIConfigurationSnapshot { snapshot }
|
|
func save(_ snapshot: AIConfigurationSnapshot) throws {
|
|
if let failName, snapshot.mcpServers.contains(where: { $0.name == failName }) {
|
|
throw CocoaError(.fileWriteNoPermission)
|
|
}
|
|
self.snapshot = snapshot
|
|
}
|
|
}
|