wandres.dev
EL PROTOCOLO LSP · qué habla el servidor

El handshake: initialize y la negociación de capacidades

La secuencia exacta de arranque de una sesión LSP, qué declara el cliente sobre sí mismo, qué responde el servidor sobre lo que sabe hacer, y por qué una función que existe en el protocolo puede no estar disponible con un servidor concreto sin que nada esté roto.

⏱ 20 min

Un protocolo que debe servir a cuarenta lenguajes y a veinte editores no puede exigir que todos implementen todo. Algunos servidores no tienen renombrado seguro porque el lenguaje no lo permite; otros no ofrecen jerarquía de llamadas porque nadie la ha escrito todavía; algunos clientes no saben mostrar código en línea y otros no tienen concepto de espacio de trabajo con varias carpetas. La solución podría haber sido versionar el protocolo y obligar a todos a cumplir un nivel completo, y habría sido un desastre: cada extensión habría requerido una migración coordinada de todo el ecosistema. LSP eligió lo contrario. Antes de intercambiar una sola consulta útil, los dos extremos se envían un inventario detallado de lo que saben hacer, y a partir de ese momento cada uno se limita a lo que el otro declaró entender. Ese inventario es la parte del protocolo que más determina tu experiencia diaria y la que casi nadie llega a leer.

🎯 Al terminar esta lección sabrás
  • Reproducir la secuencia de arranque completa: initialize, respuesta, initialized y primeras notificaciones.
  • Leer las capacidades que el cliente declara y entender que muchas son promesas de comportamiento, no permisos.
  • Inspeccionar las capacidades del servidor para saber qué funciones existen realmente en una sesión concreta.
  • Diagnosticar la ausencia de una función distinguiendo entre no soportada, no anunciada y no configurada.

La secuencia de arranque

El arranque tiene un orden estricto y no negociable. El cliente lanza el proceso servidor y le envía como primera petición initialize, con el identificador del proceso padre, la raíz del espacio de trabajo, opciones de inicialización específicas del servidor y —lo importante— su propio bloque de capacidades. El servidor responde con su bloque de capacidades y opcionalmente con información sobre sí mismo. El cliente confirma con la notificación initialized. Solo a partir de ese punto puede fluir cualquier otra cosa.

Antes de recibir la respuesta a initialize, el servidor debe rechazar cualquier petición con un error específico de servidor no inicializado y debe descartar en silencio cualquier notificación, con la única excepción de la de salida. Esa regla parece burocrática hasta que se comprende su función: garantiza que ningún mensaje se interprete bajo un conjunto de capacidades distinto del negociado. Sin ella, una notificación de apertura de documento que llegase antes del acuerdo dejaría al servidor con un documento cuya forma de sincronización aún no está decidida.

sequenceDiagram
participant C as cliente
participant S as servidor
C->>S: initialize con capacidades del cliente
S-->>C: resultado con capacidades del servidor
C->>S: initialized
C->>S: didOpen del primer documento
S-->>C: publishDiagnostics
C->>S: shutdown
S-->>C: resultado vacio
C->>S: exit

El cierre es igual de ceremonioso y por la misma razón. La petición shutdown pide al servidor que deje de aceptar trabajo pero no que termine; el servidor responde y queda a la espera. Solo entonces el cliente envía la notificación exit, y el servidor termina con código cero si recibió shutdown antes y con código uno si no. Ese doble paso permite que el cliente sepa que el servidor liberó sus recursos —índices en disco, ficheros temporales— antes de que el proceso desaparezca.

Lo que el cliente promete

El bloque de capacidades del cliente no es una lista de permisos, es una lista de promesas de comportamiento. Cuando Neovim declara que soporta ediciones de documento con anotaciones de cambio, está prometiendo que si el servidor le envía una edición con ese formato sabrá aplicarla. Si mintiera, la sesión seguiría funcionando hasta que el servidor usara la función, y entonces fallaría de forma opaca. Por eso el bloque es largo y detallado: describe formatos concretos, no funciones abstractas.

-- Las capacidades base de Neovim, ampliadas por lo que aporte tu motor
-- de completado o cualquier otro plugin que sepa hablar el protocolo.
local caps = vim.lsp.protocol.make_client_capabilities()

caps.textDocument.completion.completionItem.snippetSupport = true
caps.textDocument.completion.completionItem.resolveSupport = {
  properties = { "documentation", "detail", "additionalTextEdits" },
}
caps.workspace.didChangeWatchedFiles.dynamicRegistration = true

vim.lsp.config("*", { capabilities = caps })

Dos familias de capacidades merecen mención aparte porque cambian el comportamiento observable de forma drástica. La primera es el soporte de plantillas en los elementos de completado: si el cliente no lo declara, un servidor que sabría insertar una llamada con sus paréntesis y sus marcadores de posición insertará únicamente el nombre. La segunda es el registro dinámico, que permite al servidor añadir o quitar capacidades después del arranque mediante peticiones al cliente; un servidor puede empezar sin formateo y anunciarlo más tarde, cuando termine de indexar o cuando detecte que hay una herramienta instalada.

🤝

initialize

Única petición admitida antes del acuerdo. Lleva raíz del proyecto, opciones y capacidades del cliente.

📋

server_capabilities

Inventario de lo que el servidor sabe hacer en esta sesión concreta. Consultable en todo momento.

🔄

Registro dinámico

Permite que el conjunto de capacidades cambie tras el arranque. Explica funciones que aparecen tarde.

Por qué falta una función

Aquí está el valor práctico de todo lo anterior. Cuando una función no funciona con un servidor determinado, hay exactamente cuatro causas posibles y confundirlas es la fuente de horas perdidas. La primera es que el servidor no la implementa en absoluto. La segunda es que la implementa pero no la anunció, porque su configuración la desactiva o porque depende de una herramienta externa ausente. La tercera es que el servidor la anuncia y el cliente no la pidió, es decir, no declaró la capacidad correspondiente. La cuarta es que ambos la soportan pero el servidor la registró dinámicamente después y algo falló en ese registro.

Distinguirlas es cuestión de mirar el inventario en lugar de adivinar. La respuesta de initialize queda guardada en el objeto cliente y se puede inspeccionar en cualquier momento de la sesión.

local c = vim.lsp.get_clients({ bufnr = 0 })[1]

-- Que sabe hacer este servidor, exactamente, en esta sesion.
vim.print(vim.tbl_keys(c.server_capabilities))

-- Comprobacion puntual antes de mapear una tecla que si no fallaria en silencio.
-- Nota: renombrar YA esta en grn de fabrica. Aqui la tecla es solo el ejemplo;
-- lo que importa es el patron de preguntar antes de mapear.
if c:supports_method("textDocument/rename") then
  vim.keymap.set("n", "<leader>cr", vim.lsp.buf.rename, { buffer = 0 })
end

-- El modo de sincronizacion negociado: 1 es completo, 2 es incremental.
vim.print(c.server_capabilities.textDocumentSync)

Un caso especialmente instructivo es el de los proveedores que no son booleanos sino objetos con opciones. El proveedor de completado, por ejemplo, declara además qué caracteres deben disparar una petición y si los elementos admiten una segunda fase de resolución; el proveedor de acciones de código declara qué familias de acciones ofrece. Un cliente que trate esos campos como si fueran booleanos funcionará, pero desperdiciará la mitad de la información: pedirá completado en momentos inútiles y ofrecerá al usuario categorías de acciones que ese servidor nunca va a devolver.

{
  "capabilities": {
    "textDocumentSync": 2,
    "hoverProvider": true,
    "definitionProvider": true,
    "renameProvider": { "prepareProvider": true },
    "completionProvider": {
      "triggerCharacters": [".", ":"],
      "resolveProvider": true
    },
    "codeActionProvider": {
      "codeActionKinds": ["quickfix", "refactor.extract", "source.organizeImports"]
    }
  },
  "serverInfo": { "name": "rust-analyzer", "version": "1.90" }
}

Conviene además recordar que las capacidades son por sesión y no por servidor. El mismo binario, arrancado en dos proyectos distintos con configuraciones distintas, puede anunciar inventarios diferentes; y con varios clientes adjuntos al mismo buffer —lo habitual cuando se combina un servidor semántico con uno de diagnósticos externos— la pregunta correcta nunca es qué soporta el servidor, sino qué soporta algún cliente adjunto a este buffer.

Un acuerdo que sigue vivo

El inventario negociado al arrancar no es definitivo, y esa es la parte del ciclo de vida que más confusión genera. Mediante registro dinámico, el servidor envía al cliente una petición pidiendo que active un conjunto de capacidades adicionales, con sus opciones y con un identificador que después permitirá retirarlas. La consecuencia observable es que hay funciones que no existen durante los primeros segundos de una sesión y aparecen cuando el servidor termina de cargar el proyecto; quien no conoce el mecanismo lo vive como intermitencia inexplicable.

Hay además un segundo canal de cambio en sentido contrario. El cliente puede notificar que su configuración se modificó, y muchos servidores reaccionan reconstruyendo su estado interno o ajustando lo que ofrecen. Esa notificación es la vía correcta para cambiar opciones sin reiniciar el proceso, y desperdiciarla reiniciando el servidor entero por cada ajuste es un hábito caro en proyectos grandes, donde el coste real está en reindexar.

-- Enganchar el momento exacto en que un servidor queda adjunto y disponible.
vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(ev)
    local c = vim.lsp.get_client_by_id(ev.data.client_id)
    if not c then return end

    -- Mapear solo lo que este servidor prometio: nada de teclas mudas.
    local mapa = {
      ["textDocument/definition"]     = { "gd", vim.lsp.buf.definition },
      ["textDocument/references"]     = { "grr", vim.lsp.buf.references },
      ["textDocument/rename"]         = { "grn", vim.lsp.buf.rename },
      ["textDocument/implementation"] = { "gri", vim.lsp.buf.implementation },
    }
    for metodo, par in pairs(mapa) do
      if c:supports_method(metodo) then
        vim.keymap.set("n", par[1], par[2], { buffer = ev.buf })
      end
    end
  end,
})

-- Cambiar opciones sin reiniciar ni reindexar.
local c = vim.lsp.get_clients({ name = "luals" })[1]
if c then
  c.settings = vim.tbl_deep_extend("force", c.settings or {}, {
    Lua = { diagnostics = { globals = { "vim" } } },
  })
  c:notify("workspace/didChangeConfiguration", { settings = c.settings })
end

El detalle que hace correcto este patrón es que la comprobación ocurre en el momento en que el cliente queda adjunto, no al cargar la configuración. Es la única forma de que la decisión se tome con el inventario real de esa sesión, y explica por qué mapear teclas de forma global en el fichero de configuración produce atajos que funcionan en unos proyectos y no en otros.

La negociación de capacidades es cómo un protocolo evoluciona sin romperse, y por eso paga el precio de la incertidumbre

Detente en el problema que resuelve este mecanismo, porque es uno de los más difíciles de la ingeniería de sistemas distribuidos: cómo hacer evolucionar un contrato entre partes que se actualizan por separado, sin coordinación central y sin poder desplegarlas a la vez. La respuesta ingenua es versionar el protocolo entero, y funciona hasta que hay más de dos implementaciones, momento en el cual cada versión nueva obliga a todo el ecosistema a moverse en bloque o a fragmentarse. La respuesta de LSP es descomponer el contrato en cientos de contratos diminutos e independientes, y negociarlos uno a uno al arrancar. El efecto es que una extensión del protocolo nunca rompe nada: los que la entienden la anuncian y la usan, los que no la ignoran, y ambos grupos siguen hablando entre sí sin degradación. Es exactamente el mismo principio que hace que un navegador antiguo siga renderizando una página que usa propiedades nuevas, y es la razón de que un protocolo diseñado hace una década siga admitiendo funciones que nadie había imaginado entonces. Ahora reconoce el precio, porque lo hay y es alto. Un sistema donde cada función puede estar o no estar es un sistema donde no existe un comportamiento de referencia. No puedes escribir documentación que diga qué hace tu editor, solo qué haría si el servidor lo permitiera. No puedes escribir una configuración que funcione igual en todos los proyectos, porque las capacidades cambian con la sesión. Y sobre todo, no puedes depurar por intuición: la pregunta que resuelve el noventa por ciento de los problemas de LSP no es qué está fallando, sino qué se negoció al arrancar, y esa pregunta solo se contesta leyendo el inventario. Por eso la disciplina profesional consiste en comprobar el soporte antes de ofrecer una función al usuario en lugar de mapear la tecla y esperar. Un atajo que no hace nada es un fallo de diseño del que lo mapeó, no del servidor: el servidor fue perfectamente honesto sobre sus límites en el primer mensaje de la conversación.

⚔️ Auditar el acuerdo
  1. Imprime el inventario de capacidades de cada servidor adjunto a un buffer y clasifícalas en booleanas y con opciones.
  2. Localiza los caracteres que disparan completado en dos servidores distintos y explica qué cambia en tu experiencia de escritura.
  3. Desactiva una capacidad del cliente antes de arrancar y documenta qué función deja de comportarse como esperabas.
  4. Encuentra una función que tu editor ofrece y el servidor no anuncia; determina cuál de las cuatro causas aplica.
  5. Registra el momento del arranque en que aparece cada capacidad y detecta si alguna llegó por registro dinámico posterior.