Lo que construir un servidor MCP me enseñó sobre la API de GitLab
Entre una revisión de su registro de defectos upstream, el 5 de octubre de 2026, y el 9 de octubre, gitlab-mcp-server envió 23 merge requests a GitLab, client-go y Orbit; a las 04:48 UTC del 10 de octubre se habían fusionado 15.

En un GitLab de pruebas con licencia, mi servidor MCP intentó aprobar un merge request con el token de la persona que lo había abierto. La instancia tenía activado “Prevent approval by merge request creator”, así que GitLab se negó, y se negó con 401 Unauthorized y exactamente el mismo cuerpo que devuelve para un token que no existe. El servidor hizo lo que dice la tabla de códigos de estado y le dijo a su usuario que el token de GitLab podía ser inválido o haber caducado. El token estaba bien. La tabla en la que se basaba daba a un 401 un único significado, que el usuario no está autenticado: “The user isn’t authenticated.”
Ese servidor es gitlab-mcp-server, que expone las API REST y GraphQL de GitLab como herramientas para clientes a través del Model Context Protocol. Para ello tiene que saber qué acepta y qué devuelve cada operación de GitLab, y hay tres sitios donde aprenderlo. Este artículo cuenta lo que pasó cuando dejé de fiarme a ciegas de dos de ellos y convertí al tercero, un GitLab en marcha, en el árbitro.
TL;DR: lo que un GitLab arrancado me contó de su API
4 puntos clave- La API de GitLab tiene tres descripciones: la documentación y los documentos OpenAPI generados a partir del bloque
descde cada ruta, las estructuras de client-go y lo que responde de verdad una instancia. Discrepan más a menudo de lo que cabría esperar. - gitlab-mcp-server cubre entre 872 (Free) y 1.098 (GitLab.com Ultimate) operaciones de GitLab en su catálogo 3.2.0, así que se contrasta con una instantánea tomada de un GitLab 19.4.1 arrancado, un inventario de las peticiones que envían sus tests unitarios, el esquema GraphQL que sirve GitLab.com, una auditoría que los une y una ejecución end-to-end en Docker.
- Lo que encontré en GitLab y en su SDK de Go al construir y contrastar el servidor: un rechazo por permisos respondido con 401, escrituras respondidas con 200 y 204 que no guardaban nada, una ruta documentada con la entidad de respuesta equivocada, un desafío
WWW-Authenticateque faltaba y tipos del SDK que no pueden decodificar lo que envía GitLab. - Cada uno de esos hallazgos entra en un registro público con su evidencia y el workaround que exige. Entre una revisión del registro el 5 de octubre de 2026 y el 9 de octubre, 23 merge requests anotados en él se enviaron upstream: a gitlab-org/gitlab (20), a client-go (2) y al grafo de conocimiento Orbit (1). A las 04:48 UTC del 10 de octubre, 15 se habían fusionado, 13 de ellos en los dos días siguientes a su apertura.
Tres descripciones de una misma API
El servidor cubre la API de GitLab operación por operación, y cada acción conlleva dos obligaciones: enviar una petición que GitLab acepte y publicar lo que GitLab devuelve. Hay tres sitios donde aprender ambas cosas:
- La documentación, y los documentos OpenAPI que GitLab genera a partir del bloque
descde cada ruta de Grape. - client-go, el SDK de Go de GitLab, sobre cuyas estructuras y tipos de opciones está construido el servidor.
- Un GitLab en marcha.
Los dos primeros describen el tercero. Los escribe gente, y se desfasan. En junio de 2026, un usuario preguntó en gitlab-org/api/client-go#2269 si el desfase entre la API y el cliente se podía detectar de forma más determinista. Un mantenedor respondió que se había hablado de pasar a estructuras generadas a partir del documento OpenAPI, pero que, al menos entonces, el esquema no era completo, y que no conocían una buena forma de detectar el desfase en algo como GraphQL. En septiembre le puse cifra: gitlab-org/api/client-go#2300 cuenta 897 campos que GitLab envía y que la librería no modela.
Nada de eso es culpa de nadie. Una descripción que nadie contrasta con lo que describe acaba desfasándose, y la única forma de contrastarla es preguntar a lo descrito.
Convertir GitLab en el árbitro
Acabé con cinco comprobaciones. Cada una ve algo que las demás no pueden ver, y las tres primeras tienen cada una un punto ciego que conviene contar, porque la primera escondió uno de los bugs que vienen después.
Una instantánea tomada de un GitLab arrancado
cmd/gen_api_live arranca una imagen publicada de gitlab-ee, ejecuta dentro un script de Ruby con gitlab-rails runner y pregunta a la aplicación cargada cuál es su API REST: cada entidad de Grape con sus campos y la condición bajo la que se envía cada uno, cada ruta con sus parámetros y la entidad que nombra su desc, y la tabla de funcionalidades con licencia. La respuesta se guarda en el repositorio como gitlab-api-live.json y todas las auditorías la leen offline, así que ninguna auditoría de la API REST tiene que arrancar un GitLab, ni en CI ni en ningún otro sitio. Tomar la instantánea no requiere licencia, porque la licencia limita funcionalidades cuando se sirve una petición, no cuando se carga una clase. La actual se tomó de GitLab 19.4.1-ee el 4 de octubre de 2026 y contiene 596 entidades con 7.412 campos y 2.152 rutas.
Arrancar GitLab es mejor que analizar su código. Las dos instantáneas a las que sustituyó leían texto, el documento OpenAPI generado y un escaneo del código fuente de Grape, y frente a GitLab 19.3.1 la instancia tenía 1.935 campos que el escaneo nunca vio. GeoSiteStatus construye su lista de campos iterando sobre una constante hecha con dos llamadas a métodos, así que el código fuente dice, en la práctica, “expón la variable del bucle”: el escaneo encontró 26 campos y la instancia informa de 606.
Punto ciego: la entidad de respuesta de una ruta sale de su propia anotación desc, porque es todo lo que una ruta dice de sí misma antes de que se sirva una petición. Cuando la anotación se equivoca, la instantánea se equivoca con ella (la historia de los commits de contexto, más abajo).
Un inventario de las peticiones que envían los tests
Casi todos los tests unitarios construyen su cliente de GitLab con un mismo helper que envuelve el servidor mock con un grabador que apunta el método, la ruta con plantilla (/projects/:project_id/issues/:issue_id/notes), los nombres de los parámetros de consulta, las claves del cuerpo JSON y, para GraphQL, la operación y sus variables. Combinado para toda la suite, request-inventory.json contiene 1.633 filas de 178 paquetes (guardado en el repositorio el 8 de octubre). Existe porque ninguna auditoría anterior miraba la petición que construye un handler, y el comentario de paquete de la regla de auditoría que ahora lo lee cuenta lo que eso costó: nueve herramientas registradas que se publicaron sin poder funcionar, con todo perfecto salvo una petición que GitLab rechaza (las nueve peticiones eran GraphQL, y esas las detecta la siguiente comprobación):
Leído junto a las estructuras de opciones de client-go, el inventario encontró nueve parámetros opcionales que client-go escribe en el cuerpo de cada petición porque las etiquetas de sus campos no llevan omitempty, como "package_name_pattern": null en cada actualización de una regla de protección de paquetes. gitlab-org/api/client-go!3063 arregla los nueve y estaba en revisión el 9 de octubre.
Punto ciego: una fila es la unión de lo que un paquete envió a un endpoint y nunca dice qué nombres iban juntos en una misma petición; además, nada de lo que viaja por la red nombra la acción.
El esquema GraphQL que sirve GitLab.com
cmd/gen_graphql_schema hace introspección de GitLab.com y guarda el esquema en el repositorio, 4.475 tipos de 19.5.0-pre, tomados el 27 de septiembre de 2026, y el mismo helper de test valida contra él cada petición GraphQL que envía el test unitario de una herramienta, documento y variables. Incluso recorre por su cuenta los valores de los enums, porque la librería de validación aceptaba "critical" para VulnerabilitySeverity y GitLab no. Antes de fijar el esquema, un test GraphQL en verde solo demostraba que el código coincidía con su propio fixture: de las nueve herramientas rotas de antes, cuatro enviaban un documento que el esquema rechaza y cinco un valor que GitLab no tiene. A 9 de octubre, el esquema no rechaza ninguno de los 39 documentos del servidor, ni ninguno de los 36 documentos de client-go que puede juzgar.
Punto ciego: el coste de las consultas, porque la comprobación no impone ningún límite de profundidad ni de complejidad; las deprecaciones, que el esquema fijado no recoge; y los campos que le faltan a una release autogestionada, porque GitLab.com ejecuta una versión previa. Un job semanal de CI arranca la última imagen autogestionada y valida contra ella los documentos del propio servidor, lo que cubre el último de los tres para la release más reciente.
Una auditoría que las une, y sus declaraciones
cmd/audit_1to1 contrasta el servidor con el SDK y con la API regla a regla, incluida la petición que construye cada handler. Su ejecución del 9 de octubre comparó 240 tipos de salida con lo que la instantánea dice que envían sus endpoints, y encontró 2.066 campos que GitLab envía y que el servidor no publica, 1.414 de los cuales tampoco modela client-go. Ninguno se deja como ruido: un hallazgo que no es un bug del servidor se responde en una tabla de declaraciones con una categoría y un motivo, y una declaración que ya no coincide con nada hace fallar la ejecución. Esa regla es la que retira una declaración en cuanto una release de GitLab trae el arreglo, como muestra la sección sobre el registro.
Una ejecución end-to-end en Docker
Por debajo de todo eso, test/e2e/gitlab/ pone a trabajar el binario real del servidor contra un GitLab CE efímero o un EE con licencia, con 223 ficheros de test a 9 de octubre. Las comprobaciones unitarias detectan una petición que no puede funcionar; solo una instancia real detecta una respuesta que el servidor interpreta mal. El 401 del principio de este artículo salió de aquí.
Lo que encontré al construir y contrastar el servidor
Cada uno de estos casos tiene una fila en el registro y un merge request a GitLab o a client-go. Los estados son los leídos el 10 de octubre de 2026 a las 04:48 UTC.
Un 401 que en realidad era un 403
Este es el intercambio del principio, escrito como HTTP a partir de la línea de error que la entrada de este 401 en el registro conserva de la ejecución end-to-end con licencia, POST /api/v4/projects/109/merge_requests/1/approve: 401 {message: 401 Unauthorized}:
POST /
- PRIVATE-TOKEN: <un token válido del autor del merge request>
GitLab EE con licencia, Prevent approval by merge request creator activado Estado 401 Unauthorized Respuesta errónea
{"message":"401 Unauthorized"}El servidor dijo entonces: authentication failed: GITLAB_TOKEN may be invalid or expired.
Cuando después leí el código fuente de GitLab, encontré treinta sitios escritos para rechazar con un 401 a un usuario que ya está autenticado, todos menos uno mediante unauthorized!, desde fusionar y aprobar hasta los mirrors, los tokens de acceso y los enlaces de grupos SAML. Cualquiera puede reproducir el patrón con peticiones de solo lectura contra gitlab.com y un token que no sea Maintainer de gitlab-org/gitlab. Todos los 401 de abajo llevan el mismo cuerpo, {"message":"401 Unauthorized"}, y todas las rutas cuelgan de /api/v4:
| Código | Token | Petición |
|---|---|---|
401 | inexistente | GET /user |
401 | el mío | GET /projects/278964/remote_mirrors |
401 | el mío | GET /projects/278964/access_tokens |
200 | el mío | GET /user |
Cambiar el código de estado no era una opción, porque la guía de estilo de la API de GitLab considera un cambio incompatible cambiar cualquier código de estado que no sea 500. Un issue relacionado de GitLab, gitlab-org/gitlab#624056 (“Agree a consistent 401 and 403 error shape”), pide un formato de error común para 401 y 403 en todas las API de las funcionalidades modulares de GitLab, como Orbit y Artifact Registry, y gitlab-org/gitlab#383531 es un informe más antiguo, de 2022, sobre uno de los treinta sitios. Así que gitlab-org/gitlab!260774 documenta el comportamiento en lugar de cambiarlo. Se fusionó el 9 de octubre y ya figura en la página de resolución de problemas de la API REST en una sección nueva, “Status code 401”, que enumera los tres casos y la comprobación con GET /user. La fila de la tabla de códigos de estado ahora también lo dice:
401 Unauthorized: The user isn’t authenticated. A valid user token is necessary.
401 Unauthorized: The user isn’t authenticated. A valid user token is necessary. Some endpoints also return this status code when the user is authenticated but doesn’t have permission for the request. For more information, see status code 401.
Ahora el servidor solo culpa al token cuando GitLab lo hace: cuando el cuerpo lleva el código invalid_token de la RFC 6750, o cuando el 401 viene del endpoint GraphQL, que solo responde 401 desde sus comprobaciones de autenticación. Antes de que su pool HTTP descarte una credencial por un 401 ambiguo, pregunta una vez a GET /api/v4/user. Ese workaround se queda hasta que GitLab responda 403 en esos treinta sitios, y no hay en revisión ningún cambio que lo proponga.
Escrituras que GitLab daba por buenas
Un nombre de tablero que GitLab no puede guardar. Board valida un nombre modificado con un límite de 255 caracteres, y ningún otro punto del código lo comprueba. Con un nombre más largo o vacío, PUT /projects/:id/boards/:board_id respondía 200 OK con el tablero tal como estaba, y todos los demás atributos de la petición se descartaban junto con el nombre. POST respondía 201 Created con un tablero cuyo id era null, y no se había creado nada.
PUT /
{"name": "<256 caracteres>", "hide_backlog_list": true}Antes de gitlab-org/gitlab!260458 Estado 200 OK Respuesta errónea
El tablero tal como estaba guardado. No se guardó ni el nombre ni hide_backlog_list.
Después Estado 400 Bad Request Respuesta corregida
{ "message": { "name": [ "is too long (maximum is 255 characters)" ] } }
La causa en la actualización era una sola línea. El helper board no estaba memoizado, así que cada llamada volvía a cargar el tablero desde la base de datos, y tanto la comprobación de validez como la respuesta leían el tablero guardado, válido porque su nombre no había cambiado. El helper de creación devolvía el payload del servicio dijera lo que dijera el servicio.
- board_parent.boards.find(params[:board_id])
+ @board ||= board_parent.boards.find(params[:board_id])gitlab-org/gitlab!260458 memoiza el helper, responde a una escritura fallida en los dos helpers con render_validation_error! y deja constancia del límite en las páginas de la API. El revisor aceptó el cambio de código de estado como corrección de un bug, porque la respuesta nueva solo la recibe una petición que nunca se guardó. Se fusionó el 9 de octubre y ya está desplegado en GitLab.com. No añadí ningún workaround, porque una comprobación de longitud propia copiaría una validación que las páginas no indicaban.
Un borrado que no borra nada, y un rechazo que parece una caída. Gestionar las comprobaciones de estado externas requiere el rol Maintainer. Si un Developer borraba una comprobación, GitLab respondía 204 No Content y la comprobación seguía ahí; si la creaba, respondía 500 con {"message":["Not allowed"]}, que un cliente vuelve a intentar o notifica como una caída.
DELETE /
- PRIVATE-TOKEN: <token de un Developer>
Antes de gitlab-org/gitlab!260486 Estado 204 No Content Respuesta errónea
La comprobación sigue ahí.
Después Estado 403 Forbidden Respuesta corregida
{"message":"403 Forbidden"}
POST /
- PRIVATE-TOKEN: <token de un Developer>
Antes de gitlab-org/gitlab!260486 Estado 500 Internal Server Error Respuesta errónea
{"message":["Not allowed"]}Después Estado 403 Forbidden Respuesta corregida
{"message":"403 Forbidden"}
Los dos tenían la misma raíz: un rechazo del servicio que nunca llegaba a la respuesta. destroy_conditionally! fija el código de estado en 204 antes de ceder el control al bloque y descarta lo que este devuelve, y el rechazo del servicio de creación no llevaba ningún código HTTP, así que Grape respondía a su estado nil con un 500. El propio spec de GitLab solo probaba con un propietario, que sí puede gestionar las comprobaciones, y con alguien que no es miembro, a quien la ruta responde 404 antes de que se ejecute el servicio, así que ningún test de GitLab llegaba al rechazo del servicio. gitlab-org/gitlab!260486 comprueba el rol con authorize! antes de llamar al servicio, como ya hacían las rutas de merge requests del mismo fichero, y devuelve el error del servicio dentro del bloque de borrado, de modo que un borrado fallido es ahora un 422 en lugar de un 204:
post do
+ authorize! :create_external_status_check, user_project
...
- destroy_conditionally!(service.external_status_check) do
- service.execute
+ external_status_check = service.external_status_check
+ authorize! :delete_external_status_check, external_status_check
+
+ destroy_conditionally!(external_status_check) do
+ response = service.execute
+
+ render_api_error!(response.payload[:errors], response.http_status) if response.error?
endSe fusionó el 9 de octubre, en el hito 19.6. Hasta que lo lleve una release, el servidor lee un 500 con “Not allowed” como lo que es, un rechazo por rol, y la nota de uso de la acción de borrado pide a quien llama que confirme el borrado con la acción de listado, porque nada de lo que viaja por la red distingue ese 204 de uno real.
La ruta dice una entidad y envía otra
GET /projects/:id/merge_requests/:merge_request_iid/context_commits declaraba success Entities::Commit y devolvía Entities::CommitWithLink con type: :full, que añade diez claves: author, author_gravatar_url, commit_url, commit_path, description_html, title_html, signature_html, prev_commit_id, next_commit_id y pipeline_status_path. Grape usa el bloque desc solo para la documentación, así que la página de la documentación, los dos documentos OpenAPI y todo lo generado a partir de ellos describían una respuesta sin esas diez claves, y ningún test de GitLab podía notarlo.
La instantánea del GitLab arrancado tampoco podía verlo, porque toma la entidad de la misma anotación. Lo encontré leyendo el handler en lugar de la anotación, mientras exponía en el servidor las claves de los commits de contexto. El servidor publica cuatro de ellas a partir de la respuesta capturada, y como la instantánea dice que la ruta nunca las envía, cada una de las cuatro lleva una declaración en la auditoría.
desc 'List all context commits for a merge request' do
detail 'Lists all context commits for a specified merge request.'
- success Entities::Commit
+ success Entities::CommitWithLink
is_array truegitlab-org/gitlab!260487 cambia esa línea, regenera los dos documentos OpenAPI, añade una petición y una respuesta completa de ejemplo a la página de la API de commits de contexto y añade un request spec que obliga a las claves de la respuesta a coincidir con los campos que expone la entidad documentada, para que las dos no puedan volver a separarse. Contra master sin el cambio de la anotación, ese spec falla exactamente en las diez claves:
it 'returns the attributes of the entity the endpoint documents' do
route = described_class.routes.find do |r|
r.request_method == 'GET' && r.path.include?('/merge_requests/:merge_request_iid/context_commits')
end
documented_keys = route.options[:entity].root_exposures.map { |exposure| exposure.key.to_s }
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/context_commits", user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.first.keys).to match_array(documented_keys)
endSe fusionó el 9 de octubre. Dos arreglos anteriores del mismo tipo se fusionaron en septiembre: gitlab-org/gitlab!254698 para tres endpoints del scope del job token, y gitlab-org/gitlab!254699 para dos listados de grupos de un proyecto.
Los endpoints de aprobaciones son el mismo tipo de fallo con una vuelta de tuerca. El GET de aprobaciones de un merge request y los POST de aprobar y retirar la aprobación declaran una entidad de cuatro claves. Community Edition envía esas cuatro. Todos los builds de Enterprise Edition envían 24, con licencia o sin ella, GitLab.com incluido, porque el módulo EE sobrescribe, sin comprobar la licencia, el helper que devuelve la respuesta. El propio servidor se fió en su día de la descripción de cuatro claves: en septiembre, una instancia Community Edition respondió esas cuatro claves y el documento OpenAPI generado por GitLab coincidía, así que el servidor recortó su salida a cuatro. La instantánea tomada de una imagen EE no podía corregirlo, porque lee la misma anotación. Cuando consulté ese mismo GET en GitLab.com el 5 de octubre, respondió las 24, y ahora el servidor lee las otras 20 de la respuesta capturada.
gitlab-org/gitlab!259766 documenta las dos ediciones en la página y se fusionó el 9 de octubre. gitlab-org/gitlab!261004 hace que la entidad y el documento OpenAPI generado digan 24, como pidió un revisor, y está en revisión.
Un desafío que nunca se enviaba
Cuando a un token le falta el scope que necesita un endpoint, GitLab responde 403 con insufficient_scope en el cuerpo JSON y sin cabecera WWW-Authenticate. La sección 3 de la RFC 6750 pide el desafío, y el flujo de autorización de MCP lo lee para saber qué scope pedir a continuación. El propio código fuente de GitLab admite en lib/api/api_guard.rb que falta la cabecera y que eso rompe el estándar:
# FIXME: ForbiddenError (inherited from Bearer::Forbidden of Rack::Oauth2)
# does not include WWW-Authenticate header, which breaks the standard.Este salió mientras implementaba la detección de scope insuficiente para el modo OAuth del servidor, y es el caso más claro a favor de convertir GitLab en el árbitro. La implementación obvia, extraer error="insufficient_scope" del desafío, habría compilado, habría pasado los tests con un fake escrito a mano que emitía la cabecera y no habría saltado ni una sola vez contra un GitLab real. En su lugar, el servidor lee el cuerpo.
GET /
- Authorization: Bearer <token solo con read_user>
Sin gitlab-org/gitlab!260668 Estado 403 Forbidden Respuesta errónea
{ "error": "insufficient_scope", "error_description": "The request requires higher privileges than provided by the access token.", "scope": "api read_api" }Sin cabecera WWW-Authenticate.
Con gitlab-org/gitlab!260668 (en revisión) Estado 403 Forbidden Respuesta corregida
- WWW-Authenticate: Bearer realm="Protected by OAuth 2.0", error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", scope="api read_api"
{ "error": "insufficient_scope", "error_description": "The request requires higher privileges than provided by the access token.", "scope": "api read_api" }
gitlab-org/gitlab!260668 escribe el desafío a partir de los mismos parámetros que el cuerpo, para que los dos no puedan discrepar, y tras la revisión nombra solo los scopes que pueden autorizar la petición. Todos los demás 403 quedan igual, incluido el del token de grano fino. Está en revisión. El servidor no necesita nada de él: este es una contribución, no un arreglo que estuviera esperando.
La parte del SDK: client-go
Parte de lo que encuentran las comprobaciones está en el SDK y no en GitLab. Dos ejemplos.
Falla una página entera. GitLab envía los extended_trailers de un commit como un mapa que asocia cada trailer con la lista de sus valores, y client-go declara ExtendedTrailers map[string]string, como sigue haciendo en v3.17.0, su release más reciente a 9 de octubre de 2026. Así que ListCommits con trailers=true falla por completo en cuanto un commit de la página lleva un trailer:
"extended_trailers": {
"Approved-by": ["<nombre> <correo>", "<nombre> <correo>"],
"Cc": ["<nombre> <correo>", "<nombre> <correo>"]
}client-go v3.15.0 a v3.17.0, ExtendedTrailers map[string]string:
json: cannot unmarshal array into Go struct field .1.extended_trailers.Cc of type string
client-go!3082, ExtendedTrailerValues map[string][]string:
la página se decodificaEse error es el que gitlab-org/api/client-go!3082 anota para su página de prueba, cuyo segundo commit lleva dos trailers Cc:. El merge request añade ExtendedTrailerValues map[string][]string sobre la clave extended_trailers y depreca el campo antiguo, para que ningún código que lo use deje de compilar. Hasta que se publique, el servidor pasa por alto el error de decodificación de client-go y lee los commits de la respuesta que ya recibió.
Un campo que nunca se decodifica. ImportStatus etiqueta su marca de tiempo como create_at mientras GitLab envía created_at, así que nunca se ha rellenado. gitlab-org/api/client-go!3085 lo arregla y añade campos que GitLab envía y acepta y que les faltan a otras nueve estructuras, como last_commit en las entradas del árbol y raw en las variables de pipeline. Los dos están en revisión.
Cuando el servidor necesita un campo que client-go no modela, lo lee de la respuesta que ya tiene en lugar de hacer una petición propia, como establece el ADR-0021 del servidor; el 9 de octubre lo hacían 84 ficheros que no son de test. Cada carencia leída así queda anotada en el registro con el handler que la lleva, y el workaround se queda hasta que el servidor pasa a una release de client-go que modele el campo.
Del hallazgo al merge request
El registro upstream de gitlab-mcp-server es un único fichero Markdown del repositorio, que se presenta como la lista de defectos y carencias de los proyectos de los que depende el servidor, guardados ahí para aportar su arreglo upstream y no limitarse a esquivarlos: “Defects and missing capabilities found in projects this server depends on, kept here so they are contributed back rather than only worked around.” Cada entrada anota qué es el defecto y dónde está, cómo se encontró, el workaround que exige y el merge request con su estado. Es permanente: una entrada nunca se borra, y cuando llega un arreglo se marca como fusionada con la release que lo incluye.
Apuntar un hallazgo de esa forma, con su evidencia y lo que le cuesta a un cliente, ya es la mayor parte del trabajo de un merge request. Para un arreglo de GitLab, el ciclo se cierra así:
Cuando un defecto de GitLab le cuesta un workaround al servidor, el hallazgo avanza por dos carriles: esquivado en el servidor, arreglado upstream. Volver a tomar la instantánea de la release de GitLab que trae el arreglo deja las declaraciones sin coincidir con nada, y el build falla hasta que se borran; un workaround que la instantánea no puede ver, como una pista, se revisa a mano.
El arreglo de los commits de contexto es el siguiente en pasar por ahí. Cuando cmd/gen_api_live se ejecute contra una release que lleve gitlab-org/gitlab!260487, la instantánea recogerá esa ruta con CommitWithLink, las cuatro declaraciones no coincidirán con nada, la comprobación de declaraciones obsoletas fallará y se borrarán junto con la explicación que llevaban. Entonces, las seis claves que el servidor deja fuera aparecerán como claves que GitLab envía y que el servidor no publica, y cada una necesitará su propia declaración, mientras que las cuatro que publica seguirán leyéndose de la respuesta capturada hasta que client-go las modele.
El ciclo ya ha dado alguna vuelta. gitlab-org/gitlab!254698, el arreglo del scope del job token de antes, salió en GitLab 19.4.0, y cuando la instantánea pasó de 19.3.1-ee a 19.4.1-ee, la comprobación de declaraciones obsoletas falló por la declaración que había respondido a sus hallazgos, hasta que se eliminó. En el lado de client-go, el ciclo se cierra cuando el servidor pasa a una release que lleva el arreglo, no a través de la instantánea: la fila 34 del registro enumera catorce merge requests a client-go que añaden campos que GitLab envía de forma incondicional. Nacieron de la medición de client-go#2300, los catorce se fusionaron y se publicaron entre client-go v3.1.0 y v3.15.0 (del 9 al 28 de septiembre de 2026), y cada uno retiró un workaround del servidor.
En cifras
Según la lectura del 10 de octubre de 2026 a las 04:48 UTC, para los merge requests que el registro anota como abiertos entre su revisión del 5 de octubre y el 9 de octubre:
| Dato | Valor |
|---|---|
| Abiertos en el periodo | 23: gitlab-org/gitlab 20, client-go 2, grafo de conocimiento Orbit 1 |
| Fusionados | 15: gitlab-org/gitlab 14, grafo de conocimiento Orbit 1 |
| Fusionados en los dos días siguientes a su apertura | 13 de los 15 |
| En revisión | 8: gitlab-org/gitlab 6, client-go 2 |
El propio registro, tal como estaba la mañana del 9 de octubre, tenía 102 entradas repartidas entre ocho proyectos upstream (46 de client-go; 31 de gitlab-org/gitlab, una de ellas compartida con client-go; 17 del SDK de MCP para Go, y el resto de cinco proyectos más pequeños), y 86 de ellas tenían un arreglo en revisión o fusionado, 81 de ellos míos.
Un merge request que queda fuera de las historias anteriores merece una línea: gitlab-org/gitlab!260459 haría que GitLab rechazara un valor de filtro desconocido en los hallazgos de seguridad de un pipeline, y está en revisión. Con una severidad desconocida, GitLab responde 500, y un filtro formado solo por tipos de informe desconocidos no coincide con ningún escaneo, así que una página vacía que significa “has escrito mal el filtro” se lee como “no hay vulnerabilidades”. El registro recoge el resto.
Cierre
Estas comprobaciones se construyeron para que un solo servidor hiciera lo que dice, y la mayor parte de lo que cazaron no le tocaba arreglarlo al servidor. Apuntar cada hallazgo con su evidencia y su workaround es lo que hizo barato enviarlo upstream, y la rapidez de la respuesta fue mérito de GitLab. Gracias a los revisores y redactores técnicos de GitLab, que se tomaron en serio cada uno de esos hallazgos.
La instantánea del GitLab arrancado recoge también lo que cada ruta REST y cada elemento GraphQL exigen de un token de acceso personal de grano fino, y ocho de los 23 merge requests contados arriba salieron de ese trabajo. Tokens de acceso personal de grano fino de GitLab, acción por acción es el siguiente artículo: cubre ese trabajo y termina con cómo crear un token para el servidor.
La documentación del servidor está en jmrp.io/docs/gitlab-mcp-server/es y sus contribuciones upstream de gitlab-mcp-server aparecen en la página Sobre este proyecto; el registro está en GitHub.
Preguntas frecuentes
¿Por qué la API REST de GitLab responde 401 Unauthorized a un token que funciona?
Algunos endpoints de GitLab (fusionar, aprobar, mirrors, lecturas de tokens de acceso) rechazan con un 401 a un usuario autenticado al que le falta un permiso, con el mismo cuerpo que para un token inexistente. Para distinguirlo, llama a GET /api/v4/user con el mismo token: un 200 o un 403 significan token válido (el 403, uno que no puede leer tu cuenta), y la petición se rechazó por otro motivo. GitLab lo documenta en la página de resolución de problemas de la API REST desde gitlab-org/gitlab!260774 (9 de octubre de 2026); ningún código cambió, porque su guía de estilo de la API considera incompatible cambiar uno.
¿Puedo generar un cliente de GitLab a partir de su documento OpenAPI?
Solo con cuidado. GitLab genera el documento a partir del bloque desc de cada ruta de Grape, que Grape usa para la documentación y nunca contrasta con lo que devuelve el handler. Donde los dos difieren, el documento se equivoca: la lista de commits de contexto de un merge request se describía con diez claves menos de las que envía hasta gitlab-org/gitlab!260487, y los endpoints de aprobaciones se siguen describiendo con 4 claves mientras que todos los builds de Enterprise Edition envían 24 (gitlab-org/gitlab!261004, en revisión el 9 de octubre de 2026, hace que la entidad y el documento digan 24). Contrasta lo que generes con una instancia en marcha.
¿Cómo se contrasta el código de un cliente con un GitLab real sin llamar a GitLab desde los tests unitarios?
Toma una instantánea de la instancia una sola vez y contrasta con ella offline. gitlab-mcp-server arranca una imagen publicada de gitlab-ee, pregunta a la aplicación cargada por cada entidad, campo, condición y ruta, y guarda la respuesta en el repositorio; además fija el esquema GraphQL que sirve GitLab.com y valida contra él cada petición GraphQL que hacen los tests unitarios de sus herramientas. Los tests unitarios siguen siendo offline, y una ejecución end-to-end en Docker contra una instancia CE o EE real cubre lo que una instantánea no puede.
¿Por qué leer insufficient_scope del cuerpo de un 403 de GitLab en lugar de la cabecera WWW-Authenticate?
Porque GitLab no envía la cabecera. Su 403 para un token al que le falta un scope lleva insufficient_scope y la lista de scopes solo en el cuerpo JSON, aunque la RFC 6750 pide un desafío WWW-Authenticate; el propio código fuente de GitLab tiene un FIXME que lo reconoce. Un código que leyera la cabecera pasaría los tests con un fake escrito a mano y nunca saltaría contra un GitLab real. gitlab-org/gitlab!260668, en revisión el 9 de octubre de 2026, añade el desafío.
¿Por qué client-go no consigue listar los commits de GitLab que llevan trailers?
GitLab envía los extended_trailers de un commit como un mapa que asocia cada trailer con la lista de sus valores, y client-go declara el campo como un mapa de cadenas, como sigue haciendo en v3.17.0, su release más reciente a 9 de octubre de 2026. En cuanto un commit de una página lleva un trailer, falla la decodificación de toda la página de ListCommits. gitlab-org/api/client-go!3082, en revisión el 9 de octubre de 2026, añade un campo del tipo correcto y depreca el antiguo.
¿Qué pasa con un workaround cuando GitLab arregla el bug?
Cada hallazgo de auditoría que es de GitLab y no del servidor se responde con una declaración con categoría y motivo, y una que ya no coincide con nada hace fallar el build. Al volver a tomar la instantánea de una release de GitLab con el arreglo, las declaraciones del defecto antiguo no coinciden con nada, el build falla y se borran. Un workaround que ninguna auditoría ve, como una pista ante un código de estado engañoso, no hace fallar nada y se revisa a mano. Un arreglo de client-go retira su workaround, como un campo leído de la respuesta capturada, cuando el servidor adopta la release que lo incluye.
Lecturas y recursos adicionales
- gitlab-mcp-server GitHub
- gitlab-org/api/client-go#2269 gitlab.com
- gitlab-org/api/client-go#2300 gitlab.com
- gitlab-api-live.json GitHub
- request-inventory.json GitHub
- gitlab-org/api/client-go!3063 gitlab.com
- entrada de este 401 en el registro GitHub
- gitlab-org/gitlab#624056 gitlab.com
- gitlab-org/gitlab#383531 gitlab.com
- gitlab-org/gitlab!260774 gitlab.com
- resolución de problemas de la API REST docs.gitlab.com
- gitlab-org/gitlab!260458 gitlab.com
- gitlab-org/gitlab!260486 gitlab.com
- gitlab-org/gitlab!260487 gitlab.com
- página de la API de commits de contexto docs.gitlab.com
- gitlab-org/gitlab!254698 gitlab.com
- gitlab-org/gitlab!254699 gitlab.com
- gitlab-org/gitlab!259766 gitlab.com
- gitlab-org/gitlab!261004 gitlab.com
- sección 3 de la RFC 6750 RFC Editor
- gitlab-org/gitlab!260668 gitlab.com
- gitlab-org/api/client-go!3082 gitlab.com
- gitlab-org/api/client-go!3085 gitlab.com
- ADR-0021 GitHub
- registro upstream de gitlab-mcp-server GitHub
- gitlab-org/gitlab!260459 gitlab.com
- Tokens de acceso personal de grano fino de GitLab, acción por acción jmrp.io
- jmrp.io/docs/gitlab-mcp-server/es jmrp.io
- contribuciones upstream de gitlab-mcp-server jmrp.io