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ámetrosx-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_requiredde 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/callaumentado con tareas — declare la extensiónio.modelcontextprotocol/tasks, consultetasks/get, respondainputRequestscontasks/update, y tome el resultado detasks/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/cacheScopeen listas, lecturas y descubrimiento (2026-07-28) - Audio: Soporte de tipo de contenido de audio
- Progreso y cancelación: Canalización
progressTokencon devoluciones de llamada por llamada;notifications/cancelledautomá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,titley_metaanalizados 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 URL | Transporte |
|---|---|
Termina con /sse | SSE — HTTP+SSE está obsoleto, prefiera Streamable HTTP (Funciones obsoletas) |
Termina con /mcp | Streamable HTTP |
stdio://command o Array | stdio |
npx, node, python, etc. | stdio |
| Otras URLs HTTP | Detecció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ística | Obsoleta desde | Eliminación más temprana | Migració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-28 | Pase 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-28 | Integre 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-28 | Registre 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 Final | Migre el servidor a HTTP Streamable |
includeContext "thisServer" / "allServers" en solicitudes de muestreo | 2025-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 OAuth | 2026-07-28 (PR #2858) | la primera revisión publicada en o después de 2027-07-28 | Documentos 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 OpenAIruby_anthropic_mcp.rb- integración con Anthropicgemini_ai_mcp.rb- integración con Google Vertex AIruby_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- enPATHpython3(o$PYTHON) más un binariopythonseparado - enPATH- Paquetes de Python
flask,fastmcp,mcp- importables por$PYTHON npx(Node) - necesario para el ejemplo basado ennpx(json_input) y para cada ejemplo de LLM, que generan servidoresnpxfilesystem/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
| Variable | Predeterminado | Efecto |
|---|---|---|
RUN_AI | 1 | Establézcalo en 0 para omitir las integraciones de LLM, que realizan llamadas reales y de pago a la API. |
RUN_NPX | 1 | Establé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. |
PYTHON | python3 | Intérprete utilizado para lanzar los servidores Python/Flask/FastMCP y ejecutar las comprobaciones previas de importación. |
TIMEOUT | 120 | Tiempo de espera de reloj de pared por ejemplo en segundos; un tiempo de espera se informa como un FAIL. |
LOG_DIR | directorio mktemp nuevo | Directorio 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 enVERTEX_CREDENTIALS_FILE(predeterminadoexamples/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 comoclient_id); cuando el servidor de autorización anunciaclient_id_metadata_document_supported, el registro dinámico de clientes se omite por completo. - Desafíos de alcance (SEP-835) — un desafío
insufficient_scopede HTTP 403 generaMCPClient::Errors::InsufficientScopeError(una subclase deConnectionError) que expone#scopey#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_supportedincluyendoS256.
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 constorage.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 conissuer:en elClientInfo, o bajoclient_registration_key(issuer); sin ello, la autorización lanza unConnectionErrorque 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 elstatedel 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_scopesolicita la unión de los ámbitos ya solicitados y los que nombra el desafío, de modo que obtenerfiles:writeno renuncia afiles: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=openidno añade un segundoscopea la solicitud; lo mismo parastate,client_idy 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_typees OBLIGATORIO (RFC 6749 §5.1) y debe serBearer(§7.1). Un tokenDPoPomacse 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
localhosto HTTPS — MCP 2026-07-28 "Seguridad de la comunicación".http://app.example.com/callbackse 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:
BrowserOAuthrechaza un callback que repitaiss,state,codeo 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 alAuthorizationcon 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
_metaque lleva una solicitud (turequest_metay subaggage, 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 arequest_metaen su lugar no cambia lo que una solicitud ya enviada significa — establecerequest_metaal 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/listsobre 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 unttlMspositivo 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 unlist_resources(cursor:)explícito olist_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-Idde 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-IDpara que el servidor pueda reproducir mensajes perdidos — honrando la directivaretry: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:
- @modelcontextprotocol/server-filesystem
- @playwright/mcp
- FastMCP
- Servidores personalizados que implementan el protocolo 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 par | Qué 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 SSE | Lí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 SSE | Longitud acotada, solo ASCII imprimible (se reflejan en Last-Event-ID) |
Eventos SSE endpoint heredados | Deben 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 par | Deben 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 solicitadas | Descartadas — solo se aceptan IDs con una solicitud pendiente |
| Solicitudes iniciadas por el servidor | Las respuestas están limitadas por un presupuesto de concurrencia en lugar de generar hilos ilimitados |
Valores de esquema pattern | Comparados 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/updatey nunca vuelve a poner una solicitud ya respondida en tu manejador.
Requisitos
- Ruby >= 3.3.0
- Dependencias de ejecución:
faraday(~> 2.0) confaraday-follow_redirectsyfaraday-retry, másbase64— 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.