Skip to content

Commit 9e9f115

Browse files
authored
Merge pull request #14 from MadKangYu/codex/mcp-stdio-compat-errors
fix(mcp): support ndjson stdio clients
2 parents 2b660bb + 4cd18d4 commit 9e9f115

4 files changed

Lines changed: 130 additions & 7 deletions

File tree

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# kmsg
22

3+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
4+
35
<p><img src="assets/kmsg-logo.jpg" alt="kmsg logo" width="220" /></p>
46

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

310+
`mcp-server` 는 MCP `Content-Length` 프레이밍과 줄 단위 JSON-RPC 입력을 모두 받습니다. 요청이 `Content-Length` 방식이면 같은 방식으로 응답하고, JSON 한 줄 요청이면 JSON 한 줄로 응답합니다.
311+
308312
추천 운영 순서는 아래와 같습니다.
309313

310314
1. `watch --json` 으로 새 메시지 감지
@@ -472,13 +476,17 @@ LOCO Protocol 을 쓰려면 사실상 비공개 동작을 리버스 엔지니어
472476

473477
### 이 기능을 Rust 로 작성하면 더 빨라질까요?
474478

475-
조금은 빨라질 수 있지만, 체감 성능 향상은 제한적일 가능성이 큽니다. 이 CLI 의 주된 지연은 언어 런타임보다 `AXUIElement` 호출, UI 탐색, KakaoTalk 창 활성화/복구, 실제 앱 응답 시간에서 발생합니다.
476-
Rust 로 바꾸면 cold start순수 stdio/JSON 처리 같은 부분은 약간 유리할 수 있지만, 전체 end-to-end latency 는 크게 달라지지 않을 가능성이 높습니다. 대신 macOS native framework binding 과 유지보수 비용은 Swift 보다 높아질 수 있습니다.
479+
일부 구간은 빨라질 수 있지만, 체감 성능 향상은 제한적일 가능성이 큽니다. `kmsg` 의 주된 지연은 언어 런타임보다 `AXUIElement` 호출, UI 탐색, KakaoTalk 창 활성화/복구, 실제 앱 응답 시간에서 발생합니다.
480+
Rust 로 바꾸면 단발 CLI cold start, 순수 stdio 프레이밍, JSON encode/decode 같은 좁은 구간은 유리할 수 있습니다. 하지만 MCP 서버처럼 프로세스가 이미 떠 있는 경로에서는 그 이득이 더 작아지고, 전체 end-to-end latency 는 여전히 macOS AX 와 KakaoTalk UI 응답 시간이 지배합니다. 대신 Rust 는 macOS native framework binding 과 FFI 관리 비용이 Swift 보다 커질 수 있습니다.
477481

478482
## Inspiration
479483

480484
This project is strongly inspired by [steipete](https://github.com/steipete) and his works.
481485

486+
## License
487+
488+
`kmsg` is available under the [MIT License](./LICENSE).
489+
482490
## References
483491

484492
- https://github.com/steipete/imsg

Sources/kmsg/Commands/MCPServerCommand.swift

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ private struct KmsgMCPError: Error, @unchecked Sendable {
2424
}
2525
}
2626

27+
private enum MCPStdioTransport {
28+
case contentLength
29+
case newlineDelimitedJSON
30+
}
31+
32+
private final class MCPDataBuffer: @unchecked Sendable {
33+
private let lock = NSLock()
34+
private var data = Data()
35+
36+
func store(_ newData: Data) {
37+
lock.lock()
38+
data = newData
39+
lock.unlock()
40+
}
41+
42+
func load() -> Data {
43+
lock.lock()
44+
defer { lock.unlock() }
45+
return data
46+
}
47+
}
48+
2749
private final class KmsgSubprocessRunner {
2850
let executablePath: String
2951

@@ -58,6 +80,20 @@ private final class KmsgSubprocessRunner {
5880
)
5981
}
6082

83+
let stdoutData = MCPDataBuffer()
84+
let stderrData = MCPDataBuffer()
85+
let stdoutSemaphore = DispatchSemaphore(value: 0)
86+
let stderrSemaphore = DispatchSemaphore(value: 0)
87+
88+
DispatchQueue.global().async {
89+
stdoutData.store(stdoutPipe.fileHandleForReading.readDataToEndOfFile())
90+
stdoutSemaphore.signal()
91+
}
92+
DispatchQueue.global().async {
93+
stderrData.store(stderrPipe.fileHandleForReading.readDataToEndOfFile())
94+
stderrSemaphore.signal()
95+
}
96+
6197
let waitSemaphore = DispatchSemaphore(value: 0)
6298
DispatchQueue.global().async {
6399
process.waitUntilExit()
@@ -80,8 +116,11 @@ private final class KmsgSubprocessRunner {
80116
}
81117
}
82118

83-
let stdout = String(decoding: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
84-
let stderr = String(decoding: stderrPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
119+
_ = stdoutSemaphore.wait(timeout: .now() + 1.0)
120+
_ = stderrSemaphore.wait(timeout: .now() + 1.0)
121+
122+
let stdout = String(decoding: stdoutData.load(), as: UTF8.self)
123+
let stderr = String(decoding: stderrData.load(), as: UTF8.self)
85124

86125
return MCPCommandResult(
87126
returncode: process.terminationStatus,
@@ -107,7 +146,7 @@ private final class KmsgSubprocessRunner {
107146
)
108147
}
109148

110-
let status = run(["status"], timeoutSec: 4.0)
149+
let status = run(["status"], timeoutSec: 15.0)
111150
if status.returncode != 0 {
112151
return (
113152
false,
@@ -139,6 +178,7 @@ private final class KmsgMCPServer {
139178
private let serverVersion: String
140179
private var initialized = false
141180
private var shutdown = false
181+
private var responseTransport: MCPStdioTransport = .contentLength
142182

143183
init() {
144184
let env = ProcessInfo.processInfo.environment
@@ -678,6 +718,9 @@ private final class KmsgMCPServer {
678718
if combinedText.contains("SEARCH_MISS") {
679719
return "CHAT_NOT_FOUND"
680720
}
721+
if combinedText.contains("FOCUS_FAIL") {
722+
return "KAKAO_SEARCH_FOCUS_FAILED"
723+
}
681724
if combinedText.contains("Accessibility") || combinedText.contains("손쉬운 사용") {
682725
return "ACCESSIBILITY_PERMISSION_DENIED"
683726
}
@@ -692,6 +735,8 @@ private final class KmsgMCPServer {
692735
return "KakaoTalk window was not ready. Open KakaoTalk and retry (or enable deep_recovery)."
693736
case "CHAT_NOT_FOUND":
694737
return "Chat was not found in search results. Verify chat name spacing and visibility."
738+
case "KAKAO_SEARCH_FOCUS_FAILED":
739+
return "KakaoTalk search input could not be focused. Open KakaoTalk, ensure the chat list is visible, then retry with deep_recovery=true."
695740
case "ACCESSIBILITY_PERMISSION_DENIED":
696741
return "Grant Accessibility permission in System Settings > Privacy & Security > Accessibility."
697742
default:
@@ -767,11 +812,16 @@ private final class KmsgMCPServer {
767812

768813
while true {
769814
guard let line = readHeaderLine() else { return nil }
815+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
816+
if headers.isEmpty, trimmed.hasPrefix("{") {
817+
responseTransport = .newlineDelimitedJSON
818+
return jsonObject(from: Data(line.utf8))
819+
}
820+
770821
if line == "\r\n" || line == "\n" {
771822
break
772823
}
773824

774-
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
775825
if trimmed.isEmpty {
776826
continue
777827
}
@@ -788,8 +838,13 @@ private final class KmsgMCPServer {
788838
else {
789839
return nil
790840
}
841+
responseTransport = .contentLength
842+
843+
return jsonObject(from: body)
844+
}
791845

792-
guard let object = try? JSONSerialization.jsonObject(with: body),
846+
private func jsonObject(from data: Data) -> JSONDict? {
847+
guard let object = try? JSONSerialization.jsonObject(with: data),
793848
let dict = object as? JSONDict
794849
else {
795850
return nil
@@ -823,6 +878,15 @@ private final class KmsgMCPServer {
823878

824879
private func writeMessage(_ payload: JSONDict) throws {
825880
let encoded = try JSONSerialization.data(withJSONObject: payload, options: [])
881+
switch responseTransport {
882+
case .contentLength:
883+
try writeContentLengthMessage(encoded)
884+
case .newlineDelimitedJSON:
885+
writeNewlineDelimitedJSONMessage(encoded)
886+
}
887+
}
888+
889+
private func writeContentLengthMessage(_ encoded: Data) throws {
826890
let header = "Content-Length: \(encoded.count)\r\n\r\n"
827891
header.utf8CString.withUnsafeBufferPointer { buffer in
828892
_ = fwrite(buffer.baseAddress, 1, buffer.count - 1, stdout)
@@ -834,6 +898,17 @@ private final class KmsgMCPServer {
834898
}
835899
fflush(stdout)
836900
}
901+
902+
private func writeNewlineDelimitedJSONMessage(_ encoded: Data) {
903+
encoded.withUnsafeBytes { rawBuffer in
904+
if let baseAddress = rawBuffer.baseAddress {
905+
_ = fwrite(baseAddress, 1, encoded.count, stdout)
906+
}
907+
}
908+
var newline = UInt8(ascii: "\n")
909+
_ = fwrite(&newline, 1, 1, stdout)
910+
fflush(stdout)
911+
}
837912
}
838913

839914
struct MCPServerCommand: ParsableCommand {

docs/openclaw.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Run the MCP server:
5858
kmsg mcp-server
5959
```
6060

61+
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.
62+
6163
Config example:
6264

6365
```json
@@ -248,3 +250,4 @@ If MCP fails:
248250
- `kmsg mcp-server`
249251
- `kmsg status`
250252
- confirm Accessibility permission and KakaoTalk readiness
253+
- if a client sends JSON lines instead of `Content-Length` frames, keep one JSON-RPC request per line

tests/test_mcp_server_contract.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import unittest
2+
from pathlib import Path
3+
4+
5+
REPO_ROOT = Path(__file__).resolve().parents[1]
6+
MCP_SERVER_COMMAND = REPO_ROOT / "Sources" / "kmsg" / "Commands" / "MCPServerCommand.swift"
7+
8+
9+
class MCPServerContractTests(unittest.TestCase):
10+
def test_startup_status_check_allows_slow_ax_ready_state(self) -> None:
11+
source = MCP_SERVER_COMMAND.read_text(encoding="utf-8")
12+
self.assertIn('run(["status"], timeoutSec: 15.0)', source)
13+
14+
def test_subprocess_readers_start_after_successful_launch(self) -> None:
15+
source = MCP_SERVER_COMMAND.read_text(encoding="utf-8")
16+
run_index = source.index("try process.run()")
17+
stdout_reader_index = source.index("let stdoutData = MCPDataBuffer()")
18+
self.assertLess(run_index, stdout_reader_index)
19+
20+
def test_mcp_server_accepts_content_length_and_ndjson(self) -> None:
21+
source = MCP_SERVER_COMMAND.read_text(encoding="utf-8")
22+
self.assertIn("enum MCPStdioTransport", source)
23+
self.assertIn("case contentLength", source)
24+
self.assertIn("case newlineDelimitedJSON", source)
25+
self.assertIn('trimmed.hasPrefix("{")', source)
26+
self.assertIn("writeContentLengthMessage", source)
27+
self.assertIn("writeNewlineDelimitedJSONMessage", source)
28+
29+
def test_focus_fail_has_actionable_mcp_error_code(self) -> None:
30+
source = MCP_SERVER_COMMAND.read_text(encoding="utf-8")
31+
self.assertIn('combinedText.contains("FOCUS_FAIL")', source)
32+
self.assertIn('"KAKAO_SEARCH_FOCUS_FAILED"', source)
33+
self.assertIn("search input could not be focused", source)
34+
35+
36+
if __name__ == "__main__":
37+
unittest.main()

0 commit comments

Comments
 (0)