120 lines
6.0 KiB
Swift
120 lines
6.0 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Darwin
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
public nonisolated enum MCPImporter {
|
|
public static let maximumBytes = 1_048_576
|
|
|
|
public static func read(_ url: URL) throws -> MCPImportDocument {
|
|
let accessed = url.startAccessingSecurityScopedResource()
|
|
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
|
|
let descriptor = open(url.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
|
|
guard descriptor >= 0 else { throw MCPImportProblem.unreadableFile }
|
|
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
|
defer { try? handle.close() }
|
|
var info = stat()
|
|
guard fstat(descriptor, &info) == 0, info.st_mode & S_IFMT == S_IFREG,
|
|
info.st_size <= maximumBytes
|
|
else { throw MCPImportProblem.unreadableFile }
|
|
let data: Data
|
|
do { data = try handle.read(upToCount: maximumBytes + 1) ?? Data() } catch {
|
|
throw MCPImportProblem.unreadableFile
|
|
}
|
|
return try parse(data)
|
|
}
|
|
|
|
public static func parse(_ data: Data) throws -> MCPImportDocument {
|
|
guard data.count <= maximumBytes,
|
|
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
let servers = root["mcpServers"] as? [String: Any]
|
|
else { throw MCPImportProblem.invalidDocument }
|
|
var entries: [MCPImportEntry] = []
|
|
var issues: [MCPImportIssue] = []
|
|
for (offset, key) in servers.keys.sorted().enumerated() {
|
|
let index = offset + 1
|
|
do {
|
|
entries.append(try parseEntry(servers[key], name: key, index: index))
|
|
} catch {
|
|
issues.append(.init(index: index, problem: error as? MCPImportProblem ?? .invalidEntry))
|
|
}
|
|
}
|
|
return MCPImportDocument(entries: entries, issues: issues)
|
|
}
|
|
|
|
private static func parseEntry(_ value: Any?, name: String, index: Int) throws -> MCPImportEntry {
|
|
guard let object = value as? [String: Any] else { throw MCPImportProblem.invalidEntry }
|
|
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !MCPToolNaming.namespaceID(from: name).isEmpty else { throw MCPImportProblem.invalidEntry }
|
|
guard object["command"] == nil, object["args"] == nil, object["env"] == nil else {
|
|
throw MCPImportProblem.unsupportedTransport
|
|
}
|
|
let transport: MCPServer.Transport
|
|
switch object["type"] {
|
|
case nil: transport = .streamableHTTP
|
|
case let type as String:
|
|
switch type {
|
|
case "http", "streamable-http", "streamableHTTP": transport = .streamableHTTP
|
|
case "sse": transport = .sse
|
|
default: throw MCPImportProblem.unsupportedTransport
|
|
}
|
|
default: throw MCPImportProblem.unsupportedTransport
|
|
}
|
|
guard let rawURL = object["url"] as? String else { throw MCPImportProblem.invalidURL }
|
|
let url = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !hasVariable(url) else { throw MCPImportProblem.unresolvedVariable }
|
|
guard let components = URLComponents(string: url),
|
|
["http", "https"].contains(components.scheme?.lowercased() ?? ""),
|
|
let host = components.host, !host.isEmpty, components.url != nil,
|
|
!url.contains(where: { $0.isWhitespace }), components.fragment == nil,
|
|
components.port.map({ (1...65535).contains($0) }) ?? true
|
|
else { throw MCPImportProblem.invalidURL }
|
|
guard components.user == nil, components.password == nil,
|
|
!(components.queryItems ?? []).contains(where: { isSensitive($0.name) }),
|
|
!object.keys.contains(where: { isSensitive($0) })
|
|
else { throw MCPImportProblem.unsupportedAuthentication }
|
|
var headers: [MCPHeader] = []
|
|
var bearer: String?
|
|
if let rawHeaders = object["headers"] {
|
|
guard let values = rawHeaders as? [String: String] else { throw MCPImportProblem.invalidEntry }
|
|
var seen = Set<String>()
|
|
let allowed = CharacterSet(
|
|
charactersIn: "!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
for key in values.keys.sorted() {
|
|
let value = values[key]!
|
|
guard !key.isEmpty, key.unicodeScalars.allSatisfy({ allowed.contains($0) }),
|
|
!value.contains("\r"), !value.contains("\n"),
|
|
seen.insert(key.lowercased()).inserted
|
|
else { throw MCPImportProblem.invalidEntry }
|
|
guard !hasVariable(value) else { throw MCPImportProblem.unresolvedVariable }
|
|
if key.lowercased() == "authorization" {
|
|
let parts = value.split(maxSplits: 1, whereSeparator: { $0.isWhitespace })
|
|
guard parts.count == 2, parts[0].lowercased() == "bearer",
|
|
!parts[1].contains(where: { $0.isWhitespace })
|
|
else { throw MCPImportProblem.unsupportedAuthentication }
|
|
bearer = String(parts[1])
|
|
} else {
|
|
guard !isSensitive(key) else { throw MCPImportProblem.unsupportedAuthentication }
|
|
headers.append(MCPHeader(name: key, value: value))
|
|
}
|
|
}
|
|
}
|
|
return MCPImportEntry(
|
|
index: index, name: name, transport: transport, url: url, headers: headers, bearerToken: bearer)
|
|
}
|
|
|
|
private static func hasVariable(_ value: String) -> Bool {
|
|
value.contains("${") || value.contains("{{")
|
|
|| value.range(of: #"\$[A-Za-z_]|%[A-Za-z_][A-Za-z0-9_]*%"#, options: .regularExpression) != nil
|
|
}
|
|
|
|
private static func isSensitive(_ key: String) -> Bool {
|
|
let key = key.lowercased().filter { $0.isLetter || $0.isNumber }
|
|
return ["authorization", "authentication", "apikey", "token", "secret", "password", "cookie", "credential"]
|
|
.contains(where: { key.contains($0) }) || key == "auth" || key == "oauth"
|
|
}
|
|
}
|