diff --git a/README.md b/README.md
index 3419db4..b0bb759 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# kmsg
+[](./LICENSE)
+

> **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()