claudecode.server is the plugin's built-in Model Context Protocol (MCP) server. It is a pure-Lua WebSocket (RFC 6455) server on top of vim.loop TCP handles. It speaks JSON-RPC 2.0 to the Claude Code CLI. It listens only on 127.0.0.1, checks an auth token during the HTTP upgrade, and sends MCP tool calls to ../tools/README.md.
Layers, top to bottom:
init.lua: server lifecycle, MCP method table, JSON-RPC dispatch, and deferred responses.tcp.lua: port selection, listen socket, client registry, and the keep-alive ping timer.client.lua: per-connection state machine (handshake, then frames).handshake.luaandframe.lua: HTTP upgrade validation and frame encode/decode.utils.lua: base64, SHA-1, masking, UTF-8, and constant-time compare helpers.
mock.lua is an in-memory stand-in with no sockets. Nothing in lua/ or tests/ requires it.
M.state:{ server, port, auth_token, handlers, ping_timer }[server/init.lua:M.state]M.start(config, auth_token) -> ok, port|err: callstools.setup(M), registers handlers, creates the TCP server, and starts the ping timer [server/init.lua:M.start]M.stop() -> ok, err?: stops the ping timer, closes pending diffs, stops the TCP server, and clears deferred responses [server/init.lua:M.stop]M.register_handlers(): handlesinitialize,notifications/initialized,prompts/list,tools/list, andtools/call[server/init.lua:M.register_handlers]initializereturnsprotocolVersion = "2024-11-05"(MCP_PROTOCOL_VERSION),capabilities(logging,prompts.listChanged,tools.listChanged), andserverInfo.name = "claudecode-neovim"[server/init.lua:MCP_PROTOCOL_VERSION]tools/calldelegates totools.handle_invokeand returns a_deferredtable for blocking tools [server/init.lua:M.register_handlers]M.send(client, method, params) -> boolean: sends a JSON-RPC notification to one client [server/init.lua:M.send]M.send_response(client, id, result, error_data) -> boolean: anilid is sent as JSONnull(vim.NIL) [server/init.lua:M.send_response]M.broadcast(method, params, on_written?) -> boolean: sends a notification to everyconnectedclient, and returns true only when at least one took it. When one did,on_written(written)runs once after the last write finished, with how many writes succeeded: a socket that fails during the write reports it only there [server/init.lua:M.broadcast] [server/tcp.lua:M.broadcast]M.get_status() -> { running, port, client_count, clients }.clientsis present only while the server runs [server/init.lua:M.get_status]- Internal dispatch:
M._handle_message,M._handle_request,M._handle_notification, andM._setup_deferred_response[server/init.lua:M._handle_message]
M.create_server(config, callbacks, auth_token) -> server|nil, err: tries ports in the configured range until one binds and listens [server/tcp.lua:M.create_server]M.find_available_port(min_port, max_port): probe-only port check [server/tcp.lua:M.find_available_port]M.send_to_client,M.broadcast,M.get_client_count, andM.get_clients_info[server/tcp.lua:M.send_to_client]M.stop_server(server): closes the check and idle handles of a stall confirmation that has not run, sends a Close frame with code 1001 "Server shutting down" to clients that finished the handshake or whose 101 reply is still being written, closes the TCP handle of the others, then closes the listener [server/tcp.lua:M.stop_server] [server/client.lua:M.close_client]M.start_ping_timer(server, interval): default interval is 30000 ms. It setsserver.keepalive_timeouttointerval * 2, and clients accepted from then on carry it [server/tcp.lua:M.start_ping_timer]- Internal:
M._bind_and_listen,M._handle_new_connection,M._disconnect_client, andM._remove_client[server/tcp.lua:M._bind_and_listen]
client.create_client(tcp_handle, keepalive_timeout?),process_data(...),send_message,send_ping,close_client,abandon_close_frame,is_client_alive(client, timeout=30000),sample_write_progress(client),is_client_reading(client), andget_client_info[server/client.lua:M.process_data]handshake.process_handshake(request, expected_auth_token) -> ok, response, headers?[server/handshake.lua:M.process_handshake]handshake.validate_upgrade_request,create_handshake_response,parse_request_line,is_websocket_endpoint,create_error_response, andextract_http_request[server/handshake.lua:M.validate_upgrade_request]frame.OPCODE:CONTINUATION 0x0,TEXT 0x1,BINARY 0x2,CLOSE 0x8,PING 0x9,PONG 0xA[server/frame.lua:M.OPCODE]frame.MAX_PAYLOAD_LENGTH: 100 MiB, the largest frame payload and the largest message [server/frame.lua:M.MAX_PAYLOAD_LENGTH]frame.parse_frame(data, start?) -> frame|nil, consumed, close_code?, detail?: parses the frame that starts at bytestart(default 1).nil, 0means incomplete, andnil, 0, nil, lengthgives the whole frame's length once its header arrived.nil, 0, code, problemmeans fatal, with the broken rule inproblem[server/frame.lua:M.parse_frame]frame.create_frame,create_text_frame,create_binary_frame,create_close_frame,create_ping_frame,create_pong_frame, andis_control_frame[server/frame.lua:M.create_frame]
generate_accept_key,sha1,base64_encode,base64_decode,generate_websocket_key,parse_http_headers,is_valid_utf8,apply_mask,constant_time_compare,uint16_to_bytes,uint64_to_bytes,bytes_to_uint16, andbytes_to_uint64[server/utils.lua:M.generate_accept_key]
M.state,start,stop,register_handlers(mcp.connect,mcp.tool.invoke),add_client,remove_client,send,send_response,broadcast,simulate_message, andclear_messages[server/mock.lua:M.register_handlers]M.find_available_port(min, max): always returnsmin[server/mock.lua:M.find_available_port]
- The server listens only on
127.0.0.1with backlog 128. It closes the handle if bind or listen fails [server/tcp.lua:M._bind_and_listen] - The port iterator yields each port in the range exactly once, starting at a random offset.
create_serverreturns "No available ports in range min-max" for an empty range, and "No free port in min-max ()", for example(EADDRINUSE), when every port fails [server/tcp.lua:M.create_server] M.startrefuses with "Server already running" whenM.state.serveris set.M.stopreturns "Server not running" when it is not set.send,send_response, andbroadcastreturnfalsewithout a server [server/init.lua:M.start]- When an auth token is set, the upgrade request must carry
x-claude-code-ide-authorization. The header length must be 10..500. The token is compared withutils.constant_time_compare[server/handshake.lua:M.validate_upgrade_request] - The upgrade request must have
Upgrade: websocket,Connectioncontainingupgrade,Sec-WebSocket-Version: 13, and a 24-characterSec-WebSocket-Key[server/handshake.lua:M.validate_upgrade_request] - A non-GET or non-HTTP/1.1 request gets 404. A failed validation gets 400 "Bad WebSocket upgrade request: " [server/handshake.lua:M.process_handshake]
- Frames are dispatched only after
handshake_completeis true. The state becomesconnectedonly after a successful handshake. While its 101 reply is written, a client isupgrading: the bytes it sends then wait in its buffer for the write callback, so its request is answered once and its first frames are kept. If the client hung up or the server stopped before the write callback, the callback drops those bytes, and the client never becomesconnected[server/client.lua:M.process_data] - A whole message runs through
on_messageonly if, when the main loop gets to it, the client is stillconnectedand its socket is open. A request from a client that hung up or sent its Close frame meanwhile does not run [server/client.lua:M.process_data] - A text message can arrive in fragments (a TEXT frame with FIN clear, then CONTINUATION frames up to one with FIN set), with control frames between them.
process_datajoins the fragments and callson_messageonce with the whole text. Only the whole message must be well-formed UTF-8, and one that is not closes with 1007. A binary message closes with 1003, and nothing runs it. A CONTINUATION frame with no message to continue, or a TEXT or BINARY frame inside a fragmented message, closes with 1002. A message whose fragments add up to more thanframe.MAX_PAYLOAD_LENGTH(100 MiB) closes with 1009. Each Close frame carries the RFC 6455 name of its status as the reason:Protocol error(1002),Unsupported data(1003),Invalid frame payload data(1007) orMessage too big(1009) [server/client.lua:add_data_frame] [server/client.lua:M.process_data] process_dataparses every frame of one read from an offset into its buffer and keeps the unparsed rest once at the end, so many small frames in one read cost linear time. The reads of a frame longer than one read wait inclient.pendinguntil the whole frame arrived, and are joined once, so a large frame costs linear time too [server/client.lua:M.process_data]parse_framerejects set RSV bits, invalid opcodes, fragmented control frames, and control payloads over 125 bytes with 1002. It rejects a payload overMAX_PAYLOAD_LENGTH(100 MiB) with 1009, and a close reason that is not well-formed UTF-8 with 1007. It names the broken rule (for exampleReserved opcode 0x3), and that name is the client's disconnect reason. It does not check the UTF-8 of a text frame, because a fragment can end inside a code point.is_valid_utf8usesutils.utf8_sequence_length, the decoder behind the outboundutils.valid_utf8, so it also rejects overlong forms, UTF-16 surrogates, code points above U+10FFFF, and the bytes C0, C1 and F5 to FF [server/frame.lua:M.parse_frame] [server/utils.lua:M.is_valid_utf8]parse_framedoes not reject unmasked client frames. Server frames are unmasked by default (masked=false) [server/frame.lua:M.create_frame]- Every text frame the server sends is valid UTF-8.
send_messagereplaces each ill-formed sequence with U+FFFD, so a buffer name or path with invalid bytes reaches the client as U+FFFD and does not drop the connection [server/client.lua:M.send_message] - JSON-RPC errors: -32700 for a parse error and -32600 unless
jsonrpc == "2.0"[server/init.lua:M._handle_message]. -32601 for an unknown method and -32603 for a handler that raises [server/init.lua:M._handle_request] - When the JSON decodes to a non-table value (for example a number or a boolean),
_handle_messagereplies -32600 with"id": null. It does not raise [server/init.lua:M._handle_message] - A message with a truthy
idis a request. Any other message is a notification. A valid notification never gets a response. The -32700 and -32600 checks run before theidcheck, so a malformed notification gets an error reply [server/init.lua:M._handle_message] - A deferred response is keyed by
tostring(coroutine)in_G.claude_deferred_responses. A result with neithercontentnorerrorbecomes -32603 [server/init.lua:M._setup_deferred_response] - The ping timer closes a connected client when two ticks in a row find no pong within
interval * 2. The first such tick pings again and setsclient.pong_overdue, because a tick that Neovim runs late after a block can come before the loop has read the pong of the last ping. The next tick finds that pong, or drops the client. A tick that finds a pong clearspong_overdue. The Close frame of a dropped client carries 1011 "Keepalive timeout" (RFC 6455 reserves 1006, so no Close frame carries it),on_disconnectgets 1006 "Connection timeout", and the timer logsClosed client_<n>: no reply for <s>sat info level. A peer that stopped reading does not get that Close frame: once its receive window is full, every write to it (replies, pings, and the Close frame) waits in libuv's queue, and closing the socket drops the queue [server/tcp.lua:M.start_ping_timer] [server/tcp.lua:drop_client] - A Claude that answers each ping but never reads would keep that queue, which is Neovim's memory, without limit, so the server also closes a connected client that answers and whose write queue made no progress at two ticks in a row. Each tick calls
is_client_readingfor each connected client, which callssample_write_progress: it comparesqueued_bytes - get_write_queue_size()with its value at the last progress, and an empty queue counts as progress. A tick that finds progress clearsclient.write_stalled. The first tick that finds none sets it. The second asks for a confirmation, and a confirmation that still finds no progress drops the client: the Close frame carries 1011 "Not reading",on_disconnectgets 1006 "Write queue stalled", and the server logsClosed client_<n>: not reading for <s>s, <n> KB queuedat info level. The confirmation runs right after the next poll. libuv reports that a socket is writable again only in a poll phase, so a tick that Neovim runs after a main-loop block inside an I/O callback can find no progress although Claude read during the block, whether the tick is late or on time.after_next_pollarms a check handle, which libuv runs right after each poll phase in every version, and an idle handle, which keeps that poll from waiting for a timer or a socket. The check callback closes both handles, removes them fromserver.pending_polls, and judges the queue once more: progress clearswrite_stalled, and no progress drops the client. A client that left or started to leave in the meantime, because the poll read its EOF, its Close frame or a protocol error, is skipped without a log line.stop_servercloses the handles of a confirmation that has not run, whichserver.pending_pollslists. If libuv cannot create a handle, the tick leaves the client for the next tick. The look is a sample at one moment, not proof. libuv takes its snapshot of the ready sockets when a poll starts, so a callback that blocks inside that poll, after the snapshot, hides what Claude reads during the block from the check callback that follows. A client that was stalled for the whole window and resumes during such a block is dropped. That is a deadline race, as with any timeout: the client was stalled when the window ended, and what it read afterwards is not reported yet. Verifier probes closed a reader that resumed 0.1 s into a 0.5 s to 1.5 s block inside the confirming poll, on Neovim 0.12.3 and 0.8.0, at 3 s, 5 s and 30 s intervals. A block after the tick and before that poll, or in a scheduled callback, keeps the reader. The bound is two to three ticks after the last progress, which is 60 s to 90 s at the default interval, plus the delay of ticks that Neovim runs late by less than 1.5 intervals. A tick gap aboveinterval * 1.5counts as a wake from sleep. A wake tick starts a fresh stall window: it clears the count and judges nothing, so it delays the drop by one tick when the count was clear, and by two ticks when the wake tick takes the place of the confirming tick. The next two ticks always count, wake ticks or not, because wake ticks that come again and again are a loop that stays blocked, not sleeps. So whatever the pattern of late ticks, a stalled client is closed at the second tick after a fresh window started. A wake tick three or more ticks after the last fresh start starts a new window. The bound is never the size of the queue, so a Claude that reads slowly, or is sent one message of tens of MB, stays connected while libuv writes any of it. libuv writes more only after the kernel reports the socket writable again, which took 1.2 to 1.5 MB of reading on a Linux loopback socket with default buffers (measured), so a Claude that reads less than about 1.4 MB per window counts as stalled while a backlog exists, and one that reads just faster than that still lets the queue grow as long as Neovim queues more than it reads.writequeues a frame of more than 1 MiB in pieces of 1 MiB, because libuv on Windows lowers the queue only when a whole write request is done, so a large frame shows its progress per piece there too, and the callback of the last piece reports the frame. [server/tcp.lua:M.start_ping_timer] [server/tcp.lua:confirm_stall] [server/tcp.lua:after_next_poll] [server/tcp.lua:M.stop_server] [server/client.lua:M.is_client_reading] [server/client.lua:M.sample_write_progress] [server/client.lua:write] - On one tick the pong rule runs first. A client without a pong gets the second chance or the keepalive drop whatever its write queue shows, and only a client that answered is confirmed for a stall. A pong that arrives while the queue is stalled does not save the client: the next tick clears
pong_overdue, and then asks for the confirmation. Every connected client is sampled on every tick except one that starts a fresh stall window, answering or not, so "two ticks in a row" means consecutive ticks. A tick gap aboveinterval * 1.5counts as a wake from sleep and resetslast_pongof each connected client, and its own alive check finds the fresh pong and clearspong_overdue. A wake tick that starts a fresh stall window also resetslast_drain, clearswrite_stalled, and does not sample the queue.last_drainfeeds only thenot reading for <s>sof the log line, so the reset keeps the sleep out of it. A wake tick within two ticks after the start of a fresh window resetslast_pongonly, and counts like any tick. The 101 write callback setslast_pongagain, so the keepalive window starts when the client is connected, not at the accept. A main loop that blocks during the handshake then does not count against the client [server/tcp.lua:M.start_ping_timer] [server/client.lua:M.process_data] _disconnect_clientis idempotent:on_disconnectfires once per client id. A client that already left the server (it stopped before a scheduledon_closeor an EOF ran) gets its socket closed there, unless the write callback of its Close frame will close it [server/tcp.lua:M._disconnect_client]- A client's read error or protocol error reaches the server only as its
on_disconnectreason (Client read error: …orClient error: …), whichinit.lualogs at debug level.on_errorcarries only the server's own failures (listen, accept, and creating a client handle or the ping timer), whichinit.lualogs at error level [server/tcp.lua:M._handle_new_connection] [server/init.lua:M.start] - A rejected handshake logs
Rejected client_<n>: <reason>at warn level, with the reason from the body of the error response. Once the error response is written,on_errorremoves the client from the server and closes its socket [server/client.lua:M.process_data] - Once a client is closing,
process_datadrops the bytes it sends, andclose_clientfrees the unparsed bytes and the reads of an unfinished frame [server/client.lua:M.process_data] [server/client.lua:M.close_client] - The server logs each error, warning, and info line from a libuv callback through
logger.one_line, so the line never stops Neovim at "Press ENTER" [server/init.lua:M.start] [server/client.lua:M.process_data] [server/tcp.lua:M.start_ping_timer] - Each connection gets the id
client_<n>, wherencounts the connections of this Neovim process in_G.claudecode_clients_created. An id never repeats, even after a reload ofclaudecode.server.*. A Claude CLI that reconnects gets a new id, so it does not own the diffs it opened before [server/client.lua:M.create_client] - When the state is already
closingorclosed,close_clientsends nothing [server/client.lua:M.close_client] - A Close frame queued by
close_clientis followed by the close of the socket, from its write callback, soclient.close_pendingis set until then and_remove_clientleaves the socket open. After a protocol errorprocess_datacallson_errorat once, so the client leavesserver.clientsandon_disconnectruns while the frame can still wait in libuv's queue behind the replies of a Claude that does not read. The ping timer cannot see that client, because it leftserver.clients, soclose_clientstarts a deadline for the frame (client.close_timer, a repeating timer ofclient.keepalive_timeout). A socket that wrote no byte of its queue during a keepalive window is closed, and the queue with it, while a Claude that reads keeps its socket. The window starts with the frame, and one window without progress is enough here: the client is leaving anyway, so a close that comes too early after a blocked loop costs only its Close frame. The write callback and the deadline each close the socket if it is still open and end the other, so the first one to run wins, and a deadline never touches a socket that closed. A server without a ping timer has no keepalive window and its clients get no deadline, butM.startstarts the timer in the same call that listens, before any client can connect.drop_clientcallsabandon_close_frame, so a client that the ping timer drops, for a keepalive timeout or a stalled write queue, has its socket closed at once [server/client.lua:M.close_client] [server/client.lua:fail_connection] [server/client.lua:start_close_timer] [server/tcp.lua:release_socket] [server/tcp.lua:drop_client] sha1rejects non-string input and input over 10 MiB [server/utils.lua:M.sha1]
M.startchanges the global tool registry (tools.setup), opens a listening TCP handle, and starts avim.loopping timer [server/init.lua:M.start]- When a client completed its WebSocket handshake (
on_open, called from the 101 write callback inclient.lua), the server schedulesclaudecode.process_mention_queue(true).on_connectruns at the TCP accept, before the handshake, and only logs [server/init.lua:M.start] [server/client.lua:M.process_data] - On disconnect, the server schedules
diff.close_diffs_for_client(client.id, "client disconnected"), only whenpackage.loaded["claudecode.diff"]is set [server/init.lua:M.start] M.stopcallsdiff.close_pending_diffs("server stopping")whenpackage.loaded["claudecode.diff"]is set, resets_G.claude_deferred_responses, and clearsM.state.auth_token[server/init.lua:M.stop]M.startlogs only the auth token length, not the token [server/init.lua:M.start]tools/calldebug lines name the tool and list the argument and result keys with their sizes (string bytes, table entry counts), never their values, so file contents and selected text stay out of:messages. An error result logs its code and message [server/init.lua:M.register_handlers]client.create_clientincrements_G.claudecode_clients_created[server/client.lua:M.create_client]tcp.luaseedsmath.randomseedonce per process.init.luacallsmath.randomat load. Both change the global PRNG [server/tcp.lua:ensure_rng_seeded]client.process_datawrites handshake, PONG, and Close frames to the socket. It runson_messageandon_closethroughvim.schedule. It callson_errordirectly after a failed handshake write, and after it queues the Close frame for a protocol error [server/client.lua:M.process_data] [server/client.lua:M.close_client]- The ping timer writes ping frames and closes clients that timed out or stopped reading [server/tcp.lua:M.start_ping_timer]
- A tick that finds the second stalled tick in a row creates a
vim.loopcheck handle and an idle handle for one confirmation. The check callback closes them, andstop_servercloses those that did not run [server/tcp.lua:after_next_poll] [server/tcp.lua:M.stop_server] close_clientstarts a repeatingvim.looptimer for the Close frame of a client that haskeepalive_timeout. It runs until the frame is written, the socket closes, or the deadline closes the socket [server/client.lua:M.close_client] [server/client.lua:start_close_timer]handshake.lua,frame.lua, andutils.luado no I/O [server/handshake.lua:M]mock.luaopens no sockets and keeps its clients and messages in its ownM.statetables. Itsmcp.tool.invokehandler calls the realtools.handle_invoke, so a simulated tool call has that tool's real side effects (for examplesaveDocumentwrites the file) [server/mock.lua:M.register_handlers] [server/mock.lua:M.state]
- Run:
mise run test - Targeted:
busted -v tests/unit/server/frame_spec.lua,busted -v tests/unit/server/handshake_spec.lua,busted -v tests/unit/server/tcp_spec.lua,busted -v tests/unit/server_spec.lua