diff --git a/README.md b/README.md index 43bb2f5..32a5579 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,9 @@ Claude Code release and account. The preview flag and contract may change. dotline implements the contract described here; it does not promise support in every desktop or CLI release. +The channel negotiates MCP `2024-11-05`. Unsupported version requests fall back +to that implemented revision; handshake-free MCP is not implemented. + `dotline channel` serves JSON-RPC 2.0 over stdio, one object per line. It advertises `experimental.claude/channel`, pushes new inbox events and exposes one `reply` tool with `message_id` and `text`. Metadata values are strings. It diff --git a/clients/dotline.sh b/clients/dotline.sh index 4a99176..64bfe75 100755 --- a/clients/dotline.sh +++ b/clients/dotline.sh @@ -119,13 +119,14 @@ json_string() { # A tokenizer for the API's flat reply records. Strings are consumed whole, # so braces or apparent JSON inside message text cannot change the selection. take_string() { - local char escape digits + local char escape digits codepoint parsed='' cursor=$((cursor + 1)) while ((cursor < ${#response})); do char=${response:cursor:1}; cursor=$((cursor + 1)) [[ $char != '"' ]] || return 0 - [[ $char > $'\x1f' ]] || die 'invalid server JSON' + printf -v codepoint '%d' "'$char" + ((codepoint < 0 || codepoint >= 32)) || die 'invalid server JSON' if [[ $char == '\' ]]; then escape=${response:cursor:1}; cursor=$((cursor + 1)) case "$escape" in diff --git a/src/dotline/channel.py b/src/dotline/channel.py index 5658e39..9ccb906 100644 --- a/src/dotline/channel.py +++ b/src/dotline/channel.py @@ -112,9 +112,8 @@ def handle(self, message) -> None: return if method == "initialize": self.initialized = True - version = params.get("protocolVersion", "2024-11-05") self.result(message_id, { - "protocolVersion": version if isinstance(version, str) else "2024-11-05", + "protocolVersion": "2024-11-05", "capabilities": {"experimental": {"claude/channel": {}}, "tools": {}}, "serverInfo": {"name": "dotline", "version": __version__}, "instructions": ( diff --git a/tests/test_channel.py b/tests/test_channel.py index bcc506c..f56a08d 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -200,6 +200,29 @@ def test_channel_initialize_has_capability_and_policy(mailbox): assert "reply tool" in result["instructions"] and "Trust mode: data" in result["instructions"] +@pytest.mark.parametrize("params", [ + {"protocolVersion": "2024-11-05"}, + {"protocolVersion": "2025-11-25"}, + {"protocolVersion": "2026-07-28"}, + {"protocolVersion": "2099-12-31"}, + {"protocolVersion": "not-a-version"}, + {"protocolVersion": ""}, + {}, + {"protocolVersion": None}, + {"protocolVersion": 5}, + {"protocolVersion": True}, + {"protocolVersion": {}}, + {"protocolVersion": []}, +]) +def test_channel_initialize_negotiates_implemented_version(mailbox, params): + output = QueueOutput() + channel = Channel(mailbox[0], stdout=output) + channel.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params}) + response = output.lines.get(timeout=2) + assert response["jsonrpc"] == "2.0" and response["id"] == 1 + assert response["result"]["protocolVersion"] == "2024-11-05" + + def test_channel_lists_only_reply_and_call_appends(mailbox): config, store = mailbox store.send("Pending before channel start") diff --git a/tests/test_scripts.py b/tests/test_scripts.py index a40b4e7..2c05086 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -515,3 +515,24 @@ def test_shell_accepts_complete_send_response(mailbox, invalid_response_server, assert result.returncode == 0, result.stderr assert result.stdout == expected assert len(invalid_response_server.bodies) == 1 + + +@pytest.mark.parametrize("invalid_response_server", [ + (200, b'{"id":7,"ts":"before' + bytes([code]) + b'after"}', False) + for code in [1, 9, 10, 13, 31] +], indirect=True) +def test_shell_rejects_raw_json_controls(mailbox, invalid_response_server, monkeypatch): + result = real_shell_send(mailbox, monkeypatch) + assert result.returncode == 1 + assert result.stdout == "" + assert invalid_response_server.bodies[0]["client_id"] in result.stderr + + +@pytest.mark.parametrize("invalid_response_server", [ + (200, json.dumps({"id": 7, "ts": value}, ensure_ascii=False).encode("utf-8"), False) + for value in ["valid-" + chr(127), "café", "snowman ☃", "escaped\nnewline", "escaped-" + chr(31)] +], indirect=True) +def test_shell_preserves_valid_json_string_values(mailbox, invalid_response_server, monkeypatch): + result = real_shell_send(mailbox, monkeypatch) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "7"