Ruby MCP Client

Un cliente Ruby para el Protocolo de Contexto de Modelo (MCP), que permite la integración con herramientas y servicios externos a través de un protocolo estandarizado.

Documentación

ruby-mcp-client

Un cliente Ruby para el Protocolo de Contexto de Modelos (MCP), que permite la integración con herramientas y servicios externos mediante un protocolo estandarizado.

Instalación

# Gemfile
gem 'ruby-mcp-client'
bundle install
# or
gem install ruby-mcp-client

Descripción general

MCP permite a los asistentes de IA descubrir e invocar herramientas externas a través de diferentes mecanismos de transporte:

  • stdio - Procesos locales que implementan el protocolo MCP
  • SSE (obsoleto) - Eventos enviados por el servidor con soporte de transmisión; el transporte HTTP+SSE está obsoleto y las nuevas integraciones deberían usar Streamable HTTP en su lugar (consulte Funciones obsoletas)
  • HTTP - Solicitud/respuesta simple (sin transmisión)
  • Streamable HTTP - HTTP POST con respuestas con formato SSE

Conversiones de API integradas: to_openai_tools(), to_anthropic_tools(), to_google_tools()

Soporte del protocolo MCP

Implementa la especificación MCP 2026-07-28 y mantiene compatibilidad con cada revisión anterior (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05). El cliente es de doble era: sondea cada servidor con server/discover y habla el protocolo 2026-07-28 sin estado (por solicitud _meta, sin initialize, sin sesiones) con los servidores que lo responden, y ejecuta el protocolo de enlace clásico initialize con todos los demás — desconectándose si un servidor responde con una revisión que no puede hablar. Consulte Funciones de MCP 2026-07-28 para las nuevas capacidades y Funciones obsoletas para lo que la revisión retira.

  • Herramientas: listar, llamar, transmitir, anotaciones (estilo de sugerencia), salidas estructuradas validadas contra outputSchema (JSON Schema 2020-12 / 2019-09 / draft-07), título, parámetros x-mcp-header
  • Prompts: listar, obtener con parámetros
  • Recursos: listar, leer, plantillas, suscripciones, paginación, contenido ResourceLink
  • Elicitación: Interacciones de usuario iniciadas por el servidor (stdio, Streamable HTTP y el transporte HTTP+SSE obsoleto) y resultados input_required de múltiples idas y vueltas
  • Raíces (obsoleto en 2026-07-28): Límites de alcance del sistema de archivos con notificaciones de cambios
  • Muestreo (obsoleto en 2026-07-28): Completaciones de LLM solicitadas por el servidor con modelPreferences
  • Finalización: Autocompletar para prompts/recursos con contexto
  • Registro (obsoleto en 2026-07-28): Mensajes de registro del servidor con filtrado por nivel
  • Tareas: tools/call aumentado con tareas — declare la extensión io.modelcontextprotocol/tasks, consulte tasks/get, responda inputRequests con tasks/update, y tome el resultado de tasks/get; la superficie 2025-11-25 permanece como legado
  • Suscripciones: Flujos de notificación subscriptions/listen (2026-07-28)
  • Almacenamiento en caché: Sugerencias de frescura ttlMs / cacheScope en listas, lecturas y descubrimiento (2026-07-28)
  • Audio: Soporte de tipo de contenido de audio
  • Progreso y cancelación: Canalización progressToken con devoluciones de llamada por llamada; notifications/cancelled automático para solicitudes abandonadas (en una sesión moderna de Streamable HTTP, cerrar el flujo de respuesta es en sí mismo la cancelación, por lo que no se envía ninguna notificación)
  • Metadatos: icons, title y _meta analizados en herramientas, prompts y recursos
  • OAuth 2.1: PKCE (S256 requerido), descubrimiento RFC 8414/9728, validación de emisor RFC 9207, Documentos de metadatos de ID de cliente, registro dinámico (obsoleto), desafíos de aumento de alcance

Los transportes tratan al servidor como entrada no confiable — consulte Tratando al servidor como no confiable para los límites aplicados a los datos controlados por el par.

API de conexión rápida (recomendada)

La forma más sencilla de conectarse a un servidor MCP:

require 'mcp_client'

# Auto-detect transport from URL
client = MCPClient.connect('http://localhost:8931/mcp')      # Streamable HTTP
client = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem /home')  # stdio
client = MCPClient.connect('http://localhost:8000/sse')      # SSE (HTTP+SSE: deprecated, use Streamable HTTP)

# With options
client = MCPClient.connect('http://api.example.com/mcp',
  headers: { 'Authorization' => 'Bearer TOKEN' },
  read_timeout: 60,
  retries: 3,
  logger: Logger.new($stdout)
)

# Multiple servers
client = MCPClient.connect(['http://server1/mcp', 'http://server2/mcp'])

# Force specific transport
client = MCPClient.connect('http://custom.com/api', transport: :streamable_http)

# Protocol era and extensions (see "MCP 2026-07-28 Features" below)
client = MCPClient.connect('http://api.example.com/mcp',
  protocol: :modern,                                  # :auto (default), :modern or :legacy
  discover_timeout: 5,                                # bound on the server/discover probe
  extensions: ['io.modelcontextprotocol/tasks']       # extensions the client declares
)

# Use the client
tools = client.list_tools
result = client.call_tool('example_tool', { param: 'value' })
client.cleanup

Encabezados configurados: Los valores headers: se envían en cada solicitud, con un espacio de nombres reservado. En una sesión HTTP moderna (MCP 2026-07-28), el cliente posee Mcp-Param-*: esos encabezados se derivan de los argumentos propios de un tools/call (las anotaciones x-mcp-header de la herramienta), por lo que cualquier encabezado de ese nombre dado en headers: se descarta de las solicitudes modernas en lugar de representar un argumento que la llamada no llevaba. Las sesiones heredadas, donde el espacio de nombres no tiene significado de protocolo, lo envían sin cambios.

Detección de transporte:

Patrón de URLTransporte
Termina con /sseSSE — HTTP+SSE está obsoleto, prefiera Streamable HTTP (Funciones obsoletas)
Termina con /mcpStreamable HTTP
stdio://command o Arraystdio
npx, node, python, etc.stdio
Otras URLs HTTPDetección automática (Streamable HTTP → SSE → HTTP) — el paso SSE es el transporte HTTP+SSE obsoleto, probado solo después de que Streamable HTTP falle (Funciones obsoletas)

Trabajando con herramientas, prompts y recursos

# Tools
tools = client.list_tools
result = client.call_tool('tool_name', { param: 'value' })
result = client.call_tool('tool_name', { param: 'value' }, server: 'server_name')

# Batch tool calls
results = client.call_tools([
  { name: 'tool1', parameters: { key: 'value' } },
  { name: 'tool2', parameters: { key: 'value' }, server: 'specific_server' }
])

# Streaming (SSE/Streamable HTTP)
client.call_tool_streaming('tool', { param: 'value' }).each do |chunk|
  puts chunk
end

# Prompts
prompts = client.list_prompts
result = client.get_prompt('greeting', { name: 'Alice' })

# Pagination: list_tools and list_prompts automatically follow the server's
# nextCursor and return the COMPLETE set across all pages (with a per-call
# safety bound and an identical-cursor loop guard). No manual cursor handling
# is required.

# Resources
result = client.list_resources
contents = client.read_resource('file:///example.txt')
contents.each do |content|
  puts content.text if content.text?
  data = Base64.decode64(content.blob) if content.binary?
end

# Resource update subscriptions (per server)
server = client.servers.first
server.subscribe_resource('file:///example.txt')
server.unsubscribe_resource('file:///example.txt')

subscribe_resource responde true solo una vez que el servidor ha confirmado la suscripción, y genera una excepción en caso contrario — el propio error del servidor, o MCPClient::Errors::ResourceReadError (incluso cuando la confirmación no llega dentro del read_timeout del transporte). En un servidor 2025-11-25, la confirmación es la respuesta resources/subscribe; en uno 2026-07-28, es el reconocimiento subscriptions/listen que nombra ese URI, que la llamada espera. Las actualizaciones luego llegan como notifications/resources/updated a través de on_notification. Cada reconocimiento posterior de ese flujo también se vuelve a verificar: un flujo reabierto después de una conexión HTTP caída o un reinicio de stdio es una nueva solicitud de escucha que el servidor puede reconocer de manera más limitada, por lo que uno que regresa sin el URI cierra la suscripción en lugar de dejar el recurso reportado como observado mientras nada lo observa — incluido uno que llega en la ventana entre el reconocimiento subscribe_resource esperado y el URI que se asigna al flujo, que se verifica una vez más con la asignación en su lugar antes de que la llamada responda. Un flujo ya asignado al URI se reutiliza solo mientras el servidor esté honrando ese URI en él ahora: uno que se ha caído se espera — a través del retroceso de reapertura HTTP o el protocolo de enlace stdio, y más allá de la solicitud de reemplazo hasta que el servidor la responda — en lugar de reportarse como una observación basada en lo que el flujo que se ha ido había recibido, porque no existe ninguna suscripción del lado del servidor entre intentos de escucha y la solicitud que reemplaza a una es una nueva escucha para la que el servidor no tiene estado y puede rechazar o reconocer sin el URI. Solo un flujo que el servidor esté honrando activamente cuenta como una observación en vivo, y uno que no se convierte en uno — su reemplazo es rechazado, o nada responde dentro del tiempo de espera de reconocimiento — se cierra además de desasignarse, por lo que no puede volver y entregar las mismas actualizaciones junto al flujo que lo reemplaza, y unsubscribe_resource nunca se queda buscando una suscripción que la asignación ya no nombra. El suscriptor que espera en su propia solicitud de escucha es un asunto diferente y aún recibe su respuesta: una conexión que se cae en el momento en que llega el reconocimiento no lo deja sin respuesta, por lo que la llamada no espera su tiempo de espera de reconocimiento por una concesión que ya tiene — mientras que una solicitud de reemplazo que realmente se ha enviado no se responde hasta que el servidor responda a ella. On a 2026-07-28 server a host can also open a stream of its own with server.listen(notifications: { tools_list_changed: true }) { |method, params| … } and end it with subscription.close. A listen the server never acknowledges is given up on rather than left pending for ever: ack_timeout: bounds the wait for the acknowledgment (the transport's read timeout by default, false to wait for ever), and one that expires cancels the request and closes the handle with a RequestTimeoutError. It bounds every listen request made for that subscription, not only the first: an HTTP stream re-opened after a drop, and a subscription re-sent to the process that replaced the one it was on, are new requests the server has to acknowledge afresh. The stream itself is not bounded — once acknowledged it runs for as long as the server keeps it. The block runs on the subscription's own dispatcher thread, never on the transport's reader, so a listener may issue requests of its own; the notifications waiting for it are bounded both in number (MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS) and in the bytes they retain (MCPClient::Subscription::MAX_PENDING_NOTIFICATION_BYTES) — a count alone is not a memory bound when the peer chooses how big each payload is. Everything a queued notification retains is charged, its method name as well as its params: a peer tagging {} params with a multi-megabyte method name would otherwise pay two bytes apiece and put a thousand of them behind a slow listener without touching the byte ceiling. A listener that cannot keep up with the server loses repeats, not signals: whichever ceiling the arriving notification would breach, the queue gives up the oldest notification about the same thing as it (same method and same uri/taskId), or failing that the oldest of whichever thing has the most queued, so a stream watching several resources or tasks never loses the only queued update for a quiet one to make room for a busy one. Every MCP notification is a "look again" signal about state the host re-reads for itself, so a later notice of the same thing carries what the dropped one said, while the only notice of another thing carries what nothing else would. Two rules hold this together: every queued notification is charged exactly what it retains, and every eviction gives up an entry whose removal relieves the pressure that caused it — the byte budget considers only the entries charged against it, the count ceiling considers them all — so overflow always makes progress and no signal is spent on pressure that discarding it cannot relieve. A notification larger than the whole byte budget is not charged against it: it is held in a slot of its own, and there is only ever one such slot, so it is neither lost for being large nor able to displace anything else, and what the queue retains stays within the budget plus one peer-sized payload. pending_notifications / pending_notification_bytes / dropped_notifications report how far behind a listener fell. An incoming notification is routed in a fixed order: subscription bookkeeping (an acknowledgment, a server-side teardown) first, then the transport and client cache invalidation, then the delivery to the subscription's listeners, and the host's on_notification listeners last. Caches are therefore dropped before a notification reaches the listeners, so a listener reacting to a list_changed notification by calling list_tools (or the prompt or resource equivalents) always re-fetches rather than reading the entry the notification just invalidated. That holds for the client's caches as well as the transport's: the client drops its tool_cache / prompt_cache / resource_cache on the transport's on_cache_invalidation hook, which runs at the invalidation step, rather than on the host callback that runs after the delivery. A custom transport that emits no such hook — one written against the older interface, which only calls the notification callback — still has those caches dropped, on the callback and ahead of everything else there. Everything else the client does with a notification (logging, progress callbacks, task status) stays behind the delivery, because it is host code or leads to it. Transports that carry no subscription stream announce the same hook before their notification callback — the legacy SSE parser, and the synthetic tools/list_changed a Mcp-Param-* header-mismatch refresh emits — so nothing that invalidates a cache is announced on only one of the two. The host callback comes last because it is the only step that can block: it is host code and it runs on whatever thread is routing — on stdio the server process's sole reader — so a callback that issues a synchronous request of its own would otherwise hold up the delivery while waiting for a response only that reader can deliver. Queueing the delivery is all the routing thread does (the listeners themselves run on the subscription's dispatcher thread), so nothing host-supplied runs ahead of the callback either way. Being last, the callback can prevent nothing: one that raises is logged and stops neither the invalidation nor the delivery, and one that edits the payload it is handed can neither drop nor redirect it — the subscription a notification belongs to is resolved, and the delivery queued, before the callback sees it. The requested filter is copied and frozen when the subscription is created, so a caller that keeps and mutates the array it passed cannot change the request that goes out (Streamable HTTP builds it on the stream's own thread) or what a reconnect asks for. unsupported names the requested fields the acknowledgment did not really grant, read from its values rather than its keys: a resourceSubscriptions echoed with none of the requested URIs, or a flag acknowledged as false, counts as unsupported. acknowledged is a frozen copy of what the server granted, arrays and strings included: the notification it arrives in is handed to on_notification and to the subscription's listeners, and host code editing it in place must not be able to rewrite the subscription's own record of the watch. active? answers false while a dropped stream waits to re-open, and a closing response the client cannot recognize (an unknown resultType, a missing or scalar result) fails the subscription instead of closing it gracefully — as does one that is recognized but says the request has not finished (input_required, which is valid on tools/call, resources/read and prompts/get alone). On Streamable HTTP closing the SSE response stream is the cancellation, including against a connection that is still opening its socket: once close (or the transport's cleanup) returns, either the listen request was never sent — that session refuses to send one for a closed subscription, however long its connect takes — or its response stream has been closed. A listen POST the server answers with a 5xx is treated as a dropped stream and re-opened on the usual backoff, the way a connection failure or a read timeout on the very same request is: a brief 500 or 503 no longer ends a long-lived subscription for good. Only a 4xx, or an authorization challenge, is the server refusing this subscription, and those still end it. On stdio a server process that exits on its own is restarted at once while subscriptions are open, since a host that is only waiting for notifications never makes the request that would otherwise restart it, and the subscriptions are re-sent on the new process. An exit during the initialization that established the process counts too: a replacement that answers the discovery probe and then dies is noticed by its own reader, which waits out the initialization still in flight and then restarts — otherwise nothing noticed, the dead connection was marked initialized, the re-sent listens failed into the "wait for the next process" path, and no reader was left to establish one. If the process cannot be restarted, or if the process they were last re-sent to died less than MCPClient::ServerStdio::SUBSCRIPTION_RESTART_MIN_INTERVAL after receiving them, they end with that error rather than waiting for ever. Only an exit counts against that bound, never a teardown the client asked for: a host that calls cleanup and reconnects — which every cleanup/request cycle does — tears the process down whenever it likes, and reading that as a crash closed the very subscriptions the reconnect exists to carry across. The record of that process answers only that one question, and asking it spends it: a subscription opened directly on the replacement, after a refusal had already closed the ones the corpse carried, is judged on its own process's uptime rather than refused for a crash it had no part in. That interval runs from the moment that process received them, not from the moment a restart was attempted, so a server whose start-up alone takes longer is not credited with its own handshake and respawned for ever; both moments are recorded on the record of the process itself, and the question is asked in the one place the subscriptions are handed over, so the bound holds however a host request and the reader's own restart interleave — whichever of them re-establishes the process. A restarted process that negotiates a pre-2026-07-28 version cannot carry them either, and ends them with a CapabilityError rather than leaving them :reconnecting. A listen write that fails after the process is gone leaves the subscription for the next process to be re-sent to instead of closing it — including when it fails on the hand-over itself, where taking the new listen id has already moved the subscription off :reconnecting and the stream being torn down would have been the very one the restart was re-establishing. One that fails after a newer stream replaced it raises only if that newer stream has itself failed. The subscriptions waiting for a process live on a single queue behind one lock, and a handle is on it at most once: a cleanup moving the open subscriptions onto it overlaps a failed hand-over putting one back, and two threads appending to a bare Array can lose an entry — stranding a stream the spec requires to be re-sent — or duplicate it and send two listen requests for one subscription. close cancels what is actually outstanding: notifications/cancelled names every listen request the client wrote for that subscription on the live process, not only the id it happens to be on, while ids written to a process that has since gone are forgotten rather than cancelled on the one that replaced it. That accounting holds because a listen request is written to the pipe it was recorded against rather than to whichever process is current when the write finally happens: a write still pending when the process exits goes to the process it was opening on (failing into the paths above once its pipe is closed), never to the replacement, which would otherwise be serving a second stream whose id the teardown had already forgotten. On Streamable HTTP the mirror image is refused rather than deferred — a listen whose connection is closed while it is being opened raises ConnectionError instead of POSTing onto a transport the host has closed, which no later cleanup would find.

MCP 2026-07-28 Features

The 2026-07-28 revision makes the protocol stateless: there is no initialize handshake, no session and no server-to-client request channel. Every request carries its own metadata, and anything the server needs from the client is asked for through the result itself. The client detects which era a server speaks and adapts; existing code keeps working unchanged.

Discovery and per-request metadata

client = MCPClient.connect('http://localhost:8931/mcp')
server = client.servers.first
server.modern?            # => true for a 2026-07-28 server
server.protocol_version   # => "2026-07-28"
server.protocol_era       # => :modern or :legacy

Un servidor moderno se reconoce por su respuesta a server/discover; cada solicitud lleva entonces io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo y io.modelcontextprotocol/clientCapabilities en _meta, y en HTTP los encabezados MCP-Protocol-Version, Mcp-Method y Mcp-Name. Los metadatos proporcionados por el host (un Hash o un invocable evaluado por solicitud) se fusionan en cada solicitud con MCPClient::Client.new(request_meta: ...). Fuerce una era con protocol: :modern / protocol: :legacy (por defecto :auto) y ajuste la sonda con discover_timeout: en stdio_config, http_config y streamable_http_config (o los constructores de servidor correspondientes), y en MCPClient.connect para esos mismos tres transportes. El transporte HTTP+SSE obsoleto no tiene era propia: MCPClient::ServerSSE no acepta ninguna opción, por lo que MCPClient.connect elimina ambas para una URL /sse en lugar de pasarlas (Características obsoletas) — excepto protocol: :modern, que un transport: :sse explícito rechaza con ArgumentError en lugar de conectarse silenciosamente a un legado. ping se asigna a server/discover y log_level= al nivel de registro por solicitud en servidores modernos.

log_level= está obsoleto en MCP 2026-07-28 (SEP-2577). En un servidor moderno escribe _meta["io.modelcontextprotocol/logLevel"], parte de la utilidad de Registro que la revisión marca como Obsoleta en su conjunto: las nuevas implementaciones NO DEBEN adoptarla. Consulte Características obsoletas.

Los errores tipados llevan el código JSON-RPC: MCPClient::Errors::HeaderMismatchError (-32020), MissingRequiredClientCapabilityError (-32021) y UnsupportedProtocolVersionError (-32022).

Solicitudes de múltiples rondas

En un servidor moderno tools/call, resources/read y prompts/get pueden responder a resultType: "input_required". El cliente cumple cada solicitud en inputRequests con los manejadores que ya tiene — el manejador de elicitación, el manejador de muestreo y las raíces configuradas — y reenvía la solicitud original con inputResponses y el requestState opaco del servidor. Más de 10 rondas, una solicitud que el cliente no puede honrar o un inputRequests malformado generan MCPClient::Errors::InputRequiredError (exponiendo input_requests y request_state).

Elicitación en modo URL

Un manejador de elicitación de aridad 2 (o más) se llama con el mensaje y un hash de metadatos cuando el servidor solicita una interacción fuera de banda (url). En un servidor 2025-11-25 ese hash es { 'mode' => 'url', 'url' => ..., 'elicitationId' => ... }; en un servidor 2026-07-28 es { 'mode' => 'url', 'url' => ... } — la revisión eliminó elicitationId junto con notifications/elicitation/complete, porque el resultado se aprende al reintentar la solicitud original en lugar de a partir de una señal iniciada por el servidor, y un servidor que debe correlacionar una elicitación entre reintentos lleva su propio identificador en el requestState opaco. Un servidor moderno que envía elicitationId de todos modos no llega al host con él: el campo se descarta y se registra una advertencia.

Encabezados personalizados desde parámetros de herramientas

Los parámetros de herramientas anotados con x-mcp-header se reflejan en los encabezados de solicitud Mcp-Param-{name} en tools/call (HTTP Streamable y HTTP simple). Un rechazo de -32020 HeaderMismatch activa una tools/list actualización y un reintento; las herramientas con anotaciones inválidas se excluyen de la lista con una advertencia. MCPClient::HeaderParams expone la validación.

Suscripciones (subscriptions/listen)

subscription = client.listen(notifications: { tools_list_changed: true,
                                              resource_subscriptions: ['file:///etc/hosts'] }) do |method, params|
  puts "#{method}: #{params.inspect}"
end
subscription.acknowledged   # what the server agreed to watch
subscription.unsupported    # what it did not
subscription.close

Cada suscripción es una solicitud subscriptions/listen de larga duración; en HTTP Streamable se ejecuta en su propia secuencia y se reabre con retroceso cuando la secuencia se cae, en stdio se reenvía cuando el proceso se reinicia. subscribe_resource / unsubscribe_resource se asignan a secuencias de escucha en servidores modernos.

Resultados almacenables en caché

server/discover, las solicitudes */list y resources/read llevan ttlMs y cacheScope. Las listas se sirven mientras están frescas y se vuelven a buscar al acceder una vez que están obsoletas (una lista obsoleta se sirve con una advertencia cuando la nueva búsqueda falla transitoriamente); los resultados resources/read con un ttlMs se almacenan en caché por URI. Las entradas con cacheScope: "private" están vinculadas a las credenciales con las que la solicitud salió y nunca se comparten entre contextos de autorización. server.cache_info(:tools) / cache_info(:read, uri) exponen ttl_ms, cache_scope, received_at y fresh.

Extensión de tareas (io.modelcontextprotocol/tasks)

client = MCPClient::Client.new(mcp_server_configs: [...],
                               extensions: ['io.modelcontextprotocol/tasks'])

result = client.call_tool('long_running', {})   # polls tasks/get transparently

task = client.call_tool_as_task('long_running', {})
task = client.wait_for_task(task, timeout: 120)  # answers input_required via tasks/update
task.result if task.completed?
client.cancel_task(task)

Las llamadas aumentadas con tareas son opcionales mediante extensions:; un servidor que responde a tools/call con una tarea se consulta en su pollIntervalMs hasta que la tarea es terminal o su ttlMs expira, y las tareas input_required se responden con los manejadores de elicitación / muestreo / raíces. tasks/list y tasks/result no existen en servidores 2026-07-28; la API de tareas heredada sigue funcionando en servidores 2025-11-25.

Autorización

OAuthProvider registra el issuer del servidor de autorización con cada solicitud de autorización y valida el iss de la devolución de llamada según RFC 9207 (complete_authorization_flow(code, state, iss:)); las credenciales del cliente y los tokens están vinculados al emisor que los produjo, por lo que un servidor que cambia de servidores de autorización nunca ve el token de otro servidor. El Registro Dinámico de Clientes lleva application_type y está obsoleto en favor de los Documentos de Metadatos de ID de Cliente (client_id_metadata_url:). Consulte OAUTH.md.

Características obsoletas

El registro de características obsoletas de 2026-07-28 las enumera como Obsoletas bajo la política de ciclo de vida de características: siguen funcionando durante su ventana de obsolescencia, pero las nuevas integraciones no deben adoptarlas. La eliminación más temprana es la del propio registro, y nombra una revisión, no una fecha: lo que las características que 2026-07-28 depreca esperan es la primera revisión publicada en o después de 2027-07-28, que puede llegar mucho después de esa fecha — no planifique en torno a 2027-07-28 como fecha de eliminación. Los valores includeContext siguen a Sampling, y solo el transporte HTTP+SSE tiene un reloj propio. La eliminación más temprana es cuando una característica se vuelve elegible para eliminación; la eliminación real es una decisión del Mantenedor Principal tomada durante la preparación del lanzamiento. El cliente registra un aviso por característica por proceso en el primer uso, nombrando la eliminación más temprana y la migración sugerida:

CaracterísticaObsoleta desdeEliminación más tempranaMigración
Raíces (roots:, Client#roots=, o responder a una solicitud roots/list con una lista no vacía)2026-07-28 (SEP-2577)la primera revisión publicada en o después de 2027-07-28Pase directorios o archivos a través de parámetros de herramientas, URIs de recursos o configuración del servidor
Muestreo (sampling_handler: en Client.new o MCPClient.connect, o servir una solicitud a través del on_sampling_request de un transporte)2026-07-28 (SEP-2577)la primera revisión publicada en o después de 2027-07-28Integre directamente con la API del proveedor de LLM
Registro (log_level= en el cliente o un servidor, un io.modelcontextprotocol/logLevel en request_meta o un _meta por llamada, notifications/message)2026-07-28 (SEP-2577)la primera revisión publicada en o después de 2027-07-28Registre en stderr (stdio) o use OpenTelemetry
Transporte HTTP+SSE (MCPClient::ServerSSE, advertido una vez conectado)2025-03-26 (reclasificado por SEP-2596)tres meses después de que SEP-2596 alcance FinalMigre el servidor a HTTP Streamable
includeContext "thisServer" / "allServers" en solicitudes de muestreo2025-11-25 (reclasificado por SEP-2596)sigue a Sampling (SEP-2577)Los servidores omiten el campo o envían "none"
Registro Dinámico de Clientes OAuth2026-07-28 (PR #2858)la primera revisión publicada en o después de 2027-07-28Documentos de Metadatos de ID de Cliente o credenciales pre-registradas

Un cliente que nunca adoptó Raíces nunca recibe advertencias sobre ellas. Registra un manejador roots/list en cada servidor incondicionalmente, para que un client.roots = [...] posterior se sirva sin reconectar, y hasta que se establezca una raíz responde a roots/list con una lista vacía — una respuesta vacía no expone nada obsoleto, por lo que no genera ningún aviso. El aviso se activa cuando el host configura roots:, llama a Client#roots=, o sirve una respuesta que realmente lleva una raíz (incluyendo desde un transporte manejado directamente, sin un Client).

MCPClient::Deprecations::REGISTRY los enumera, cada uno con su earliest_removal; establezca MCPClient::Deprecations.enabled = false (antes de construir clientes) para silenciar los avisos. Los avisos van al registrador que se le dio al cliente o servidor; sin uno van al registrador $stdout predeterminado como cualquier otra advertencia. Un aviso le cuesta a la operación obsoleta exactamente lo que un logger.warn le cuesta, y nada más: un registrador que genera una excepción, descarta el aviso o escribe en ninguna parte (Logger.new(nil), o uno cuyo dispositivo se ha cerrado) se traga, por lo que la característica sigue funcionando y el aviso permanece debido a un uso posterior — pero un registrador que bloquea, bloquea a su llamador aquí igual que en cualquier otro lugar de la biblioteca. Un fallo está fuera de alcance: un Logger estándar sobre un dispositivo abierto que falla al escribir oculta eso a su llamador (lo reporta en $stderr solamente y regresa como si hubiera escrito), por lo que ese aviso se gasta; un dispositivo cerrado se reconoce, uno roto no. Un registrador que el host envolvió se consulta a través del envoltorio (warn?, y el dispositivo del Delegator que contiene), por lo que un envoltorio etiquetado, de difusión o hecho a mano por encima de WARN deja el aviso debido en lugar de gastarlo en una línea que nadie lee; un envoltorio que filtra por algo que un nivel no puede expresar — una etiqueta, una lista de permitidos de fuente — no puede consultarse, y sí lo gasta. Nada espera jamás un aviso. Un llamador que encuentra uno ya en vuelo — el primer uso de otro hilo, o un formateador, suscriptor de registro, gancho de auditoría o accessor level que alcanza una característica obsoleta desde dentro del aviso que se está escribiendo — se detiene de inmediato en lugar de ponerse en cola detrás de él. Ponerse en cola valdría un punto muerto, ya que el hilo que escribe un aviso mantiene el registrador y el hilo que se pondría en cola puede estar manteniendo un bloqueo que ese registrador necesita (un logger.info ordinario mantiene exactamente tal bloqueo mientras su dispositivo se ejecuta). Y no compraría nada: quienquiera que tenga el aviso lo está escribiendo, y si su registrador falla o filtra la advertencia, el aviso se debe de nuevo, por lo que el siguiente uso de la característica lo genera.

Características de MCP 2025-11-25

Anotaciones de herramientas

tool = client.find_tool('delete_user')

# Hint-style annotations (MCP 2025-11-25)
# Defaults follow the MCP ToolAnnotations schema: when a hint is absent the
# client assumes the less-safe value, so an un-annotated tool is treated as
# writable, potentially destructive, and open-world.
tool.read_only_hint?      # Defaults to false; tool may modify its environment
tool.destructive_hint?    # Defaults to true; tool may perform destructive updates
tool.idempotent_hint?     # Defaults to false; repeated calls may have additional effects
tool.open_world_hint?     # Defaults to true; tool may interact with external entities

# Legacy annotations
tool.read_only?              # Safe to execute?
tool.destructive?            # Warning: destructive operation
tool.requires_confirmation?  # Needs user confirmation

Salidas estructuradas

tool = client.find_tool('get_weather')
tool.structured_output?  # Has output schema?
tool.output_schema       # JSON Schema for output

result = client.call_tool('get_weather', { location: 'SF' })
data = result['structuredContent']  # Type-safe structured data

# Per MCP 2025-11-25, clients SHOULD validate structured results against the
# tool's output schema, and a tool that declares an outputSchema must return
# structuredContent in successful results. call_tool checks both automatically
# (so does call_tool_streaming, for a chunk that is a complete result, and so
# does a task's result), against the JSON Schema vocabulary this client
# evaluates: type, enum/const, properties/required, patternProperties,
# additionalProperties, propertyNames, minProperties/maxProperties,
# dependentRequired/dependentSchemas (draft-07 dependencies),
# items/prefixItems/additionalItems, minItems/maxItems, uniqueItems (JSON
# equality, so an object is never equal to an array), contains with
# minContains/maxContains, string bounds and pattern (an ECMA-262 regular
# expression, translated before it is matched), numeric bounds and multipleOf,
# allOf/anyOf/oneOf/not, if/then/else, $ref/$defs/definitions inside the
# document, and unevaluatedItems/unevaluatedProperties from the annotations
# the whole composition produces (what properties/patternProperties/
# additionalProperties, prefixItems/items/contains and the two keywords
# themselves evaluated, collected through every $ref/allOf/anyOf/oneOf/
# if-then-else/dependentSchemas that passed — never from a cousin). A
# $dynamicRef or $recursiveRef that names no dynamic anchor is the plain
# reference it resolves to and is applied as one; one that does binds to the
# outermost resource of the DYNAMIC SCOPE declaring that anchor — the
# resources the evaluation actually entered, tracked as it enters them, so a
# duplicate anchor in a resource the instance never enters decides nothing.
# What is NOT evaluated is the two keywords that only annotate
# (format, contentSchema). When a schema uses one of those, call_tool logs a
# "validation is partial" warning naming them (in both modes), since data may
# pass this check that a full validator would reject; an unevaluated keyword
# is never read as a match for not/oneOf/if either. A pattern is bounded to
# 10,000 characters and must be an ECMA-262 expression (Ruby-only syntax such
# as inline flags or possessive quantifiers makes the schema unusable). Two
# ECMA-262 constructs Ruby's engine cannot reproduce — a back-reference to a
# group a quantifier repeats (ECMA-262 clears it at each iteration, Ruby
# keeps it) and a variable-length lookbehind — make the schema unusable too,
# rather than being answered under the other engine's rules. By default a
# violation (mismatch, or missing structuredContent on a successful result)
# logs a warning; opt in to strict mode to raise instead:
client = MCPClient::Client.new(
  mcp_server_configs: [...],
  validate_structured_content: :strict # raises MCPClient::Errors::ValidationError on violation
)
# A task-delivered result (get_task_result) is validated the same way when the
# task is named with a Task handle: the handle call_tool_as_task returns carries
# the definition its creating call went out under, and every handle of that task
# keeps it — the one a get_task refresh returns, and the one a wait_for_task
# hands back. A bare task ID identifies no tool, so a result fetched by ID is
# not validated.

Un resultado se verifica contra la definición de herramienta bajo la que salió la solicitud que lo produjo. Eso importa en una sesión HTTP moderna, donde un rechazo HeaderMismatch hace que el cliente actualice tools/list y reintente con encabezados Mcp-Param-* recalculados: el reintento se responde bajo la definición actualizada, por lo que esa es contra la que se valida. Un tools/list_changed que meramente llega mientras la llamada está en vuelo nunca cambia la definición contra la que se verifica el resultado — el servidor nunca vio el reemplazo.

Lo que cuenta como contenido estructurado sigue la revisión a la que se negoció la sesión. MCP 2026-07-28 amplió structuredContent a cualquier valor JSON, por lo que un null presente, matriz, cadena, número o booleano es contenido estructurado y se valida contra el esquema de salida. MCP 2025-11-25 lo tipa como un objeto: en una sesión negociada a esa revisión cualquier otra cosa no es contenido estructurado en absoluto, y una herramienta que declara un outputSchema y envía uno se reporta como si no hubiera devuelto ninguno. Un transporte que no puede decir qué revisión negoció no se asume como legado.

Dialectos y referencias de esquemas JSON

El validador integrado lee JSON Schema 2020-12 (el predeterminado de MCP), 2019-09 y draft-07. Según MCP 2026-07-28, un dialecto no compatible debe reportarse como un error, por lo que una herramienta cuyo inputSchema declara uno que este cliente no implementa se rechaza antes de que se envíe la solicitud:

# inputSchema: {"$schema": "urn:unknown-dialect", ...}
client.call_tool('t', {})
# => raises MCPClient::Errors::ValidationError:
#    "...input schema declares the JSON Schema dialect \"urn:unknown-dialect\":
#     that dialect is not supported (supported: ...)"

Lo mismo aplica a un outputSchema: un dialecto no compatible allí genera un ValidationError en ambos modos, ya que el cliente no puede leer el esquema en absoluto — a diferencia de un desajuste de contenido estructurado, que el modo validate_structured_content decide. La definición verificada es aquella bajo la cual la solicitud respondida realmente se envió, por lo que un reintento de HeaderMismatch bajo un esquema actualizado también queda cubierto — y dado que el rechazo significa que el servidor no ejecutó el intento, un inputSchema actualizado cuyo dialecto este cliente no puede leer detiene el reintento antes de que se envíe, en lugar de después de que la herramienta se haya ejecutado. Un esquema que simplemente no es utilizable por otra razón (un $ref que requeriría una obtención de red, un documento más allá de los límites del recurso, un valor de palabra clave malformado — incluidas las palabras clave de metadatos, que deben escribirse como el tipo JSON Schema las proporciona) se advierte, y para un esquema de entrada la llamada aún se envía, ya que el servidor es dueño de la validación de argumentos.

Dos recursos de esquema no pueden responder a una URI: un documento cuyos $ids (o cuyos nombres de $anchor dentro de un recurso) colisionan no es utilizable, ya que sobre qué declaración aterriza una referencia dependería del orden en que se leyó el documento.

Las referencias se resuelven solo dentro del documento — nunca se obtiene nada — pero un documento que agrupa los recursos que utiliza se resuelve por completo: un $ref que nombra un $id incrustado ("urn:example:s", una URI relativa contra la base que un $id circundante estableció, o la referencia vacía "") se resuelve a ese recurso, y solo una referencia a un recurso que el documento no contiene se reporta como externa. Bajo 2020-12 y 2019-09, definitions se comporta como el $defs del que fue renombrado, ya que el meta-esquema de ambos dialectos lo conserva.

Raíces

Obsoleto en MCP 2026-07-28 (SEP-2577); consulte Características obsoletas.

# Set filesystem scope boundaries
client.roots = [
  { uri: 'file:///home/user/project', name: 'Project' },
  { uri: 'file:///var/log', name: 'Logs' }
]

# Access current roots
client.roots

Muestreo (Completaciones de LLM solicitadas por el servidor)

Obsoleto en MCP 2026-07-28 (SEP-2577); consulte Características obsoletas.

# Configure handler when creating client
client = MCPClient.connect('http://server/mcp',
  sampling_handler: ->(messages, model_prefs, system_prompt, max_tokens) {
    # Process server's LLM request
    {
      'model' => 'gpt-4',
      'stopReason' => 'endTurn',
      'role' => 'assistant',
      'content' => { 'type' => 'text', 'text' => 'Response here' }
    }
  }
)

Los historiales de muestreo se verifican antes de que se ejecute el manejador (MCP 2026-07-28 client/sampling: ambas partes DEBEN validar el contenido del mensaje): cada mensaje necesita un rol y contenido de "user"/"assistant", un mensaje de usuario que contenga resultados de herramientas no contiene nada más, y cada uso de herramienta por parte del asistente es respondido por el mensaje de usuario que lo sigue. Un historial malformado se rechaza con -32602 (o falla el viaje de ida y vuelta múltiple localmente en un servidor 2026-07-28) y el manejador no se invoca.

Una solicitud de viaje de ida y vuelta múltiple puede esperar una interacción que ocurre fuera de banda (una elicitación en modo URL): el servidor sigue respondiendo solo con un requestState, y el cliente reintenta con una pausa creciente. El anfitrión dirige esa espera y puede reanudar una solicitud que detuvo:

client.on_input_required_wait do |wait|
  # wait.rpc_method, wait.round_trip, wait.delay, wait.request_state,
  # wait.result, wait.elapsed
  :cancel if user_pressed_cancel?   # :retry retries now; anything else waits the pace
end

begin
  client.call_tool('checkout', { 'cart' => cart })
rescue MCPClient::Errors::InputRequiredError => e
  # e.resumable? — the continuation (e.request_method, e.request_params,
  # e.request_state) is carried by every error the round trip raises
  client.resume_input_required(e) if e.resumable? && user_pressed_retry?
end

Una espera nunca supera el tiempo de espera bajo el cual se ejecuta la solicitud — el dado a la llamada, o el read_timeout configurado del transporte cuando la llamada no nombró ninguno — y el tiempo que tu propio control pasa decidiendo cuenta en su contra. El error que genera entonces es reanudable de la misma manera.

La llamada a herramientas de muestreo (SEP-1577) es opcional: pasa sampling_supports_tools: true para declarar la capacidad de sampling.tools. El manejador entonces recibe los parámetros completos de la solicitud (incluyendo tools/toolChoice) como un quinto argumento opcional; sin la opción, las solicitudes de muestreo habilitadas para herramientas se rechazan con -32602 como lo requiere la especificación. En un servidor 2026-07-28, donde el muestreo llega como una solicitud de entrada dentro de una respuesta de viaje de ida y vuelta múltiple y inputResponses no tiene canal de error por solicitud, el mismo rechazo falla todo el viaje de ida y vuelta con MCPClient::Errors::InputRequiredError y el manejador nunca se invoca:

client = MCPClient::Client.new(
  mcp_server_configs: [...],
  sampling_supports_tools: true,
  sampling_handler: ->(messages, prefs, system_prompt, max_tokens, params = nil) {
    tools = params && params['tools'] # ToolUseContent may be returned in content
    # ...
  }
)

Seguimiento de progreso

Adjunta una devolución de llamada de progreso por llamada — el cliente genera un progressToken único, lo coloca en la solicitud _meta, y enruta los notifications/progress coincidentes a tu bloque mientras la solicitud está activa (los tokens obsoletos después de la finalización se descartan):

client.call_tool('long_running', args, progress: ->(progress, total, message) {
  puts "#{message}: #{progress}/#{total}"
})

Un _meta a nivel de solicitud (por ejemplo, un progressToken elegido a mano) también se puede pasar dentro de los argumentos bajo la clave '_meta' en cada transporte — se eleva al nivel de parámetros JSON-RPC en el cable, nunca se envía como argumento de herramienta.

Tiempos de espera y cancelación

Los tiempos de espera son configurables por solicitud además del read_timeout por servidor. Una solicitud con tiempo de espera agotado genera MCPClient::Errors::RequestTimeoutError (una subclase de TransportError), nunca se reenvía silenciosamente por la capa de reintentos, y se envía un notifications/cancelled de mejor esfuerzo para la solicitud abandonada (nunca para initialize, y las llamadas aumentadas con tareas usan tasks/cancel en su lugar):

client.send_rpc('tools/call', params: { name: 'slow', arguments: {} }, timeout: 300)
server.rpc_request('tools/list', {}, timeout: 5)

Identidad del cliente e instrucciones del servidor

Los anfitriones pueden presentar su propia información de Implementation (enviada como clientInfo durante initialize; name y version requeridos — title, description, websiteUrl, icons opcionales), y leer la pista de instructions del servidor después de conectarse:

client = MCPClient::Client.new(
  mcp_server_configs: [...],
  client_info: { 'name' => 'my-ide', 'version' => '2.0.0', 'description' => 'An MCP-powered IDE' }
)
client.servers.first.connect
puts client.servers.first.instructions # e.g. "Use the search tool before answering."

Control de capacidades

Las características opcionales del servidor (logging/setLevel, resources/subscribe, completion/complete, tasks/list, tasks/cancel) solo se envían a servidores que negociaron la capacidad correspondiente; de lo contrario, se genera MCPClient::Errors::CapabilityError (el ciclo de vida prohíbe usar capacidades que no se negociaron). Client#log_level= omite servidores que no registran en lugar de fallar. Las capacidades del cliente declaradas se derivan de lo que el anfitrión realmente registró (manejadores, raíces), nunca codificadas — y también controlan el tráfico propio del cliente: notifications/roots/list_changed va solo a una sesión que declaró roots (nunca a un servidor 2026-07-28, que eliminó la notificación, y nunca a HTTP simple en una sesión heredada, que no tiene canal de solicitud de servidor para servir raíces).

Finalización (Autocompletar)

result = client.complete(
  ref: { type: 'ref/prompt', name: 'greeting' },
  argument: { name: 'name', value: 'A' }
)
# => { 'values' => ['Alice', 'Alex'], 'total' => 100, 'hasMore' => true }

Registro

Obsoleto en MCP 2026-07-28 (SEP-2577); consulte Características obsoletas.

# Set log level
client.log_level = 'debug'  # debug/info/notice/warning/error/critical

# Handle log notifications
client.on_notification do |server, method, params|
  if method == 'notifications/message'
    puts "[#{params['level']}] #{params['logger']}: #{params['data']}"
  end
end

Tareas (Herramientas de larga duración aumentadas con tareas)

Un servidor capaz de tareas (uno que anuncia tasks.requests.tools.call) puede ejecutar una herramienta cuyo execution.taskSupport es optional o required como una tarea en segundo plano: la llamada regresa inmediatamente con un identificador de tarea, y el resultado se obtiene más tarde. Pruébalo localmente: python3 examples/echo_server_streamable.py & y luego ./examples/tasks_example.rb ejecuta el ciclo de vida completo contra un servidor de demostración capaz de tareas.

En MCP 2026-07-28, el cliente debe declarar la extensión para que el servidor pueda responder con una tarea en absoluto:

client = MCPClient::Client.new(mcp_server_configs: [...],
                               extensions: ['io.modelcontextprotocol/tasks'])

tool = client.find_tool('long_job')
tool.supports_task?   # execution.taskSupport is optional/required?

# Create the task (returns immediately). The server sets the lifetime it
# grants (ttlMs) and the pace it wants to be polled at (pollIntervalMs).
task = client.call_tool_as_task('long_job', { input: 'data' })

# Drive the whole lifecycle: polls tasks/get at the server's pace, answers
# any inputRequests through your elicitation/sampling handlers with
# tasks/update, and returns the finished task.
finished = client.wait_for_task(task)
result = finished.result               # the CallToolResult the task produced

# Or step it yourself
until task.terminal? || task.input_required?
  sleep((task.poll_interval || 1000) / 1000.0)
  task = client.get_task(task)          # tasks/get, routed to the task's own server
end
result = client.get_task_result(task)   # read from tasks/get on 2026-07-28

client.cancel_task(task)                # tasks/cancel

En un servidor 2026-07-28, tasks/result y tasks/list han desaparecido: el resultado se lee de tasks/get, client.list_tasks genera un error, y un ttl: pasado a call_tool_as_task se ignora (el servidor otorga ttlMs). Contra un servidor 2025-11-25, la superficie anterior aún se aplica — ttl: se envía, get_task_result usa tasks/result, y client.list_tasks pagina tasks/list.

Los ID de tarea son únicos solo dentro del servidor que los emitió, así que pasa el Task devuelto por call_tool_as_task — lleva su propio servidor. Un ID de tarea simple también funciona cuando el cliente tiene un solo servidor; con varios servidores configurados, genera ArgumentError en lugar de adivinar, así que nombra el servidor explícitamente:

client.get_task('task-123', server: 'my-server')

# React to server-pushed status updates
client.on_notification do |server, method, params|
  puts "Task #{params['taskId']} -> #{params['status']}" if method == 'notifications/tasks/status'
end

Las notificaciones anuncian; no impulsan. Un notifications/tasks (2026-07-28, en un flujo de listen) que lleva inputRequests llega a los oyentes exactamente como llegó — el cliente responde a las solicitudes de entrada solo dentro de wait_for_task (y call_tool), o cuando el anfitrión envía las respuestas él mismo con update_task. Un anfitrión que sigue una tarea a través de notificaciones la entrega a wait_for_task cuando quiere que se respondan las solicitudes.

Los identificadores sobreviven a un proceso solo en la medida en que el anfitrión los conserve: task.to_h serializa un identificador, y MCPClient::Task.from_json(hash, server: server) (o el taskId simple con server:) nombra la misma tarea en otro cliente, que puede get_task, wait_for_task o cancel_task. Cada identificador que una creación, una actualización de get_task, un wait_for_task o una cancelación devuelve dentro de un cliente también nombra la vida útil de su tarea: una vez que el servidor ha entregado el mismo ID a una nueva tarea, ese identificador genera TaskReplacedError en lugar de alcanzar el reemplazo.

Elicitación (Interacciones de usuario iniciadas por el servidor)

client = MCPClient::Client.new(
  mcp_server_configs: [MCPClient.stdio_config(command: 'python server.py')],
  elicitation_handler: ->(message, schema) {
    puts "Server asks: #{message}"
    # Return: { 'action' => 'accept', 'content' => { 'field' => 'value' } }
    # Or: { 'action' => 'decline' } or { 'action' => 'cancel' }
  }
)

En modo formulario, el manejador puede devolver el contenido por sí mismo ({ 'campo' => 'valor' }), which is sent as an aceptar. An explicit acción debe ser uno de accept, decline o cancel — cualquier otro valor se responde cancel, ya que no es consentimiento que el usuario haya dado.

En modo URL, el segundo argumento del manejador es { 'mode' => 'url', 'url' => ... } en un servidor 2026-07-28 y { 'mode' => 'url', 'url' => ..., 'elicitationId' => ... } en uno 2025-11-25: la revisión más nueva eliminó elicitationId, por lo que un anfitrión no debe esperar esa clave de un servidor moderno (consulte Elicitación en modo URL). Su respuesta es consentimiento, no datos: solo un action explícito de accept, decline o cancel (o un true literal para aceptar) cuenta — cualquier otra cosa se responde cancel. content se descarta, ya que es solo de modo formulario, mientras que un _meta proporcionado por el manejador se pasa.

Configuración avanzada

Para más control, usa create_client con configuraciones explícitas:

client = MCPClient.create_client(
  mcp_server_configs: [
    MCPClient.stdio_config(command: 'npx server', name: 'local'),
    # HTTP+SSE is deprecated (SEP-2596): keep this only for an existing SSE
    # server, and prefer Streamable HTTP (streamable_http_config below) for
    # anything new. See "Deprecated features".
    MCPClient.sse_config(
      base_url: 'https://api.example.com/sse',
      headers: { 'Authorization' => 'Bearer TOKEN' },
      read_timeout: 30, ping: 10, retries: 3
    ),
    MCPClient.http_config(
      base_url: 'https://api.example.com',
      endpoint: '/rpc',
      headers: { 'Authorization' => 'Bearer TOKEN' }
    ),
    MCPClient.streamable_http_config(
      base_url: 'https://api.example.com/mcp',
      read_timeout: 60, retries: 3
    )
  ],
  logger: Logger.new($stdout)
)

# Or load from JSON file
client = MCPClient.create_client(server_definition_file: 'servers.json')

Reintentos

La opción retries: controla el reintento automático con retroceso exponencial. Solo los fallos donde la solicitud probablemente no se completó en el servidor se reintentan: errores de transporte/red y respuestas HTTP 5xx. Los fallos a nivel de aplicación — una respuesta de error JSON-RPC o un HTTP 4xx — nunca se reintentan, porque el servidor ya procesó o rechazó la solicitud. Los fallos de servidor reintentables generan MCPClient::Errors::TransientServerError, una subclase de MCPClient::Errors::ServerError, por lo que los manejadores rescue ServerError existentes no se ven afectados.

tools/call nunca se reintenta automáticamente. Incluso un fallo "transitorio" puede llegar después de que el servidor ejecutó la solicitud, y JSON-RPC no tiene una clave de idempotencia que haga seguro una reproducción — por lo que un reintento podría ejecutar un efecto secundario dos veces. Reintenta una llamada de herramienta explícitamente si tu aplicación sabe que es seguro repetirla, y trata el error generado como resultado desconocido en lugar de no ejecutado:

begin
  client.call_tool('send_invoice', { customer: 'acme' })
rescue MCPClient::Errors::TransportError => e
  # The server may or may not have sent the invoice. Check before retrying.
end

El mismo razonamiento excluye RequestTimeoutError y ResponseTooLargeError de los reintentos, y se aplica a la recuperación de sesión: si un tools/call regresa con un 404 de sesión expirada, el cliente inicia una sesión nueva pero no reenvía la llamada — genera un error para que tú decidas. Las solicitudes idempotentes se reenvían contra la nueva sesión como antes.

Una excepción: un flujo de respuesta roto en una conexión HTTP Streamable moderna (MCP 2026-07-28). Esa revisión eliminó la reanudación SSE y requiere que un flujo de respuesta roto pierda la solicitud en vuelo, que los clientes DEBEN reemitir como una nueva solicitud con un nuevo ID de solicitud — sin excepción para tools/call. Puede requerirlo de manera segura porque cerrar el flujo de respuesta es la señal de cancelación: el servidor DEBE tratar la interrupción como una cancelación de esa solicitud, detener el trabajo tan pronto como sea práctico y no enviar nada más para ella. Entonces, cuando un flujo de respuesta moderno termina sin el resultado, el cliente envía la llamada de nuevo una vez con un ID de solicitud nuevo; si ese flujo también se rompe, genera MCPClient::Errors::ResponseStreamClosedError en lugar de intentar de nuevo. Cada otro fallo ambiguo no cambia y nunca se reenvía, porque en ninguno de esos casos se le dijo al servidor que se detuviera. Una respuesta que sí llegó no es ambigua, por lo que nunca se reemplaza: el cliente lee los cuerpos de respuesta a medida que llegan, y un socket que muere después del evento SSE final devuelve el resultado que ya contenía en lugar de llamar a la herramienta una segunda vez.

Límites de tamaño de respuesta (HTTP transmisible)

Una respuesta codificada con gzip se descomprime incrementalmente y se abandona una vez que se expande más allá de max_decompressed_body_bytes (por defecto 64 MiB), por lo que un cuerpo pequeño altamente comprimido no puede agotar la memoria. Excederlo genera MCPClient::Errors::ResponseTooLargeError.

Aumente el límite si intercambia legítimamente cargas útiles muy grandes — blobs de recursos en base64 o audio — para que si una respuesta se acepta no dependa de la elección del servidor de comprimirla:

MCPClient.streamable_http_config(
  base_url: 'https://api.example.com/mcp',
  max_decompressed_body_bytes: 256 * 1024 * 1024
)

Personalización de Faraday

MCPClient.http_config(base_url: 'https://internal.company.com') do |faraday|
  faraday.ssl.cert_store = custom_cert_store
  faraday.ssl.verify = true
end

El middleware de respuesta que agregue aquí se respeta en ambas rutas: si un middleware conn.response :json (con o sin conn.response :raise_error) ya ha decodificado el cuerpo, un resultado exitoso se lee del objeto decodificado en lugar de analizarse una segunda vez, y el error JSON-RPC que lleva un HTTP 4xx aún se reconoce, por lo que un rechazo de protocolo conserva su code, data y clase de error tipada en lugar de degradarse a un ServerError simple.

Definición JSON del servidor

{
  "mcpServers": {
    "filesystem": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home"]
    },
    "api": {
      "type": "streamable_http",
      "url": "https://api.example.com/mcp",
      "headers": { "Authorization": "Bearer TOKEN" }
    }
  }
}

Ejemplos de integración con IA

OpenAI

require 'mcp_client'
require 'openai'

mcp = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem .')
tools = mcp.to_openai_tools

client = OpenAI::Client.new(api_key: ENV['OPENAI_API_KEY'])
response = client.chat.completions.create(
  model: 'gpt-4',
  messages: [{ role: 'user', content: 'List files' }],
  tools: tools
)

Anthropic

require 'mcp_client'
require 'anthropic'

mcp = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem .')
tools = mcp.to_anthropic_tools

client = Anthropic::Client.new(access_token: ENV['ANTHROPIC_API_KEY'])
# Use tools with Claude API

RubyLLM

require 'mcp_client'
require 'ruby_llm'

RubyLLM.configure { |c| c.openai_api_key = ENV['OPENAI_API_KEY'] }
mcp = MCPClient.connect('http://localhost:8931/mcp')  # Playwright MCP

# Wrap each MCP tool as a RubyLLM tool
tools = mcp.list_tools.map do |t|
  tool_name = t.name
  Class.new(RubyLLM::Tool) do
    description t.description
    params t.schema
    define_method(:name) { tool_name }
    define_method(:execute) { |**args| mcp.call_tool(tool_name, args) }
  end.new
end

chat = RubyLLM.chat(model: 'gpt-4o-mini')
tools.each { |tool| chat.with_tool(tool) }
response = chat.ask('Navigate to google.com and tell me the page title')

Consulte examples/ para implementaciones completas:

  • ruby_openai_mcp.rb, openai_ruby_mcp.rb - integración con OpenAI
  • ruby_anthropic_mcp.rb - integración con Anthropic
  • gemini_ai_mcp.rb - integración con Google Vertex AI
  • ruby_llm_mcp.rb - integración con RubyLLM (proveedor OpenAI)

Ejecución de los ejemplos

El arnés examples/run_all_examples.sh ejecuta cada ejemplo que puede ejecutarse en la máquina actual — servidores stdio autocontenidos, los servidores de eco y elicitación Python/Flask/FastMCP, servidores MCP basados en npx, y (opcionalmente) las integraciones de LLM de pago. Inicia y derriba cada servidor automáticamente e imprime un resumen PASS/FAIL/SKIP. tasks_example.rb se ejecuta contra el echo_server_streamable.py local; oauth_browser_auth.rb es interactivo y solo se ejecuta cuando se opta por participar con RUN_OAUTH=1.

Requisitos previos

Ejecute bundle install primero. El script verifica previamente lo siguiente e imprime una advertencia (no aborta) por cualquier elemento faltante; los ejemplos afectados se omiten o fallan:

  • ruby, bundle, curl, lsof - en PATH
  • python3 (o $PYTHON) más un binario python separado - en PATH
  • Paquetes de Python flask, fastmcp, mcp - importables por $PYTHON
  • npx (Node) - necesario para el ejemplo basado en npx (json_input) y para cada ejemplo de LLM, que generan servidores npx filesystem/Playwright

Uso

examples/run_all_examples.sh                       # run everything runnable on this machine
RUN_AI=0 examples/run_all_examples.sh              # skip the paid-LLM examples
RUN_NPX=0 examples/run_all_examples.sh             # skip the npx-based example (json_input)
LOG_DIR=/path examples/run_all_examples.sh         # write logs to a chosen dir
PYTHON=python3.12 TIMEOUT=180 examples/run_all_examples.sh  # override interpreter and per-example timeout

Interruptores de entorno

VariablePredeterminadoEfecto
RUN_AI1Establézcalo en 0 para omitir las integraciones de LLM, que realizan llamadas reales y de pago a la API.
RUN_NPX1Establézcalo en 0 (o deje npx desactivado en PATH) para omitir el ejemplo basado en npx (json_input). Los ejemplos de LLM también generan servidores npx, pero están controlados por RUN_AI y sus claves de API.
PYTHONpython3Intérprete utilizado para lanzar los servidores Python/Flask/FastMCP y ejecutar las comprobaciones previas de importación.
TIMEOUT120Tiempo de espera de reloj de pared por ejemplo en segundos; un tiempo de espera se informa como un FAIL.
LOG_DIRdirectorio mktemp nuevoDirectorio para registros por ejemplo y por servidor; la ruta se imprime después de la comprobación previa y en el resumen.

Secretos y claves de API

Los secretos reales viven en examples/secrets.env, que está en gitignore y se obtiene automáticamente (cada línea KEY=value se exporta) cuando está presente. Copie la plantilla rastreada para comenzar:

cp examples/secrets.env.example examples/secrets.env
# then set ZAPIER_MCP_TOKEN=... to enable the Zapier streamable-HTTP example

Establezca ZAPIER_MCP_TOKEN (desde la página de configuración de Zapier MCP, "Opción 1: encabezado de autorización") para ejecutar streamable_http_example.rb y oauth_example.rb contra Zapier; anule ZAPIER_MCP_URL si su URL de conexión difiere. Para ejecutar el oauth_browser_auth.rb interactivo, establezca MCP_SERVER_URL (por ejemplo, un túnel ngrok a su servidor MCP protegido por OAuth) en secrets.env y pase RUN_OAUTH=1. Los ejemplos de LLM cada uno necesita sus propias credenciales en el entorno y se omiten sin ellas:

  • ruby_anthropic_mcp.rb - ANTHROPIC_API_KEY (+ npx)
  • openai_ruby_mcp.rb - OPENAI_API_KEY (+ npx)
  • ruby_openai_mcp.rb, ruby_llm_mcp.rb - OPENAI_API_KEY (+ npx, más un servidor MCP de Playwright en :8931)
  • gemini_ai_mcp.rb - un JSON de cuenta de servicio de Vertex en VERTEX_CREDENTIALS_FILE (predeterminado examples/google-credentials.json, + npx)

Cómo se juzga aprobado/fallido

La mayoría de los ejemplos imprimen sus propias marcas de éxito/fallo pero salen con 0 independientemente, por lo que el arnés combina el código de salida con un escaneo de la salida en lugar de confiar solo en el estado de salida. Un ejemplo FAIL cuando sale con código distinto de cero, agota el tiempo (salida 124), imprime una firma de error grave (un traceback de Ruby/Python, Connection refused, uninitialized constant y similares), imprime una marca ❌, o le falta su marcador de éxito esperado; de lo contrario PASS. (La comprobación ❌ se suprime con IGNORE_XMARK=1 para las demostraciones de elicitación interactivas, donde ❌ puede ser una salida legítima de "rechazado"). El script sale con 0 solo si cero ejemplos fallaron — los SKIP no afectan el estado de salida.

Para tutoriales más profundos por tema, consulte examples/README.md, examples/README_ECHO_SERVER.md, examples/STREAMABLE_HTTP_TESTING.md y examples/elicitation/README.md.

Autenticación OAuth 2.1

require 'mcp_client'
require 'mcp_client/auth/browser_oauth'

oauth = MCPClient::Auth::OAuthProvider.new(
  server_url: 'https://api.example.com/mcp',
  redirect_uri: 'http://localhost:8080/callback',
  scope: 'mcp:read mcp:write'
)

browser_oauth = MCPClient::Auth::BrowserOAuth.new(oauth)
token = browser_oauth.authenticate  # Opens browser, handles callback

client = MCPClient::Client.new(
  mcp_server_configs: [{
    type: 'streamable_http',
    base_url: 'https://api.example.com/mcp',
    oauth_provider: oauth
  }]
)

Características: PKCE, descubrimiento de servidor (.well-known), validación de emisor RFC 9207, Documentos de metadatos de ID de cliente, registro dinámico (respaldo obsoleto), actualización de tokens.

Consulte OAUTH.md para documentación completa.

Extras de OAuth (2025-11-25)

  • Documentos de metadatos de ID de cliente (SEP-991) — pase client_id_metadata_url: 'https://myapp.example/oauth-client.json' (una URL HTTPS con una ruta, que también sirve como client_id); cuando el servidor de autorización anuncia client_id_metadata_document_supported, el registro dinámico de clientes se omite por completo.
  • Desafíos de alcance (SEP-835) — un desafío insufficient_scope de HTTP 403 genera MCPClient::Errors::InsufficientScopeError (una subclase de ConnectionError) que expone #scope y #error_description; los alcances desafiados se tratan como autoritativos para el siguiente flujo de autorización.
  • PKCE — la autorización se niega a continuar cuando el servidor de autorización no anuncia code_challenge_methods_supported incluyendo S256.

Vinculación del servidor de autorización (2026-07-28)

  • El estado de registro es por servidor de autorización (SEP-2352) — las credenciales y los tokens se almacenan bajo la URL del servidor MCP (el registro en uso) y bajo provider.client_registration_key(issuer), de modo que dos servidores de autorización detrás de un mismo servidor MCP mantienen cada uno su propio estado de registro en lugar de reemplazarse entre sí. Siembra credenciales pre-registradas para un servidor de autorización específico con storage.set_client_info(provider.client_registration_key(issuer), creds).
  • Las credenciales pre-registradas deben nombrar a su servidor de autorización — un client_id (y cualquier secreto asociado) es emitido por un servidor de autorización, por lo que las credenciales que no nombran a ninguno no quedan vinculadas al servidor que el descubrimiento encuentre por casualidad: eso enviaría el secreto registrado con un servidor a otro. Almacénalas con issuer: en el ClientInfo, o bajo client_registration_key(issuer); sin ello, la autorización lanza un ConnectionError que lo indica y no se envía nada a ningún sitio. Un ID de Documento de Metadatos de Client ID es portátil y no necesita emisor.
  • Las credenciales pre-registradas tienen prioridad sobre las que el cliente registró por sí mismo — cuando un servidor de autorización tiene credenciales propias bajo client_registration_key(issuer), se usan antes que un ID de Documento de Metadatos de Client ID (que responde por cada servidor) y antes que un registro dinámico en el espacio, según exige el orden de prioridad de registro de MCP — y un registro dinámico nunca las sobrescribe.
  • Se conserva un token para cada servidor de autorización — cuando el servidor de autorización cambia, el token del anterior se aparta bajo su propia clave en lugar de descartarse, y se retoma si ese servidor vuelve a ser el en uso. Un token que este cliente retiró (un desafío 401 que nombra a otro servidor de autorización) se elimina dondequiera que esté guardado, y ningún token se presenta jamás a un servidor de autorización distinto del que lo emitió.
  • Un registro que un flujo necesita debe llegar al almacenamiento — un backend que no pueda persistir las credenciales en uso lanza una excepción en lugar de devolver una URL de autorización cuyo callback luego informaría "Falta PKCE o información del cliente". La copia por servidor de autorización sigue siendo de mejor esfuerzo.
  • Una renovación presenta las credenciales en el espacio que un host escribe — un secreto rotado bajo la URL del servidor MCP se usa, no la copia más antigua guardada bajo la clave propia del servidor de autorización. La autorización y la renovación eligen el mismo registro.
  • Una renovación y un intercambio de código se vuelven a comprobar cuando llega la respuesta — un token de un servidor de autorización que dejó de ser el de este recurso mientras la solicitud estaba en vuelo se descarta en lugar de almacenarse sobre el token del servidor actual o presentarse, y un intercambio de código que llega tarde ya no elimina la solicitud de autorización pendiente que otro flujo inició mientras tanto.
  • Un registro por solicitud — el state, el verificador PKCE, el emisor esperado, el client id y la URI de redirección de una solicitud de autorización se escriben juntos, y el state del callback se comprueba contra el registro que leen las demás comprobaciones. Dos flujos que comparten un backend de almacenamiento ya no pueden intercalar sus escrituras hasta que el estado de un flujo nombre la solicitud del otro flujo.
  • Los ámbitos se acumulan entre pasos de autenticación — reautorizar tras un desafío insufficient_scope solicita la unión de los ámbitos ya solicitados y los que nombra el desafío, de modo que obtener files:write no renuncia a files:read. "Ya solicitado" sobrevive a un reinicio: cubre el ámbito configurado y el ámbito que el servidor de autorización concedió al token en mano, no solo lo que este objeto proveedor pidió por última vez. Está limitado a un servidor de autorización, por lo que los ámbitos de otro servidor nunca se piden al nuevo.
  • Un parámetro de solicitud de autorización aparece una vez — la cadena de consulta propia del endpoint de autorización se conserva (RFC 6749 §3.1), pero un endpoint de https://as.example/authorize?scope=openid no añade un segundo scope a la solicitud; lo mismo para state, client_id y los parámetros PKCE. Todo lo demás que el endpoint lleva (tenant, brand, una configuración regional) se conserva.
  • Solo se presenta un tipo de token que el cliente entienda — token_type es OBLIGATORIO (RFC 6749 §5.1) y debe ser Bearer (§7.1). Un token DPoP o mac se rechaza donde se emite y donde se lee de vuelta, y también se rechaza una respuesta que no nombre ningún tipo: §5.1 no define un valor predeterminado, y §7.1 prohíbe usar un token cuyo tipo el cliente no entienda.
  • Cada URI de redirección es localhost o HTTPS — MCP 2026-07-28 "Seguridad de la comunicación". http://app.example.com/callback se rechaza cuando se configura y cuando una respuesta de registro la registra; HTTP plano en la interfaz de bucle local y los esquemas de uso privado RFC 8252 (com.example.app:/cb) no se ven afectados.
  • Un parámetro de callback puede aparecer una vez — RFC 6749 §3.1: BrowserOAuth rechaza un callback que repita iss, state, code o cualquier otro parámetro en lugar de tomar silenciosamente el último valor.

Resultados Cacheables (MCP 2026-07-28)

Un servidor 2026-07-28 puede vincular un resultado con ttlMs (cuánto tiempo el cliente PUEDE considerarlo fresco, contado desde la recepción) y cacheScope ("public" o "private"). Los transportes honran ambos para tools/list, prompts/list, resources/list, resources/templates/list, server/discover y resources/read; cache_info(:tools) — o cache_info(:read, uri) — informa lo que se registró. Un ttlMs ausente significa 0 en un servidor 2026-07-28, un cacheScope faltante o no reconocido se trata como "private", y un resultado resources/read se conserva solo cuando el servidor le dio un ttlMs positivo.

Un resultado cacheado se sirve solo a una solicitud que llevaría las mismas cosas que la solicitud que lo produjo:

  • la misma autorización. Una entrada "private" está vinculada al Authorization con el que su propia solicitud realmente salió — lo que el adaptador envió, no lo que la fase de respuesta dejó después en el entorno de solicitud — por lo que un token rotado, uno revocado o una solicitud anónima nunca lo leen.
  • los mismos parámetros efectivos. El _meta que lleva una solicitud (tu request_meta y su baggage, la identidad del cliente y las capacidades) es parte de lo que un resultado está vinculado, sea cual sea su alcance: "public" permite compartir entre llamantes, no entre parámetros por los que un servidor pueda variar su respuesta. Una solicitud lleva una copia de esos metadatos, tomada cuando se construye, por lo que reescribir una cadena o un contenedor que entregaste a request_meta en su lugar no cambia lo que una solicitud ya enviada significa — establece request_meta al nuevo valor en su lugar, y la próxima solicitud lo llevará.

Cualquier cosa que el transporte no pueda leer de su propia configuración hace que esos sean incognoscibles, y la reutilización se desactiva entonces en lugar de adivinarse. El middleware propio instalado a través de faraday_config es un caso así — cualquier cosa con un hook de solicitud, y cualquier manejador que lleve un callback tuyo: puede establecer un Authorization o reescribir el cuerpo de la solicitud, y ninguna inspección puede decir si lo hace. El middleware del framework que el transporte puede leer (el propio reintento de Faraday, JSON, url-encoded, multipart, logger, follow-redirects y :authorization configurado con valores literales) mantiene el cacheo activo; un :authorization entregado a un proc mantiene las entradas públicas pero no las privadas, ya que la credencial que vende puede diferir de una solicitud a otra.

Dos reglas siguen el protocolo en lugar de la caché:

  • un resources/templates/list sobre el que un servidor no puso ninguna pista se vuelve a buscar en cada llamada, como antes de que los resultados se cachearan en absoluto; solo un ttlMs positivo permite que una lista de plantillas se responda sin una solicitud;
  • un cursor que el servidor rechaza (-32602) termina la secuencia de páginas a la que pertenecía. Las páginas cacheadas para esa lista se descartan, una lista paginada automáticamente se reinicia una vez desde la primera página, y un list_resources(cursor:) explícito o list_resource_templates(cursor:) lanza una excepción — después de que la primera página cacheada bajo el cursor muerto se haya descartado, para que la próxima llamada realmente vuelva a buscar.

Una nueva búsqueda de una lista que falla por una razón transitoria puede servir la copia obsoleta en los transportes HTTP ("Los clientes PUEDEN servir respuestas obsoletas si ocurren errores durante la nueva búsqueda"); los transportes SSE y stdio lanzan una excepción en su lugar, y un fallo de autorización nunca sirve una copia obsoleta, por lo que llega a tu flujo de autenticación.

Un resultado server/discover se juzga por la única regla que usa todo resultado cacheado: una vez que su ttlMs ha transcurrido (una pista ausente, cero, negativa o malformada está obsoleta de inmediato) se vuelve a buscar en el próximo acceso que lo necesite, antes de juzgar una capacidad, en cada transporte.

Notificaciones del Servidor

client.on_notification do |server, method, params|
  case method
  when 'notifications/tools/list_changed'
    client.clear_cache  # Auto-handled
  when 'notifications/message'
    puts "Log: #{params['data']}"
  when 'notifications/roots/list_changed'
    puts "Roots changed"
  end
end

Gestión de Sesiones

En una sesión heredada (un servidor que habla MCP 2025-11-25 o anterior), tanto el transporte HTTP como el HTTP Streamable manejan automáticamente los servidores basados en sesiones:

  • Captura de sesión: Extrae Mcp-Session-Id de la respuesta de inicialización
  • Persistencia de sesión: Incluye el encabezado de sesión en solicitudes posteriores
  • Terminación de sesión: Envía una solicitud DELETE durante la limpieza
  • Reanudabilidad (HTTP Streamable, SEP-1699): rastrea los IDs de eventos SSE y, cuando un flujo de respuesta se interrumpe, se reanuda vía GET con Last-Event-ID para que el servidor pueda reproducir mensajes perdidos — honrando la directiva retry: del servidor

No se requiere configuración: funciona automáticamente.

Una sesión moderna (MCP 2026-07-28) no tiene nada de esto: sin id de sesión, sin DELETE, sin flujo GET y sin reanudación. Un flujo de respuesta que se rompe pierde la solicitud en vuelo, y el cliente la reemite una vez como una nueva solicitud — consulta Tratando al Servidor como No Confiable para saber qué significa eso para tools/call.

Compatibilidad del Servidor

Funciona con cualquier servidor compatible con MCP:

Ejemplo FastMCP

# Start server
python examples/echo_server_streamable.py
# Connect and use
client = MCPClient.connect('http://localhost:8931/mcp')
tools = client.list_tools
result = client.call_tool('echo', { message: 'Hello!' })

Tratando al Servidor como No Confiable

Un servidor MCP conectado controla todo lo que te envía, y los transportes están escritos bajo esa suposición. No necesitas configurar nada de esto — es el comportamiento predeterminado — pero vale la pena saber qué rechazará el cliente:

Entrada controlada por el parQué hace el cliente
Cuerpos de respuesta comprimidos (solo Streamable HTTP — el único transporte que solicita gzip)Descomprimidos incrementalmente, abandonados más allá de max_decompressed_body_bytes (64 MiB por defecto)
Flujos SSELímite de búfer por conexión; los eventos se escanean incrementalmente, por lo que un evento sin terminar cuesta memoria y CPU acotadas
Directivas retry:Respetadas, pero con un mínimo para que retry: 0 no pueda provocar un bucle de reconexión
IDs de eventos SSELongitud acotada, solo ASCII imprimible (se reflejan en Last-Event-ID)
Eventos SSE endpoint heredadosDeben permanecer en el origen de la conexión; las redirecciones fuera del origen se rechazan, por lo que los encabezados de credenciales configurados nunca llegan a otro host
URLs de descubrimiento OAuth de un parDeben ser HTTPS y se rechazan cuando el host es una dirección literal de loopback/privada/link-local. La única excepción es una pila local: una URL de servidor configurada en la interfaz de loopback (localhost, *.localhost, 127.0.0.0/8, ::1) puede enviarse a una URL loopback de HTTP simple — nunca a una link-local o privada. Un desafío rechazado falla de forma segura. Los nombres de host no se resuelven, por lo que un nombre público que apunte a una dirección privada no se detecta — ver la nota a continuación
Respuestas JSON-RPC no solicitadasDescartadas — solo se aceptan IDs con una solicitud pendiente
Solicitudes iniciadas por el servidorLas respuestas están limitadas por un presupuesto de concurrencia en lugar de generar hilos ilimitados
Valores de esquema patternComparados bajo un presupuesto de tiempo para toda la operación; un tiempo de espera agotado falla la validación en lugar de pasarla silenciosamente
Mensajes de registro (notifications/message)Caracteres de control escapados y longitud limitada, por lo que un servidor no puede falsificar líneas de registro
Flujos de respuesta rotos (MCP 2026-07-28)La solicitud perdida se reemite una vez como una nueva solicitud, tools/call incluido: la revisión dice que los clientes DEBEN reemitir y convierte el flujo cerrado en la señal de cancelación del servidor. Un servidor que ya había terminado la herramienta antes de que el flujo se rompiera la ejecuta una segunda vez. Un rechazo de -32020 HeaderMismatch también se reintenta una vez, después de un refresco de tools/list. Las sesiones heredadas mantienen la regla 2.1.0: las solicitudes no idempotentes nunca se reenvían
Resultados de cacheScope: "private"Vinculados al encabezado Authorization con el que se envió la solicitud (SHA-256 de sus bytes) y nunca se sirven bajo otro. Una credencial transportada en otro lugar — una cookie, un encabezado X-Api-Key, TLS de cliente — no forma parte de ese contexto

Límite conocido: la verificación OAuth es textual. Un par aún puede anunciar un nombre de host público cuyo registro DNS apunte dentro de tu red; detectar eso requiere filtrado en el momento de la resolución en la capa HTTP, que esta gema no hace. Si ejecutas en un entorno donde eso importa, restringe la salida en la capa de red.

Dos valores predeterminados relacionados que vale la pena destacar porque afectan tus datos en lugar de los del par:

  • Los payloads nunca se escriben en los registros. En DEBUG el cliente registra un resumen de método/id y un conteo de bytes, no parámetros de solicitud, cuerpos de respuesta o fragmentos SSE sin procesar. Las configuraciones del servidor se registran con las claves que contienen credenciales redactadas.
  • Las excepciones de host no se reflejan al servidor. Un manejador de elicitación, muestreo o raíces que lanza una excepción produce un mensaje de error JSON-RPC constante; el detalle permanece en tu registro local. Cuando un servidor solicita varias entradas a la vez (solicitudes de ida y vuelta múltiples de MCP 2026-07-28) y una de ellas falla, las respuestas que tu manejador ya produjo se conservan en lugar de descartarse: el bucle de sondeo de una tarea las envía con su próximo tasks/update y nunca vuelve a poner una solicitud ya respondida en tu manejador.

Requisitos

  • Ruby >= 3.3.0
  • Dependencias de ejecución: faraday (~> 2.0) con faraday-follow_redirects y faraday-retry, más base64 — todas instaladas automáticamente por la gema

El desarrollo usa Ruby 4.0.7 (ver .ruby-version). CI ejecuta la suite en 4.0.7 más el mínimo compatible, 3.3.

Licencia

Disponible como código abierto bajo la Licencia MIT.

Contribuciones

Informes de errores y solicitudes de extracción bienvenidos en https://github.com/simonx1/ruby-mcp-client.