Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# kmsg

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)

<p><img src="assets/kmsg-logo.jpg" alt="kmsg logo" width="220" /></p>

> **Disclaimer**: `kmsg`는 Kakao Corp. 의 공식 도구가 아닙니다.
Expand Down Expand Up @@ -305,6 +307,8 @@ OpenClaw MCP 설정 예시:
}
```

`mcp-server` 는 MCP `Content-Length` 프레이밍과 줄 단위 JSON-RPC 입력을 모두 받습니다. 요청이 `Content-Length` 방식이면 같은 방식으로 응답하고, JSON 한 줄 요청이면 JSON 한 줄로 응답합니다.

추천 운영 순서는 아래와 같습니다.

1. `watch --json` 으로 새 메시지 감지
Expand Down Expand Up @@ -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
Expand Down
85 changes: 80 additions & 5 deletions Sources/kmsg/Commands/MCPServerCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand All @@ -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:
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions docs/openclaw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
37 changes: 37 additions & 0 deletions tests/test_mcp_server_contract.py
Original file line number Diff line number Diff line change
@@ -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()
Loading