167 lines
7.0 KiB
Swift
167 lines
7.0 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Darwin
|
|
import Foundation
|
|
|
|
@main
|
|
struct TermindMCPBridge {
|
|
static func main() async {
|
|
let configuration = Configuration(arguments: Array(CommandLine.arguments.dropFirst()))
|
|
guard let url = URL(string: configuration.url), !configuration.token.isEmpty else {
|
|
fail("Usage: termind-mcp [--url URL] [--token TOKEN]")
|
|
exit(2)
|
|
}
|
|
|
|
let session = URLSession(configuration: .ephemeral)
|
|
var sessionID: String?
|
|
var protocolVersion: String?
|
|
|
|
while let line = readLine(strippingNewline: true) {
|
|
guard let input = line.data(using: .utf8),
|
|
let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any]
|
|
else {
|
|
fail("Ignoring a stdin line that is not a JSON-RPC object.")
|
|
continue
|
|
}
|
|
let id = object["id"]
|
|
let method = object["method"] as? String
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.httpBody = input
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
|
|
request.setValue("Bearer \(configuration.token)", forHTTPHeaderField: "Authorization")
|
|
let params = object["params"] as? [String: Any]
|
|
let meta = params?["_meta"] as? [String: Any]
|
|
let modernVersion = meta?["io.modelcontextprotocol/protocolVersion"] as? String
|
|
if let modernVersion {
|
|
request.setValue(modernVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
|
request.setValue(method, forHTTPHeaderField: "Mcp-Method")
|
|
if let name = (params?["name"] ?? params?["uri"]) as? String {
|
|
request.setValue(headerValue(name), forHTTPHeaderField: "Mcp-Name")
|
|
}
|
|
} else if let sessionID {
|
|
request.setValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
|
|
}
|
|
if modernVersion == nil, let protocolVersion {
|
|
request.setValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
|
}
|
|
|
|
do {
|
|
let (data, rawResponse) = try await session.data(for: request)
|
|
guard let response = rawResponse as? HTTPURLResponse else {
|
|
throw BridgeError.notHTTP
|
|
}
|
|
if modernVersion == nil, let assigned = response.value(forHTTPHeaderField: "Mcp-Session-Id"),
|
|
!assigned.isEmpty
|
|
{
|
|
sessionID = assigned
|
|
}
|
|
guard (200..<300).contains(response.statusCode) else {
|
|
if let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
envelope["jsonrpc"] as? String == "2.0", envelope["error"] is [String: Any]
|
|
{
|
|
write(data)
|
|
continue
|
|
}
|
|
if id != nil {
|
|
write(errorResponse(id: id, message: "Termind HTTP server returned \(response.statusCode)."))
|
|
} else {
|
|
fail("Termind HTTP server returned \(response.statusCode) for \(method ?? "notification").")
|
|
}
|
|
continue
|
|
}
|
|
guard response.statusCode != 202, !data.isEmpty else { continue }
|
|
guard let responseObject = try? JSONSerialization.jsonObject(with: data),
|
|
JSONSerialization.isValidJSONObject(responseObject)
|
|
else {
|
|
if id != nil { write(errorResponse(id: id, message: "Termind returned invalid JSON.")) }
|
|
continue
|
|
}
|
|
if method == "initialize",
|
|
let envelope = responseObject as? [String: Any],
|
|
let result = envelope["result"] as? [String: Any],
|
|
let negotiated = result["protocolVersion"] as? String
|
|
{
|
|
protocolVersion = negotiated
|
|
}
|
|
write(try JSONSerialization.data(withJSONObject: responseObject))
|
|
} catch {
|
|
if id != nil {
|
|
write(errorResponse(id: id, message: "Cannot reach Termind: \(error.localizedDescription)"))
|
|
} else {
|
|
fail("Cannot deliver \(method ?? "notification"): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
if let sessionID {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "DELETE"
|
|
request.setValue("Bearer \(configuration.token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
|
|
if let protocolVersion {
|
|
request.setValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
|
}
|
|
_ = try? await session.data(for: request)
|
|
}
|
|
}
|
|
|
|
private static func headerValue(_ value: String) -> String {
|
|
if value.utf8.allSatisfy({ $0 == 9 || (32...126).contains($0) }),
|
|
value == value.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!(value.hasPrefix("=?base64?") && value.hasSuffix("?="))
|
|
{
|
|
return value
|
|
}
|
|
return "=?base64?" + Data(value.utf8).base64EncodedString() + "?="
|
|
}
|
|
|
|
private static func errorResponse(id: Any?, message: String) -> Data {
|
|
let object: [String: Any] = [
|
|
"jsonrpc": "2.0", "id": id ?? NSNull(),
|
|
"error": ["code": -32000, "message": message],
|
|
]
|
|
return (try? JSONSerialization.data(withJSONObject: object)) ?? Data()
|
|
}
|
|
|
|
private static func write(_ data: Data) {
|
|
FileHandle.standardOutput.write(data)
|
|
FileHandle.standardOutput.write(Data([0x0A]))
|
|
}
|
|
|
|
private static func fail(_ value: String) {
|
|
FileHandle.standardError.write(Data((value + "\n").utf8))
|
|
}
|
|
}
|
|
|
|
private struct Configuration {
|
|
var url: String
|
|
var token: String
|
|
|
|
init(arguments: [String], environment: [String: String] = ProcessInfo.processInfo.environment) {
|
|
url = environment["TERMIND_MCP_URL"] ?? "http://127.0.0.1:7391/mcp"
|
|
token = environment["TERMIND_MCP_TOKEN"] ?? ""
|
|
var index = 0
|
|
while index < arguments.count {
|
|
switch arguments[index] {
|
|
case "--url" where index + 1 < arguments.count:
|
|
index += 1
|
|
url = arguments[index]
|
|
case "--token" where index + 1 < arguments.count:
|
|
index += 1
|
|
token = arguments[index]
|
|
default:
|
|
break
|
|
}
|
|
index += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum BridgeError: LocalizedError {
|
|
case notHTTP
|
|
var errorDescription: String? { "Termind returned a non-HTTP response." }
|
|
}
|