diff --git a/README.md b/README.md index 3419db4..b0bb759 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # kmsg +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +

kmsg logo

> **Disclaimer**: `kmsg`는 Kakao Corp. 의 공식 도구가 아닙니다. @@ -305,6 +307,8 @@ OpenClaw MCP 설정 예시: } ``` +`mcp-server` 는 MCP `Content-Length` 프레이밍과 줄 단위 JSON-RPC 입력을 모두 받습니다. 요청이 `Content-Length` 방식이면 같은 방식으로 응답하고, JSON 한 줄 요청이면 JSON 한 줄로 응답합니다. + 추천 운영 순서는 아래와 같습니다. 1. `watch --json` 으로 새 메시지 감지 @@ -472,13 +476,17 @@ LOCO Protocol 을 쓰려면 사실상 비공개 동작을 리버스 엔지니어 ### 이 기능을 Rust 로 작성하면 더 빨라질까요? -조금은 빨라질 수 있지만, 체감 성능 향상은 제한적일 가능성이 큽니다. 이 CLI 의 주된 지연은 언어 런타임보다 `AXUIElement` 호출, UI 탐색, KakaoTalk 창 활성화/복구, 실제 앱 응답 시간에서 발생합니다. -Rust 로 바꾸면 cold start 나 순수 stdio/JSON 처리 같은 부분은 약간 유리할 수 있지만, 전체 end-to-end latency 는 크게 달라지지 않을 가능성이 높습니다. 대신 macOS native framework binding 과 유지보수 비용은 Swift 보다 높아질 수 있습니다. +일부 구간은 빨라질 수 있지만, 체감 성능 향상은 제한적일 가능성이 큽니다. `kmsg` 의 주된 지연은 언어 런타임보다 `AXUIElement` 호출, UI 탐색, KakaoTalk 창 활성화/복구, 실제 앱 응답 시간에서 발생합니다. +Rust 로 바꾸면 단발 CLI cold start, 순수 stdio 프레이밍, JSON encode/decode 같은 좁은 구간은 유리할 수 있습니다. 하지만 MCP 서버처럼 프로세스가 이미 떠 있는 경로에서는 그 이득이 더 작아지고, 전체 end-to-end latency 는 여전히 macOS AX 와 KakaoTalk UI 응답 시간이 지배합니다. 대신 Rust 는 macOS native framework binding 과 FFI 관리 비용이 Swift 보다 커질 수 있습니다. ## Inspiration This project is strongly inspired by [steipete](https://github.com/steipete) and his works. +## License + +`kmsg` is available under the [MIT License](./LICENSE). + ## References - https://github.com/steipete/imsg diff --git a/Sources/kmsg/Commands/MCPServerCommand.swift b/Sources/kmsg/Commands/MCPServerCommand.swift index a815182..1c2c9c9 100644 --- a/Sources/kmsg/Commands/MCPServerCommand.swift +++ b/Sources/kmsg/Commands/MCPServerCommand.swift @@ -24,6 +24,28 @@ private struct KmsgMCPError: Error, @unchecked Sendable { } } +private enum MCPStdioTransport { + case contentLength + case newlineDelimitedJSON +} + +private final class MCPDataBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func store(_ newData: Data) { + lock.lock() + data = newData + lock.unlock() + } + + func load() -> Data { + lock.lock() + defer { lock.unlock() } + return data + } +} + private final class KmsgSubprocessRunner { let executablePath: String @@ -58,6 +80,20 @@ private final class KmsgSubprocessRunner { ) } + let stdoutData = MCPDataBuffer() + let stderrData = MCPDataBuffer() + let stdoutSemaphore = DispatchSemaphore(value: 0) + let stderrSemaphore = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + stdoutData.store(stdoutPipe.fileHandleForReading.readDataToEndOfFile()) + stdoutSemaphore.signal() + } + DispatchQueue.global().async { + stderrData.store(stderrPipe.fileHandleForReading.readDataToEndOfFile()) + stderrSemaphore.signal() + } + let waitSemaphore = DispatchSemaphore(value: 0) DispatchQueue.global().async { process.waitUntilExit() @@ -80,8 +116,11 @@ private final class KmsgSubprocessRunner { } } - let stdout = String(decoding: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - let stderr = String(decoding: stderrPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + _ = stdoutSemaphore.wait(timeout: .now() + 1.0) + _ = stderrSemaphore.wait(timeout: .now() + 1.0) + + let stdout = String(decoding: stdoutData.load(), as: UTF8.self) + let stderr = String(decoding: stderrData.load(), as: UTF8.self) return MCPCommandResult( returncode: process.terminationStatus, @@ -107,7 +146,7 @@ private final class KmsgSubprocessRunner { ) } - let status = run(["status"], timeoutSec: 4.0) + let status = run(["status"], timeoutSec: 15.0) if status.returncode != 0 { return ( false, @@ -139,6 +178,7 @@ private final class KmsgMCPServer { private let serverVersion: String private var initialized = false private var shutdown = false + private var responseTransport: MCPStdioTransport = .contentLength init() { let env = ProcessInfo.processInfo.environment @@ -678,6 +718,9 @@ private final class KmsgMCPServer { if combinedText.contains("SEARCH_MISS") { return "CHAT_NOT_FOUND" } + if combinedText.contains("FOCUS_FAIL") { + return "KAKAO_SEARCH_FOCUS_FAILED" + } if combinedText.contains("Accessibility") || combinedText.contains("손쉬운 사용") { return "ACCESSIBILITY_PERMISSION_DENIED" } @@ -692,6 +735,8 @@ private final class KmsgMCPServer { return "KakaoTalk window was not ready. Open KakaoTalk and retry (or enable deep_recovery)." case "CHAT_NOT_FOUND": return "Chat was not found in search results. Verify chat name spacing and visibility." + case "KAKAO_SEARCH_FOCUS_FAILED": + return "KakaoTalk search input could not be focused. Open KakaoTalk, ensure the chat list is visible, then retry with deep_recovery=true." case "ACCESSIBILITY_PERMISSION_DENIED": return "Grant Accessibility permission in System Settings > Privacy & Security > Accessibility." default: @@ -767,11 +812,16 @@ private final class KmsgMCPServer { while true { guard let line = readHeaderLine() else { return nil } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if headers.isEmpty, trimmed.hasPrefix("{") { + responseTransport = .newlineDelimitedJSON + return jsonObject(from: Data(line.utf8)) + } + if line == "\r\n" || line == "\n" { break } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { continue } @@ -788,8 +838,13 @@ private final class KmsgMCPServer { else { return nil } + responseTransport = .contentLength + + return jsonObject(from: body) + } - guard let object = try? JSONSerialization.jsonObject(with: body), + private func jsonObject(from data: Data) -> JSONDict? { + guard let object = try? JSONSerialization.jsonObject(with: data), let dict = object as? JSONDict else { return nil @@ -823,6 +878,15 @@ private final class KmsgMCPServer { private func writeMessage(_ payload: JSONDict) throws { let encoded = try JSONSerialization.data(withJSONObject: payload, options: []) + switch responseTransport { + case .contentLength: + try writeContentLengthMessage(encoded) + case .newlineDelimitedJSON: + writeNewlineDelimitedJSONMessage(encoded) + } + } + + private func writeContentLengthMessage(_ encoded: Data) throws { let header = "Content-Length: \(encoded.count)\r\n\r\n" header.utf8CString.withUnsafeBufferPointer { buffer in _ = fwrite(buffer.baseAddress, 1, buffer.count - 1, stdout) @@ -834,6 +898,17 @@ private final class KmsgMCPServer { } fflush(stdout) } + + private func writeNewlineDelimitedJSONMessage(_ encoded: Data) { + encoded.withUnsafeBytes { rawBuffer in + if let baseAddress = rawBuffer.baseAddress { + _ = fwrite(baseAddress, 1, encoded.count, stdout) + } + } + var newline = UInt8(ascii: "\n") + _ = fwrite(&newline, 1, 1, stdout) + fflush(stdout) + } } struct MCPServerCommand: ParsableCommand { diff --git a/docs/openclaw.md b/docs/openclaw.md index 9a8cdd7..bd9bba6 100644 --- a/docs/openclaw.md +++ b/docs/openclaw.md @@ -58,6 +58,8 @@ Run the MCP server: kmsg mcp-server ``` +The stdio server accepts both MCP `Content-Length` framing and newline-delimited JSON-RPC input. It replies with the same framing style as the current request, so older OpenClaw-style clients and simpler NDJSON supervisors can use the same binary. + Config example: ```json @@ -248,3 +250,4 @@ If MCP fails: - `kmsg mcp-server` - `kmsg status` - confirm Accessibility permission and KakaoTalk readiness +- if a client sends JSON lines instead of `Content-Length` frames, keep one JSON-RPC request per line diff --git a/tests/test_mcp_server_contract.py b/tests/test_mcp_server_contract.py new file mode 100644 index 0000000..6acd708 --- /dev/null +++ b/tests/test_mcp_server_contract.py @@ -0,0 +1,37 @@ +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MCP_SERVER_COMMAND = REPO_ROOT / "Sources" / "kmsg" / "Commands" / "MCPServerCommand.swift" + + +class MCPServerContractTests(unittest.TestCase): + def test_startup_status_check_allows_slow_ax_ready_state(self) -> None: + source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + self.assertIn('run(["status"], timeoutSec: 15.0)', source) + + def test_subprocess_readers_start_after_successful_launch(self) -> None: + source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + run_index = source.index("try process.run()") + stdout_reader_index = source.index("let stdoutData = MCPDataBuffer()") + self.assertLess(run_index, stdout_reader_index) + + def test_mcp_server_accepts_content_length_and_ndjson(self) -> None: + source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + self.assertIn("enum MCPStdioTransport", source) + self.assertIn("case contentLength", source) + self.assertIn("case newlineDelimitedJSON", source) + self.assertIn('trimmed.hasPrefix("{")', source) + self.assertIn("writeContentLengthMessage", source) + self.assertIn("writeNewlineDelimitedJSONMessage", source) + + def test_focus_fail_has_actionable_mcp_error_code(self) -> None: + source = MCP_SERVER_COMMAND.read_text(encoding="utf-8") + self.assertIn('combinedText.contains("FOCUS_FAIL")', source) + self.assertIn('"KAKAO_SEARCH_FOCUS_FAILED"', source) + self.assertIn("search input could not be focused", source) + + +if __name__ == "__main__": + unittest.main()