Files
Termind/TFMTests/MCPServerToolsTests.swift

273 lines
11 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.serialized)
@MainActor
struct MCPServerToolsTests {
@Test
func downloadTicketAndStatusUseTheExecutorWithoutOpeningSSHOrLocalFiles() async throws {
let host = TermindDomain.Host(name: "Download", host: "download.example")
let server = makeMCPServerTools(hosts: [host])
server.configureTransfers(port: 7391)
let runID = UUID()
let broker = SecretBroker()
let result = try await server.call(
name: "sftp_download",
arguments: .object([
"host_id": .string(host.id.uuidString), "remote_path": .string("/source/a.zip"),
]), runID: runID, clientName: "Tests", callID: "download", secretBroker: broker
)
#expect(!result.isError)
let payload = try #require(result.untrustedPayload)
let ticket = try #require(try AgentJSONValue.decode(Data(payload.utf8)).objectValue)
let token = try #require(ticket["download_token"]?.stringValue)
#expect(token.count == 43)
#expect(ticket["method"] == .string("GET"))
#expect(ticket["headers"]?.objectValue?["Authorization"] == .string("Bearer \(token)"))
#expect(ticket["download_url"]?.stringValue?.hasPrefix("http://127.0.0.1:7391/downloads/") == true)
let status = try await server.call(
name: "sftp_download_status", arguments: .object(["download_id": ticket["download_id"]!]),
runID: runID, clientName: "Tests", callID: "status", secretBroker: broker
)
#expect(!status.isError)
let initial = try #require(try AgentJSONValue.decode(Data(status.untrustedPayload!.utf8)).objectValue)
#expect(initial["status"] == .string("waiting"))
#expect(initial["byte_count"] == .null)
#expect(initial["sha256"] == .null)
let crossSession = try await server.call(
name: "sftp_download_status", arguments: .object(["download_id": ticket["download_id"]!]),
runID: UUID(), clientName: "Other", callID: "other", secretBroker: SecretBroker()
)
#expect(crossSession.isError)
await server.endSession(runID: runID, secretBroker: broker)
#expect(throws: MCPDownloadError.self) {
try server.downloads.status(id: UUID(uuidString: ticket["download_id"]!.stringValue!)!, runID: runID)
}
}
@Test
func externalUploadTicketAndStatusUseTheMCPExecutorWithoutOpeningFilesOrSSH() async throws {
let host = TermindDomain.Host(name: "Upload", host: "upload.example")
let server = makeMCPServerTools(hosts: [host])
server.uploads.configure(port: 7391)
let runID = UUID()
let broker = SecretBroker()
let result = try await server.call(
name: "sftp_upload",
arguments: .object([
"host_id": .string(host.id.uuidString), "remote_path": .string("/target/a.zip"),
"byte_count": .number(0),
"sha256": .string("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
]), runID: runID, clientName: "Tests", callID: "upload", secretBroker: broker
)
#expect(!result.isError)
let payload = try #require(result.untrustedPayload)
let ticket = try #require(try AgentJSONValue.decode(Data(payload.utf8)).objectValue)
let token = try #require(ticket["upload_token"]?.stringValue)
#expect(token.count == 43)
#expect(ticket["headers"]?.objectValue?["Authorization"] == .string("Bearer \(token)"))
#expect(ticket["upload_url"]?.stringValue?.hasPrefix("http://127.0.0.1:7391/uploads/") == true)
#expect(ticket["method"] == .string("PUT"))
let status = try await server.call(
name: "sftp_upload_status", arguments: .object(["upload_id": ticket["upload_id"]!]),
runID: runID, clientName: "Tests", callID: "status", secretBroker: broker
)
#expect(!status.isError)
#expect(status.untrustedPayload?.contains("waiting") == true)
let other = try await server.call(
name: "sftp_upload_status", arguments: .object(["upload_id": ticket["upload_id"]!]),
runID: UUID(), clientName: "Other", callID: "status-other", secretBroker: SecretBroker()
)
#expect(other.isError)
await server.endSession(runID: runID, secretBroker: broker)
#expect(throws: MCPUploadError.self) {
try server.uploads.status(id: UUID(uuidString: ticket["upload_id"]!.stringValue!)!, runID: runID)
}
}
@Test
func disabledToolsAreAbsentAndCannotBeCalled() async {
let server = makeMCPServerTools(disabledTools: ["sftp_delete"])
#expect(!server.descriptors.contains { $0.name == "sftp_delete" })
#expect(!server.contains("sftp_delete"))
await #expect(throws: MCPServerToolsError.unknownTool("sftp_delete")) {
_ = try await server.call(
name: "sftp_delete", arguments: .object([:]), runID: UUID(),
clientName: "Tests", callID: "disabled", secretBroker: SecretBroker()
)
}
}
@Test
func listHostsReturnsUntrustedStructuredJSONWithoutPromptFraming() async throws {
let server = makeMCPServerTools()
let result = try await server.call(
name: "list_hosts", arguments: .object([:]), runID: UUID(),
clientName: "Tests", callID: "list", secretBroker: SecretBroker()
)
let payload = try #require(result.untrustedPayload)
let object = try #require(try AgentJSONValue.decode(Data(payload.utf8)).objectValue)
#expect(object["hosts"]?.arrayValue?.isEmpty == true)
#expect(object["groups"]?.arrayValue?.isEmpty == true)
#expect(!payload.contains("<untrusted-data>"))
#expect(!result.isError)
}
@Test(.requiresVisibleWindow)
func secretPromptReturnsOnlyAOneShotHandle() async throws {
let authentication = ExternalAuthCoordinator()
let server = makeMCPServerTools(externalAuthentication: authentication)
let broker = SecretBroker()
let runID = UUID()
let call = Task {
try await server.call(
name: "request_user_secret",
arguments: .object([
"prompt": .string("Password for sudo"),
"purpose": .string("sudo password"),
]),
runID: runID, clientName: "Claude Code", callID: "secret",
secretBroker: broker
)
}
try await waitForMCPCondition {
if case .secret? = authentication.current?.request { return true }
return false
}
if case .secret(let request)? = authentication.current?.request {
#expect(request.timeout == .seconds(300))
}
authentication.respondSecret(
.answered([
AgentUserInputAnswer(
questionID: "secret", value: .secret(agentSecret("TOP-SECRET"))
)
]))
let result = try await call.value
let payload = try #require(result.untrustedPayload)
let object = try #require(try AgentJSONValue.decode(Data(payload.utf8)).objectValue)
let rawHandle = try #require(object["secret_handle"]?.stringValue)
let handle = SecretHandle(id: try #require(UUID(uuidString: rawHandle)))
#expect(!payload.contains("TOP-SECRET"))
#expect(await broker.count == 1)
let consumed = try await broker.consume(handle)
defer { consumed.clear() }
#expect(try consumed.withUnsafeBytes { Array($0) } == Array("TOP-SECRET".utf8))
await #expect(throws: SecretBrokerError.notFound) {
_ = try await broker.consume(handle)
}
}
@Test(.requiresVisibleWindow)
func timedOutSecretPromptReturnsAnErrorAndIssuesNoHandle() async throws {
let authentication = ExternalAuthCoordinator()
let server = makeMCPServerTools(externalAuthentication: authentication)
let broker = SecretBroker()
let call = Task {
try await server.call(
name: "request_user_secret",
arguments: .object([
"prompt": .string("Password for sudo"),
"purpose": .string("sudo password"),
"timeout_seconds": .number(17),
]),
runID: UUID(), clientName: "Claude Code", callID: "secret-timeout",
secretBroker: broker
)
}
try await waitForMCPCondition {
if case .secret? = authentication.current?.request { return true }
return false
}
if case .secret(let request)? = authentication.current?.request {
#expect(request.timeout == .seconds(17))
}
authentication.respondSecret(.timedOut)
let result = try await call.value
#expect(result.isError)
#expect(result.content == "User input timed out after 17 seconds.")
#expect(await broker.count == 0)
}
@Test(.requiresVisibleWindow)
func endingASessionCancelsItsPromptAndDiscardsSecrets() async throws {
let authentication = ExternalAuthCoordinator()
let server = makeMCPServerTools(externalAuthentication: authentication)
let broker = SecretBroker()
let runID = UUID()
_ = try await broker.issue(agentSecret("secret"))
let prompt = Task {
await authentication.enqueue(
secretRequest(), clientName: "Client", runID: runID
)
}
try await waitForMCPCondition { authentication.current != nil }
await server.endSession(runID: runID, secretBroker: broker)
#expect(await prompt.value == .cancelled)
#expect(await broker.count == 0)
}
}
@MainActor
func makeMCPServerTools(
disabledTools: Set<String> = [],
externalAuthentication: ExternalAuthCoordinator = ExternalAuthCoordinator(),
hosts: [TermindDomain.Host] = [],
metrics: any MetricsSnapshotReading = TestMetricsReader()
) -> MCPServerTools {
let store = HostLibraryModel(hosts: hosts)
let resolver = AgentHostResolver(store: store)
return MCPServerTools(
hosts: resolver, metrics: metrics,
connections: AgentConnectionPool(
configuration: { _ in throw MCPTestFailure.unused },
routeFingerprint: { _ in throw MCPTestFailure.unused },
openConnection: { _ in throw MCPTestFailure.unused },
openSFTP: { _ in throw MCPTestFailure.unused },
sweepInterval: nil
),
authentication: AgentSSHAuthenticationRegistry(resolver: resolver),
background: AgentBackgroundCommands(sweepInterval: nil),
externalAuthentication: externalAuthentication,
history: ToolAuditModel(), disabledTools: disabledTools
)
}
private func secretRequest() -> AgentUserInputRequest {
AgentUserInputRequest(
questions: [AgentUserQuestion(id: "secret", prompt: "Secret", kind: .secret)],
purpose: "test"
)
}
@MainActor
private func waitForMCPCondition(_ condition: @MainActor () -> Bool) async throws {
for _ in 0..<100 {
if condition() { return }
await Task.yield()
}
throw MCPTestFailure.timedOut
}
private enum MCPTestFailure: Error { case unused, timedOut }