Ruby MCP Client
Um cliente Ruby para o Model Context Protocol (MCP), permitindo integração com ferramentas e serviços externos por meio de um protocolo padronizado.
Documentação
ruby-mcp-client
Um cliente Ruby para o Model Context Protocol (MCP), permitindo integração com ferramentas e serviços externos por meio de um protocolo padronizado.
Instalação
# Gemfile
gem 'ruby-mcp-client'
bundle install
# or
gem install ruby-mcp-client
Visão Geral
O MCP permite que assistentes de IA descubram e invoquem ferramentas externas por meio de diferentes mecanismos de transporte:
- stdio - Processos locais que implementam o protocolo MCP
- SSE (obsoleto) - Server-Sent Events com suporte a streaming; o transporte HTTP+SSE está obsoleto e novas integrações devem usar Streamable HTTP (veja Recursos obsoletos)
- HTTP - Requisição/resposta simples (sem streaming)
- Streamable HTTP - HTTP POST com respostas formatadas em SSE
Conversões de API integradas: to_openai_tools(), to_anthropic_tools(), to_google_tools()
Suporte ao Protocolo MCP
Implementa a especificação MCP 2026-07-28 e permanece compatível com
todas as revisões anteriores (2025-11-25, 2025-06-18, 2025-03-26,
2024-11-05). O cliente é de dupla era: ele testa cada servidor com
server/discover e fala o protocolo 2026-07-28 sem estado (com _meta
por requisição, sem initialize, sem sessões) com servidores que o respondem, e executa
o handshake clássico initialize com todos os demais — desconectando-se se um
servidor responder com uma revisão que ele não consegue falar. Veja
Recursos do MCP 2026-07-28 para as novas
capacidades e Recursos obsoletos para o que a
revisão aposenta.
- Ferramentas: listar, chamar, streaming, anotações (estilo dica), saídas estruturadas validadas contra
outputSchema(JSON Schema 2020-12 / 2019-09 / draft-07), título, parâmetrosx-mcp-header - Prompts: listar, obter com parâmetros
- Recursos: listar, ler, modelos, assinaturas, paginação, conteúdo ResourceLink
- Elicitação: Interações iniciadas pelo servidor (stdio, Streamable HTTP e o transporte obsoleto HTTP+SSE) e resultados
input_requiredde múltiplas idas e voltas - Raízes (obsoleto em 2026-07-28): Limites de escopo do sistema de arquivos com notificações de alteração
- Amostragem (obsoleto em 2026-07-28): Completions de LLM solicitadas pelo servidor com modelPreferences
- Conclusão: Autocompletar para prompts/recursos com contexto
- Registro (obsoleto em 2026-07-28): Mensagens de log do servidor com filtro por nível
- Tarefas:
tools/callaumentado por tarefas — declare a extensãoio.modelcontextprotocol/tasks, consultetasks/get, respondainputRequestscomtasks/updatee obtenha o resultado detasks/get; a superfície 2025-11-25 permanece como legado - Assinaturas: Fluxos de notificação
subscriptions/listen(2026-07-28) - Cache: Dicas de frescor
ttlMs/cacheScopeem listas, leituras e descoberta (2026-07-28) - Áudio: Suporte a tipo de conteúdo de áudio
- Progresso e Cancelamento: Infraestrutura
progressTokencom callbacks por chamada;notifications/cancelledautomático para requisições abandonadas (em uma sessão moderna de Streamable HTTP, fechar o fluxo de resposta já é o cancelamento, então nenhuma notificação é enviada) - Metadados:
icons,titlee_metaanalisados em ferramentas, prompts e recursos - OAuth 2.1: PKCE (S256 obrigatório), descoberta RFC 8414/9728, validação de emissor RFC 9207, Documentos de Metadados de Client ID, registro dinâmico (obsoleto), desafios de aumento de escopo
Os transportes tratam o servidor como entrada não confiável — veja Tratando o Servidor como Não Confiável para os limites aplicados a dados controlados pelo par.
API de Conexão Rápida (Recomendada)
A maneira mais simples de conectar a um 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
Cabeçalhos configurados: Os valores headers: são enviados em cada requisição, com um
namespace reservado. Em uma sessão HTTP moderna (MCP 2026-07-28), o cliente é dono
de Mcp-Param-*: esses cabeçalhos são derivados dos argumentos de um tools/call (as anotações x-mcp-header da ferramenta), então qualquer cabeçalho com esse nome fornecido em
headers: é descartado de requisições modernas em vez de substituir um
argumento que a chamada não carregava. Sessões legadas, onde o namespace não tem
significado de protocolo, o enviam inalterado.
Detecção de Transporte:
| Padrão de URL | Transporte |
|---|---|
Termina com /sse | SSE — HTTP+SSE está obsoleto, prefira Streamable HTTP (Recursos obsoletos) |
Termina com /mcp | Streamable HTTP |
stdio://command ou Array | stdio |
npx, node, python, etc. | stdio |
| Outras URLs HTTP | Detecção automática (Streamable HTTP → SSE → HTTP) — a etapa SSE é o transporte obsoleto HTTP+SSE, tentado apenas após Streamable HTTP falhar (Recursos obsoletos) |
Trabalhando com Ferramentas, Prompts e 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 somente depois que o servidor confirmou a
assinatura, e lança erro caso contrário — o próprio erro do servidor, ou
MCPClient::Errors::ResourceReadError (inclusive quando a confirmação não
chega dentro do read_timeout do transporte). Em um servidor 2025-11-25, a
confirmação é a resposta resources/subscribe; em um servidor 2026-07-28, é
o reconhecimento subscriptions/listen nomeando esse URI, que a chamada
aguarda. As atualizações então chegam como notifications/resources/updated por meio de
on_notification. Cada reconhecimento posterior desse fluxo também é revalidado:
um fluxo reaberto após uma conexão HTTP perdida ou um reinício do stdio é uma nova
solicitação de escuta que o servidor pode reconhecer de forma mais restrita, então um
que volte sem o URI encerra a assinatura em vez de deixar o recurso
relatado como monitorado enquanto nada o monitora — incluindo um que chega na
janela entre o reconhecimento que subscribe_resource aguardava e o
URI sendo mapeado para o fluxo, que é verificado mais uma vez com o mapeamento em
vigor antes de a chamada responder. Um fluxo já mapeado para o URI é reutilizado
somente enquanto o servidor estiver honrando esse URI nele agora: um que caiu
é aguardado — por meio do backoff de reabertura HTTP ou do handshake stdio, e
além da solicitação de substituição até o servidor respondê-la — em vez de
ser relatado como um monitor com base no que o fluxo que se foi havia
concedido, porque nenhuma assinatura do lado do servidor existe entre tentativas de escuta
e a solicitação que substitui uma é uma nova escuta para a qual o servidor não mantém estado
e pode rejeitar ou reconhecer sem o URI. Somente um fluxo que o servidor está
ativamente honrando conta como um monitor ativo, e um que não se torna
um — sua substituição é recusada, ou nada responde dentro do
tempo limite de reconhecimento — é fechado e também desmapeado, para que não possa voltar
e entregar as mesmas atualizações ao lado do fluxo que o substitui, e
unsubscribe_resource nunca fica procurando por uma assinatura que o mapeamento não
nomeia mais. O assinante aguardando sua própria
solicitação de escuta é outra questão e ainda recebe sua resposta: uma conexão
que cai no momento em que o reconhecimento chega não o deixa sem resposta, então a
chamada não espera seu tempo limite de reconhecimento por uma concessão que já
tem — enquanto uma solicitação de substituição que realmente foi enviada fica sem resposta
até o servidor responder ela.
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.
Recursos do MCP 2026-07-28
A revisão de 2026-07-28 torna o protocolo sem estado: não há
initialize handshake, nem sessão, nem canal de requisição servidor-para-cliente.
Cada requisição carrega seus próprios metadados, e qualquer coisa que o servidor
precise do cliente é solicitada através do próprio resultado. O cliente detecta
qual era o servidor fala e se adapta; o código existente continua funcionando sem alterações.
Descoberta e metadados por requisição
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
Um servidor moderno é reconhecido pela sua resposta a server/discover; cada
requisição então carrega io.modelcontextprotocol/protocolVersion,
io.modelcontextprotocol/clientInfo e
io.modelcontextprotocol/clientCapabilities em _meta, e em HTTP os
cabeçalhos MCP-Protocol-Version, Mcp-Method e Mcp-Name. Metadados fornecidos pelo host
(um Hash ou um callable avaliado por requisição) são mesclados em cada
requisição com MCPClient::Client.new(request_meta: ...). Force uma era com
protocol: :modern / protocol: :legacy (padrão :auto) e ajuste a
sondagem com discover_timeout: em stdio_config, http_config e
streamable_http_config (ou os construtores de servidor correspondentes), e em
MCPClient.connect para esses mesmos três transportes. O transporte HTTP+SSE
depreciado não tem era própria: MCPClient::ServerSSE não aceita nenhuma das opções,
então MCPClient.connect descarta ambas para uma URL /sse em vez de passá-las
adiante (Recursos depreciados) — exceto protocol: :modern,
que um transport: :sse explícito recusa com ArgumentError em vez de
conectar silenciosamente ao legado.
ping mapeia para server/discover e log_level= para o nível de log
por requisição em servidores modernos.
log_level=está depreciado no MCP 2026-07-28 (SEP-2577). Em um servidor moderno, ele escreve_meta["io.modelcontextprotocol/logLevel"], parte do utilitário de Logging que a revisão marca como Depreciado como um todo: novas implementações NÃO DEVEM adotá-lo. Veja Recursos depreciados.
Erros tipados
carregam o código JSON-RPC: MCPClient::Errors::HeaderMismatchError
(-32020), MissingRequiredClientCapabilityError (-32021) e
UnsupportedProtocolVersionError (-32022).
Requisições de múltiplas idas e voltas
Em um servidor moderno, tools/call, resources/read e prompts/get podem
responder a resultType: "input_required". O cliente atende cada requisição em
inputRequests com os handlers que já possui — o handler de elicitação,
o handler de amostragem e as raízes configuradas — e reenvia a requisição
original com inputResponses e o requestState opaco do servidor. Mais
de 10 rodadas, uma requisição que o cliente não pode atender ou um
inputRequests malformado geram MCPClient::Errors::InputRequiredError (expondo
input_requests e request_state).
Elicitação em modo URL
Um handler de elicitação com aridade 2 (ou mais) é chamado com a mensagem e um
hash de metadados quando o servidor solicita uma interação fora de banda (url).
Em um servidor 2025-11-25, esse hash é
{ 'mode' => 'url', 'url' => ..., 'elicitationId' => ... }; em um
servidor 2026-07-28, é { 'mode' => 'url', 'url' => ... } — a
revisão removeu elicitationId juntamente com
notifications/elicitation/complete, porque o resultado é aprendido ao
tentar novamente a requisição original em vez de a partir de um sinal iniciado pelo servidor, e
um servidor que precisa correlacionar uma elicitação entre tentativas carrega seu próprio
identificador no requestState opaco. Um servidor moderno que envia
elicitationId mesmo assim não o alcança o host: o campo é descartado
e um aviso é registrado.
Cabeçalhos personalizados de parâmetros de ferramenta
Parâmetros de ferramenta anotados com x-mcp-header são espelhados nos
cabeçalhos de requisição Mcp-Param-{name} em tools/call (Streamable HTTP e
HTTP simples). Uma rejeição de incompatibilidade de cabeçalho -32020 aciona uma atualização de tools/list
e uma nova tentativa; ferramentas com anotações inválidas são excluídas da
lista com um aviso. MCPClient::HeaderParams expõe a validação.
Assinaturas (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 assinatura é uma requisição subscriptions/listen de longa duração; em
Streamable HTTP, ela roda em seu próprio stream e é reaberta com backoff quando
o stream cai; em stdio, é reenviada quando o processo reinicia.
subscribe_resource / unsubscribe_resource mapeiam para streams de escuta em
servidores modernos.
Resultados armazenáveis em cache
server/discover, as requisições */list e resources/read carregam
ttlMs e cacheScope. As listas são servidas enquanto frescas e re-buscadas ao
acesso quando obsoletas (uma lista obsoleta é servida com um aviso quando a re-busca
falha transitoriamente); resultados resources/read com um ttlMs são armazenados em cache por
URI. Entradas com cacheScope: "private" são vinculadas às credenciais com as quais a
requisição foi enviada e nunca compartilhadas entre contextos de autorização.
server.cache_info(:tools) / cache_info(:read, uri) expõem ttl_ms,
cache_scope, received_at e fresh.
Extensão de tarefas (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)
Chamadas aumentadas por tarefa são opt-in por meio de extensions:; um servidor que
responde a tools/call com uma tarefa é consultado em seu pollIntervalMs até que a
tarefa seja terminal ou seu ttlMs expire, e tarefas input_required são
respondidas com os handlers de elicitação / amostragem / raízes. tasks/list e
tasks/result não existem em servidores 2026-07-28; a API de tarefas legada continua
funcionando em servidores 2025-11-25.
Autorização
OAuthProvider registra o issuer do servidor de autorização com cada
requisição de autorização e valida o iss do callback conforme RFC 9207
(complete_authorization_flow(code, state, iss:)); credenciais de cliente e
tokens são vinculados ao emissor que os produziu, então um servidor que
alterna servidores de autorização nunca vê o token de outro servidor. O
Registro Dinâmico de Cliente carrega application_type e está depreciado em favor
de Documentos de Metadados de ID de Cliente (client_id_metadata_url:). Veja
OAUTH.md.
Recursos depreciados
O registro de recursos depreciados de 2026-07-28
lista estes como Depreciados sob a política de ciclo de vida de recursos: eles continuam
funcionando durante sua janela de depreciação, mas novas integrações não devem
adotá-los. A remoção mais antiga é a do próprio registro, e ela nomeia uma
revisão, não uma data: o que os recursos que 2026-07-28 deprecia esperam é
a primeira revisão lançada em ou após 2027-07-28, que pode em si chegar
bem depois dessa data — não planeje em torno de 2027-07-28 como uma data de remoção. Os
valores includeContext seguem Amostragem, e apenas o transporte HTTP+SSE tem
um relógio próprio. A remoção mais antiga é quando um recurso se torna
elegível para remoção; a remoção real é uma decisão dos Mantenedores Principais
tomada durante a preparação do lançamento. O cliente registra um aviso por recurso por
processo no primeiro uso, nomeando a remoção mais antiga e a
migração sugerida:
| Recurso | Depreciado desde | Remoção mais antiga | Migração |
|---|---|---|---|
Raízes (roots:, Client#roots=, ou responder a uma requisição roots/list com uma lista não vazia) | 2026-07-28 (SEP-2577) | a primeira revisão lançada em ou após 2027-07-28 | Passe diretórios ou arquivos por parâmetros de ferramenta, URIs de recurso ou configuração de servidor |
Amostragem (sampling_handler: em Client.new ou MCPClient.connect, ou servir uma requisição pelo on_sampling_request de um transporte) | 2026-07-28 (SEP-2577) | a primeira revisão lançada em ou após 2027-07-28 | Integre diretamente com a API do provedor de LLM |
Logging (log_level= no cliente ou em um servidor, um io.modelcontextprotocol/logLevel em request_meta ou um _meta por chamada, notifications/message) | 2026-07-28 (SEP-2577) | a primeira revisão lançada em ou após 2027-07-28 | Registre em stderr (stdio) ou use OpenTelemetry |
Transporte HTTP+SSE (MCPClient::ServerSSE, avisado uma vez conectado) | 2025-03-26 (reclassificado por SEP-2596) | três meses após SEP-2596 atingir Final | Migre o servidor para Streamable HTTP |
includeContext "thisServer" / "allServers" em requisições de amostragem | 2025-11-25 (reclassificado por SEP-2596) | segue Amostragem (SEP-2577) | Servidores omitem o campo ou enviam "none" |
| Registro Dinâmico de Cliente OAuth | 2026-07-28 (PR #2858) | a primeira revisão lançada em ou após 2027-07-28 | Documentos de Metadados de ID de Cliente ou credenciais pré-registradas |
Um cliente que nunca adotou Raízes nunca é avisado sobre elas. Ele registra um
handler roots/list em cada servidor incondicionalmente, para que um
client.roots = [...] posterior seja servido sem reconexão, e até que uma raiz seja definida,
ele responde a roots/list com uma lista vazia — uma resposta vazia não expõe nada
depreciado, então não gera aviso. O aviso dispara quando o host configura
roots:, chama Client#roots=, ou serve uma resposta que realmente carrega uma
raiz (incluindo de um transporte dirigido diretamente, sem um Client).
MCPClient::Deprecations::REGISTRY os lista, cada um com seu
earliest_removal; defina
MCPClient::Deprecations.enabled = false (antes de construir clientes) para
silenciar os avisos. Os avisos vão para o logger que o cliente ou servidor
recebeu; sem um, eles vão para o logger padrão $stdout como qualquer outro
aviso. Um aviso custa à operação depreciada exatamente o que um
logger.warn custa a ela, e nada mais: um logger que levanta, descarta o aviso
ou escreve em lugar nenhum (Logger.new(nil), ou um cujo dispositivo foi fechado) é
engolido, então o recurso continua funcionando e o aviso permanece devido a um uso
posterior — mas um logger que bloqueia, bloqueia seu chamador aqui exatamente como faz
em qualquer outro lugar na biblioteca. Uma falha está além do alcance: um
Logger padrão sobre um dispositivo aberto que falha ao escrever esconde isso de seu chamador
(ele relata apenas em $stderr e retorna como se tivesse escrito), então esse
aviso é gasto; um dispositivo fechado é reconhecido, um quebrado não é. Um logger que o host envolveu é consultado através
do wrapper (warn?, e o dispositivo do Delegator que ele contém), então um
wrapper marcado, de broadcast ou feito à mão acima de WARN deixa o aviso devido
em vez de gastá-lo em uma linha que ninguém lê; um wrapper que filtra por
algo que um nível não pode expressar — uma tag, uma lista de permissão de fonte — não pode ser
consultado, e o gasta.
Nada nunca espera por um aviso. Um chamador que encontra um já em andamento —
o primeiro uso de outra thread, ou um formatador, assinante de log, hook de auditoria ou
acessor level que alcança um recurso depreciado de dentro do aviso
sendo escrito — recua imediatamente em vez de enfileirar atrás dele. Enfileirar valeria um
deadlock, já que a thread escrevendo um aviso segura o logger e a thread
que enfileiraria pode estar segurando um lock que o logger precisa (um
logger.info comum segura exatamente tal lock enquanto seu dispositivo roda). E não
compraria nada: quem segura o aviso está escrevendo-o, e se seu logger
falhar ou filtrar o aviso, o aviso é devido novamente, então o próximo uso do recurso o levanta.
Recursos do MCP 2025-11-25
Anotações de ferramenta
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
Saídas estruturadas
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.
Um resultado é verificado contra a definição de ferramenta sob a qual a requisição que o produziu
foi enviada. Isso importa em uma sessão HTTP moderna, onde uma rejeição HeaderMismatch
faz o cliente atualizar tools/list e tentar novamente com cabeçalhos Mcp-Param-*
recalculados: a nova tentativa é respondida sob a definição atualizada, então
é contra ela que o resultado é validado. Um tools/list_changed que meramente
chega enquanto a chamada está em andamento nunca muda a definição contra a qual o resultado é
verificado — o servidor nunca viu a substituição.
O que conta como conteúdo estruturado segue a revisão para a qual a sessão foi
negociada. O MCP 2026-07-28 ampliou structuredContent para qualquer valor JSON,
então um null presente, array, string, número ou booleano é conteúdo estruturado
e é validado contra o esquema de saída. O MCP 2025-11-25 o tipa como um
objeto: em uma sessão negociada para essa revisão, qualquer outra coisa não é
conteúdo estruturado, e uma ferramenta que declara um outputSchema e
envia um é relatada como tendo retornado nenhum. Um transporte que não pode dizer
qual revisão negociou não é assumido como legado.
Dialetos e referências de JSON Schema
O validador embutido lê JSON Schema 2020-12 (o padrão do MCP), 2019-09
e draft-07. Conforme o MCP 2026-07-28, um dialeto não suportado deve ser relatado como
um erro, então uma ferramenta cujo inputSchema declara um que este cliente não
implementa é recusada antes que a requisição seja enviada:
# 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: ...)"
O mesmo se aplica a um outputSchema: um dialeto não suportado lá levanta um
ValidationError em ambos os modos, já que o cliente não consegue ler o esquema de
forma alguma — diferente de uma incompatibilidade de conteúdo estruturado, que o
modo validate_structured_content decide. A definição verificada é aquela sob a qual a
solicitação respondida realmente saiu, então uma nova tentativa de HeaderMismatch
sob um esquema atualizado também é coberta — e como a rejeição significa que o
servidor não executou a tentativa, um inputSchema atualizado cujo dialeto
este cliente não consegue ler interrompe a nova tentativa antes de ser enviada, em vez de depois
de a ferramenta ter sido executada. Um esquema que é meramente inutilizável por outro motivo (um
$ref que exigiria uma busca de rede, um documento além dos limites do recurso,
um valor de palavra-chave malformado — incluindo as palavras-chave de metadados, que devem ser
escritas como o tipo JSON Schema as fornece) é alvo de aviso, e para um esquema
de entrada a chamada ainda sai, já que o servidor é dono da validação de argumentos.
Dois recursos de esquema não podem responder a uma URI: um documento cujos $ids (ou
cujos nomes de $anchor dentro de um recurso) colidem é inutilizável, já que
em qual declaração uma referência cai dependeria da ordem em que o
documento foi lido.
As referências são resolvidas apenas dentro do documento — nada é jamais buscado —
mas um documento que agrupa os recursos que usa é resolvido por completo: um
$ref nomeando um $id embutido ("urn:example:s", uma URI relativa contra a
base que um $id circundante estabeleceu, ou a referência vazia "") resolve para
esse recurso, e apenas uma referência a um recurso que o documento não carrega
é relatada como externa. Sob 2020-12 e 2019-09, definitions se comporta como
o $defs do qual foi renomeado, já que o meta-esquema de ambos os dialetos o retém.
Raízes
Obsoleto no MCP 2026-07-28 (SEP-2577); veja Recursos obsoletos.
# Set filesystem scope boundaries
client.roots = [
{ uri: 'file:///home/user/project', name: 'Project' },
{ uri: 'file:///var/log', name: 'Logs' }
]
# Access current roots
client.roots
Amostragem (Conclusões de LLM solicitadas pelo servidor)
Obsoleto no MCP 2026-07-28 (SEP-2577); veja Recursos obsoletos.
# 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' }
}
}
)
Os históricos de amostragem são verificados antes de o manipulador ser executado (MCP 2026-07-28
client/sampling: ambas as partes DEVEM validar o conteúdo da mensagem): toda mensagem
precisa de um papel "user"/"assistant" e conteúdo, uma mensagem de usuário contendo resultados
de ferramenta não contém mais nada, e cada uso de ferramenta pelo assistente é respondido pela mensagem
de usuário que o segue. Um histórico malformado é recusado com -32602 (ou falha
a ida e volta múltipla localmente em um servidor 2026-07-28) e o manipulador não é
invocado.
Uma solicitação de ida e volta múltipla pode aguardar uma interação que acontece fora da banda
(uma elicitação em modo URL): o servidor continua respondendo apenas com um
requestState, e o cliente tenta novamente com uma pausa crescente. O host conduz
essa espera e pode retomar uma solicitação que interrompeu:
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
Uma espera nunca ultrapassa o tempo limite sob o qual a solicitação é executada — o
fornecido à chamada, ou o read_timeout configurado do transporte quando a chamada
não nomeou nenhum — e o tempo que seu próprio controle gasta decidindo conta contra ele.
O erro que ele levanta então é retomável da mesma forma.
A chamada de ferramenta de amostragem (SEP-1577) é opcional: passe sampling_supports_tools: true
para declarar a capacidade sampling.tools. O manipulador então recebe os
parâmetros completos da solicitação (incluindo tools/toolChoice) como um quinto argumento opcional;
sem a adesão, solicitações de amostragem habilitadas para ferramenta são rejeitadas com -32602
como a especificação exige. Em um servidor 2026-07-28, onde a amostragem chega como uma
solicitação de entrada dentro de uma resposta de ida e volta múltipla e inputResponses não tem
canal de erro por solicitação, a mesma rejeição falha toda a ida e volta com
MCPClient::Errors::InputRequiredError e o manipulador nunca é invocado:
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
# ...
}
)
Rastreamento de Progresso
Anexe um retorno de chamada de progresso por chamada — o cliente gera um
progressToken único, o coloca no _meta da solicitação e roteia notifications/progress
correspondentes para o seu bloco enquanto a solicitação está ativa (tokens
obsoletos após a conclusão são descartados):
client.call_tool('long_running', args, progress: ->(progress, total, message) {
puts "#{message}: #{progress}/#{total}"
})
Um _meta em nível de solicitação (por exemplo, um progressToken escolhido manualmente) também pode ser passado
dentro dos argumentos sob a chave '_meta' em todo transporte — ele é elevado
para o nível de params JSON-RPC no fio, nunca enviado como um argumento de ferramenta.
Tempos Limite e Cancelamento
Os tempos limite são configuráveis por solicitação, além do read_timeout por servidor.
Uma solicitação com tempo esgotado levanta
MCPClient::Errors::RequestTimeoutError (uma subclasse de TransportError), é
nunca reenviada silenciosamente pela camada de nova tentativa, e um
notifications/cancelled de melhor esforço é enviado para a solicitação abandonada (nunca para
initialize, e chamadas aumentadas por tarefa usam tasks/cancel em vez disso):
client.send_rpc('tools/call', params: { name: 'slow', arguments: {} }, timeout: 300)
server.rpc_request('tools/list', {}, timeout: 5)
Identidade do Cliente e Instruções do Servidor
Os hosts podem apresentar suas próprias informações de Implementation (enviadas como clientInfo durante
initialize; name e version obrigatórios — title, description,
websiteUrl, icons opcionais) e ler a dica de instructions do servidor
após conectar:
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."
Controle de Capacidade
Recursos opcionais do servidor (logging/setLevel, resources/subscribe,
completion/complete, tasks/list, tasks/cancel) são enviados apenas a servidores
que negociaram a capacidade correspondente; caso contrário,
MCPClient::Errors::CapabilityError é levantado (o ciclo de vida proíbe usar
capacidades que não foram negociadas). Client#log_level= pula
servidores sem registro em vez de falhar. As capacidades de cliente declaradas são
derivadas do que o host realmente registrou (manipuladores, raízes), nunca
codificadas — e elas também controlam o tráfego do próprio cliente:
notifications/roots/list_changed vai apenas para uma sessão que declarou
roots (nunca para um servidor 2026-07-28, que removeu a notificação, e
nunca para HTTP simples em uma sessão legada, que não tem canal de solicitação do servidor
para servir raízes).
Conclusão (Autocompletar)
result = client.complete(
ref: { type: 'ref/prompt', name: 'greeting' },
argument: { name: 'name', value: 'A' }
)
# => { 'values' => ['Alice', 'Alex'], 'total' => 100, 'hasMore' => true }
Registro
Obsoleto no MCP 2026-07-28 (SEP-2577); veja Recursos obsoletos.
# 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
Tarefas (Ferramentas de longa duração aumentadas por tarefa)
Um servidor com capacidade de tarefa (um que anuncia tasks.requests.tools.call) pode executar uma ferramenta
cujo execution.taskSupport é optional ou required como uma tarefa em segundo plano:
a chamada retorna imediatamente com um identificador de tarefa, e o resultado é buscado depois.
Experimente localmente: python3 examples/echo_server_streamable.py & e depois
./examples/tasks_example.rb executa o ciclo de vida completo contra um servidor de demonstração
com capacidade de tarefa.
No MCP 2026-07-28, o cliente deve declarar a extensão para que o servidor possa responder com uma tarefa de forma alguma:
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
Em um servidor 2026-07-28, tasks/result e tasks/list desapareceram: o resultado
é lido de tasks/get, client.list_tasks levanta, e um ttl: passado para
call_tool_as_task é ignorado (o servidor concede ttlMs). Contra um
servidor 2025-11-25, a superfície anterior ainda se aplica — ttl: é enviado,
get_task_result usa tasks/result, e client.list_tasks pagina
tasks/list.
Os IDs de tarefa são únicos apenas dentro do servidor que os emitiu, então passe o Task
retornado por call_tool_as_task — ele carrega seu próprio servidor. Um ID de tarefa simples também
funciona quando o cliente tem um único servidor; com vários servidores configurados, ele
levanta ArgumentError em vez de adivinhar, então nomeie o servidor explicitamente:
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
Notificações anunciam; elas não conduzem. Um notifications/tasks (2026-07-28,
em um fluxo listen) que carrega inputRequests alcança os ouvintes exatamente
como chegou — o cliente responde a solicitações de entrada apenas dentro de wait_for_task
(e call_tool), ou quando o host envia as respostas ele mesmo com
update_task. Um host que acompanha uma tarefa por notificações a entrega a
wait_for_task quando quer que as solicitações sejam respondidas.
Os identificadores sobrevivem a um processo apenas na medida em que o host os mantém: task.to_h
serializa um identificador, e MCPClient::Task.from_json(hash, server: server) (ou
o taskId simples com server:) nomeia a mesma tarefa em outro cliente, que
pode get_task, wait_for_task ou cancel_task-la. Cada identificador que uma criação, uma
atualização de get_task, um wait_for_task ou um cancelamento devolve dentro de um
cliente também nomeia a vida útil de sua tarefa: uma vez que o servidor entregou o
mesmo id a uma nova tarefa, esse identificador levanta TaskReplacedError em vez de
alcançar a substituição.
Elicitação (Interações de usuário iniciadas pelo 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' }
}
)
No modo formulário, o manipulador pode retornar o conteúdo por conta própria ({ 'campo' => 'valor' }), which is sent as an accept. An explicit action deve ser um de
accept, decline ou cancel — qualquer outro valor é respondido cancel, já que
não é consentimento que o usuário deu.
No modo URL, o segundo argumento do manipulador é { 'mode' => 'url', 'url' => ... }
em um servidor 2026-07-28 e
{ 'mode' => 'url', 'url' => ..., 'elicitationId' => ... } em um
2025-11-25: a revisão mais nova removeu elicitationId, então um host não deve esperar essa
chave de um servidor moderno (veja
Elicitação em modo URL). Sua resposta é
consentimento, não dados: apenas um action explícito de accept, decline ou cancel
(ou um true literal para aceitar) conta — qualquer outra coisa é respondida cancel.
content é descartado, já que é apenas do modo formulário, enquanto um _meta
fornecido pelo manipulador é passado adiante.
Configuração Avançada
Para mais controle, use create_client com configs 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')
Novas Tentativas
A opção retries: controla a nova tentativa automática com backoff exponencial. Apenas
falhas onde a solicitação provavelmente não foi concluída no servidor são
tentadas novamente: erros de transporte/rede e respostas HTTP 5xx. Falhas
em nível de aplicação — uma resposta de erro JSON-RPC ou um HTTP 4xx — nunca são
tentadas novamente, porque o servidor já processou ou rejeitou a solicitação. Falhas
de servidor que podem ser tentadas novamente levantam MCPClient::Errors::TransientServerError, uma subclasse de
MCPClient::Errors::ServerError, então os manipuladores rescue ServerError existentes são
afetados.
tools/call nunca é tentado novamente automaticamente. Mesmo uma falha "transitória" pode chegar
após o servidor ter executado a solicitação, e JSON-RPC não tem chave de idempotência
que tornaria uma repetição segura — então uma nova tentativa poderia executar um efeito colateral duas vezes.
Tente novamente uma chamada de ferramenta explicitamente se sua aplicação souber que é seguro repetir, e
trate o erro levantado como resultado desconhecido em vez de não executado:
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
O mesmo raciocínio exclui RequestTimeoutError e ResponseTooLargeError
de novas tentativas, e se aplica à recuperação de sessão: se um tools/call voltar com
um 404 de sessão expirada, o cliente inicia uma nova sessão, mas não reenvia
a chamada — ele levanta para que você decida. Solicitações idempotentes são reenviadas contra
a nova sessão como antes.
Uma exceção: um fluxo de resposta quebrado em uma conexão HTTP Streamable moderna (MCP 2026-07-28).
Essa revisão removeu a retomabilidade SSE e exige que
um fluxo de resposta quebrado perca a solicitação em andamento, que os clientes DEVEM
reemitir como uma nova solicitação com um novo ID de solicitação — sem exceção para
tools/call. Ela pode exigir isso com segurança porque fechar o fluxo de resposta é
o sinal de cancelamento: o servidor DEVE tratar a quebra como um cancelamento dessa
solicitação, parar o trabalho o mais rápido possível e não enviar mais nada para ela. Então,
quando um fluxo de resposta moderno termina sem o resultado, o cliente envia a chamada
novamente uma vez com um novo id de solicitação; se esse fluxo quebrar também, ele levanta
MCPClient::Errors::ResponseStreamClosedError em vez de tentar novamente. Toda
outra falha ambígua permanece inalterada e ainda nunca é reenviada, porque em nenhum
desses casos o servidor foi instruído a parar.
Uma resposta que de fato chegou não é ambígua, portanto nunca é substituída: o
cliente lê os corpos das respostas conforme eles chegam em fluxo, e um socket que
morre após o evento SSE final retorna o resultado que já carregava em vez de
chamar a ferramenta uma segunda vez.
Limites de Tamanho de Resposta (Streamable HTTP)
Uma resposta codificada com gzip é descomprimida incrementalmente e abandonada
quando ultrapassa max_decompressed_body_bytes (padrão 64 MiB), então um corpo pequeno
e altamente comprimido não pode esgotar a memória. Exceder esse limite gera
MCPClient::Errors::ResponseTooLargeError.
Aumente o limite se você legitimaamente trocar cargas muito grandes — blobs de recursos em base64 ou áudio — para que a aceitação de uma resposta não dependa da escolha do servidor em comprimi-la:
MCPClient.streamable_http_config(
base_url: 'https://api.example.com/mcp',
max_decompressed_body_bytes: 256 * 1024 * 1024
)
Personalização do Faraday
MCPClient.http_config(base_url: 'https://internal.company.com') do |faraday|
faraday.ssl.cert_store = custom_cert_store
faraday.ssl.verify = true
end
Middlewares de resposta adicionados aqui são respeitados em ambos os caminhos: se
um middleware conn.response :json (com ou sem conn.response :raise_error)
já decodificou o corpo, um resultado bem-sucedido é lido do objeto decodificado
em vez de ser analisado uma segunda vez, e o erro JSON-RPC que um HTTP 4xx
carrega ainda é reconhecido, então uma rejeição de protocolo mantém seu code, data
e classe de erro tipada em vez de degradar para um ServerError simples.
JSON de Definição do 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" }
}
}
}
Exemplos de Integração com 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 implementações completas:
ruby_openai_mcp.rb,openai_ruby_mcp.rb- integração com OpenAIruby_anthropic_mcp.rb- integração com Anthropicgemini_ai_mcp.rb- integração com Google Vertex AIruby_llm_mcp.rb- integração com RubyLLM (provedor OpenAI)
Executando os Exemplos
O harness examples/run_all_examples.sh executa todos os exemplos que podem rodar na máquina atual — servidores stdio autocontidos, os servidores de eco e elicitação Python/Flask/FastMCP, servidores MCP baseados em npx e (opcionalmente) as integrações pagas de LLM. Ele inicia e encerra cada servidor automaticamente e imprime um resumo PASS/FAIL/SKIP. tasks_example.rb roda contra o echo_server_streamable.py local; oauth_browser_auth.rb é interativo e só roda quando você opta por participar com RUN_OAUTH=1.
Pré-requisitos
Execute bundle install primeiro. O script faz uma verificação prévia dos seguintes itens e imprime um aviso (ele não aborta) para qualquer coisa ausente; os exemplos afetados são então pulados ou falham:
ruby,bundle,curl,lsof- emPATHpython3(ou$PYTHON) mais um binário separado depython- emPATH- Pacotes Python
flask,fastmcp,mcp- importáveis por$PYTHON npx(Node) - necessário pelo exemplo baseado emnpx(json_input) e por todos os exemplos de LLM, que iniciam 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
Chaves de Ambiente
| Variável | Padrão | Efeito |
|---|---|---|
RUN_AI | 1 | Defina como 0 para pular as integrações de LLM, que fazem chamadas de API reais e pagas. |
RUN_NPX | 1 | Defina como 0 (ou deixe npx desligado em PATH) para pular o exemplo baseado em npx (json_input). Os exemplos de LLM também iniciam servidores npx, mas são controlados por RUN_AI e suas chaves de API. |
PYTHON | python3 | Interpretador usado para iniciar os servidores Python/Flask/FastMCP e executar as verificações prévias de importação. |
TIMEOUT | 120 | Tempo limite de relógio por exemplo em segundos; um tempo limite é relatado como um FAIL. |
LOG_DIR | diretório mktemp novo | Diretório para logs por exemplo e por servidor; o caminho é impresso após a verificação prévia e no resumo. |
Segredos e Chaves de API
Segredos reais ficam em examples/secrets.env, que é ignorado pelo git e carregado automaticamente (cada linha KEY=value é exportada) quando presente. Copie o modelo rastreado para começar:
cp examples/secrets.env.example examples/secrets.env
# then set ZAPIER_MCP_TOKEN=... to enable the Zapier streamable-HTTP example
Defina ZAPIER_MCP_TOKEN (da página de configuração do Zapier MCP, "Opção 1: cabeçalho de autorização") para executar streamable_http_example.rb e oauth_example.rb contra o Zapier; substitua ZAPIER_MCP_URL se sua URL de conexão for diferente. Para executar o oauth_browser_auth.rb interativo, defina MCP_SERVER_URL (por exemplo, um túnel ngrok para seu servidor MCP protegido por OAuth) em secrets.env e passe RUN_OAUTH=1. Os exemplos de LLM cada um precisa de suas próprias credenciais no ambiente e são pulados sem elas:
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, além de um servidor MCP Playwright em:8931)gemini_ai_mcp.rb- um JSON de conta de serviço Vertex emVERTEX_CREDENTIALS_FILE(padrãoexamples/google-credentials.json, +npx)
Como Passar/Falhar é Julgado
A maioria dos exemplos imprime suas próprias marcas de sucesso/falha, mas sai com 0 independentemente, então o harness combina o código de saída com uma varredura da saída em vez de confiar apenas no status de saída. Um exemplo FAIL quando sai com código diferente de zero, excede o tempo limite (saída 124), imprime uma assinatura de erro grave (um traceback Ruby/Python, Connection refused, uninitialized constant e similares), imprime uma marca ❌ ou está faltando seu marcador de sucesso esperado; caso contrário, ele PASS. (A verificação ❌ é suprimida com IGNORE_XMARK=1 para as demonstrações interativas de elicitação, onde ❌ pode ser uma saída legítima de "recusado".) O script sai com 0 apenas se zero exemplos falharam — SKIP não afetam o status de saída.
Para tutoriais mais aprofundados por tópico, consulte examples/README.md, examples/README_ECHO_SERVER.md, examples/STREAMABLE_HTTP_TESTING.md e examples/elicitation/README.md.
Autenticação 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
}]
)
Recursos: PKCE, descoberta de servidor (.well-known), validação de emissor RFC 9207, Documentos de Metadados de ID do Cliente, registro dinâmico (fallback obsoleto), renovação de token.
Consulte OAUTH.md para documentação completa.
Extras de OAuth (2025-11-25)
- Documentos de Metadados de ID do Cliente (SEP-991) — passe
client_id_metadata_url: 'https://myapp.example/oauth-client.json'(uma URL HTTPS com um caminho, que também serve comoclient_id); quando o servidor de autorização anunciaclient_id_metadata_document_supported, o registro dinâmico de cliente é totalmente ignorado. - Desafios de escopo (SEP-835) — um desafio
insufficient_scopede HTTP 403 geraMCPClient::Errors::InsufficientScopeError(uma subclasse deConnectionError) expondo#scopee#error_description; os escopos desafiados são tratados como autoritativos para o próximo fluxo de autorização. - PKCE — a autorização se recusa a prosseguir quando o servidor de autorização
não anuncia
code_challenge_methods_supportedincluindoS256.
Vínculo do servidor de autorização (2026-07-28)
- O estado de registro é por servidor de autorização (SEP-2352) — as credenciais
e tokens são armazenados sob a URL do servidor MCP (o registro em uso) e
sob
provider.client_registration_key(issuer), de modo que dois servidores de autorização atrás de um único servidor MCP mantêm cada um seu próprio estado de registro em vez de se substituírem. Semeie credenciais pré-registradas para um servidor de autorização específico comstorage.set_client_info(provider.client_registration_key(issuer), creds). - Credenciais pré-registradas devem nomear seu servidor de autorização — um
client_id(e qualquer segredo associado) é emitido por um servidor de autorização, portanto credenciais que não nomeiam nenhum não são vinculadas a qualquer servidor que a descoberta por acaso encontrar: isso enviaria o segredo registrado com um servidor para outro. Armazene-os comissuer:noClientInfo, ou sobclient_registration_key(issuer); sem isso, a autorização levanta umConnectionErrorinformando isso e nada é enviado a lugar algum. Um ID de Documento de Metadados de Client ID é portátil e não precisa de emissor. - Credenciais pré-registradas têm precedência sobre o que o cliente registrou ele mesmo —
quando um servidor de autorização tem credenciais próprias sob
client_registration_key(issuer), elas são usadas antes de um ID de Documento de Metadados de Client ID (que responde por todos os servidores) e antes de um registro dinâmico no slot, conforme a ordem de prioridade de registro do MCP exige — e um registro dinâmico nunca as sobrescreve. - Um token é mantido para cada servidor de autorização — quando o servidor de autorização muda, o token do anterior é guardado sob sua própria chave em vez de ser descartado, e é retomado se esse servidor voltar a ser o em uso. Um token que este cliente aposentou (um desafio 401 nomeando outro servidor de autorização) é excluído onde quer que esteja guardado, e nenhum token é jamais apresentado a um servidor de autorização diferente daquele que o emitiu.
- Um registro que um fluxo precisa deve chegar ao armazenamento — um backend que não consegue persistir as credenciais em uso levanta uma exceção em vez de retornar uma URL de autorização cujo callback relataria então "Missing PKCE or client info". A cópia por servidor de autorização permanece best-effort.
- Uma atualização apresenta as credenciais no slot em que um host escreve — um segredo rotacionado sob a URL do servidor MCP é usado, não a cópia mais antiga mantida sob a chave própria do servidor de autorização. Autorização e atualização escolhem o mesmo registro.
- Uma atualização e uma troca de código são ambas revalidadas quando a resposta chega — um token de um servidor de autorização que deixou de ser o deste recurso enquanto a solicitação estava em trânsito é descartado em vez de ser armazenado sobre o token do servidor atual ou apresentado, e uma troca de código que chega atrasada não exclui mais a solicitação de autorização pendente que outro fluxo iniciou nesse meio tempo.
- Um registro por solicitação — o
state, o verificador PKCE, o emissor esperado, o client id e a URI de redirecionamento de uma solicitação de autorização são escritos juntos, e ostatedo callback é verificado contra o registro que as outras verificações leem. Dois fluxos compartilhando um mesmo backend de armazenamento não podem mais intercalar suas escritas até que o estado de um fluxo nomeie a solicitação do outro fluxo. - Escopos se acumulam entre etapas — reautorizar após um
desafio
insufficient_scopepede a união dos escopos já solicitados e os que o desafio nomeia, então obterfiles:writenão abre mão defiles:read. "Já solicitado" sobrevive a uma reinicialização: cobre o escopo configurado e o escopo que o servidor de autorização concedeu ao token em mãos, não apenas o que este objeto provedor pediu por último. É limitado a um servidor de autorização, então os escopos de outro servidor nunca são pedidos ao novo. - Um parâmetro de solicitação de autorização aparece uma vez — a
string de consulta própria do endpoint de autorização é mantida (RFC 6749 §3.1), mas um endpoint de
https://as.example/authorize?scope=openidnão adiciona um segundoscopeà solicitação; o mesmo parastate,client_ide os parâmetros PKCE. Todo o resto que o endpoint carrega (tenant,brand, um locale) é mantido. - Apenas um tipo de token que o cliente entende é apresentado —
token_typeé OBRIGATÓRIO (RFC 6749 §5.1) e deve serBearer(§7.1). Um tokenDPoPoumacé recusado onde é emitido e onde é lido de volta, e também é recusada uma resposta que não nomeia nenhum tipo: §5.1 não define padrão, e §7.1 proíbe usar um token cujo tipo o cliente não entende. - Toda URI de redirecionamento é
localhostou HTTPS — MCP 2026-07-28 "Communication Security".http://app.example.com/callbacké recusada quando é configurada e quando uma resposta de registro a registra; HTTP simples na interface de loopback e esquemas de uso privado RFC 8252 (com.example.app:/cb) não são afetados. - Um parâmetro de callback pode aparecer uma vez — RFC 6749 §3.1:
BrowserOAuthrecusa um callback que repeteiss,state,codeou qualquer outro parâmetro em vez de silenciosamente pegar o último valor.
Resultados em Cache (MCP 2026-07-28)
Um servidor 2026-07-28 pode limitar um resultado com ttlMs (por quanto tempo o cliente PODE
considerá-lo fresco, contado a partir do recebimento) e cacheScope ("public" ou
"private"). Os transportes honram ambos para tools/list, prompts/list,
resources/list, resources/templates/list, server/discover e
resources/read; cache_info(:tools) — ou cache_info(:read, uri) — relata
o que foi registrado. Um ttlMs ausente significa 0 em um servidor 2026-07-28, um
cacheScope ausente ou não reconhecido é tratado como "private", e um resultado
resources/read é mantido apenas quando o servidor lhe deu um ttlMs positivo.
Um resultado em cache é servido apenas a uma solicitação que carregaria as mesmas coisas que a solicitação que o produziu carregou:
- a mesma autorização. Uma entrada
"private"é vinculada aoAuthorizationcom o qual sua própria solicitação realmente saiu — o que o adaptador enviou, não o que a fase de resposta depois deixou no ambiente da solicitação — então um token rotacionado, um revogado, ou uma solicitação anônima nunca o lê. - os mesmos parâmetros efetivos. O
_metaque uma solicitação carrega (seurequest_metae seubaggage, a identidade do cliente e capacidades) é parte do que um resultado é vinculado, qualquer que seja seu escopo:"public"permite compartilhamento entre chamadores, não entre parâmetros pelos quais um servidor pode variar sua resposta. Uma solicitação carrega uma cópia desses metadados, tirada quando é construída, então reescrever uma string ou um contêiner que você entregou arequest_metano lugar não muda o que uma solicitação já enviada significa — definarequest_metapara o novo valor em vez disso, e a próxima solicitação o carrega.
Qualquer coisa que o transporte não consiga ler de sua própria configuração torna esses
desconhecíveis, e a reutilização é então desligada em vez de adivinhada. Middleware de
sua autoria instalado através de faraday_config é um desses casos — qualquer coisa com um
hook de solicitação, e qualquer handler carregando um callback seu: ele pode definir um
Authorization ou reescrever o corpo da solicitação, e nenhuma inspeção pode dizer se
ele o faz. Middleware de framework que o transporte consegue ler (o próprio retry do Faraday,
JSON, url-encoded, multipart, logger, follow-redirects, e :authorization
configurado com valores literais) mantém o cache ligado; um :authorization entregue a
um proc mantém entradas públicas mas não privadas, já que a credencial que ele fornece
pode diferir de solicitação para solicitação.
Duas regras seguem o protocolo em vez do cache:
- um
resources/templates/listque um servidor colocou nenhuma dica é buscado novamente em toda chamada, como era antes de os resultados serem armazenados em cache; apenas umttlMspositivo permite que uma lista de modelos seja respondida sem uma solicitação; - um cursor que o servidor rejeita (
-32602) encerra a sequência de páginas à qual pertencia. As páginas armazenadas em cache para essa lista são descartadas, uma lista paginada automaticamente reinicia uma vez a partir da primeira página, e umlist_resources(cursor:)explícito oulist_resource_templates(cursor:)levanta uma exceção — após a primeira página armazenada em cache sob o cursor morto ter sido descartada, para que a próxima chamada realmente busque novamente.
Uma nova busca de uma lista que falha por um motivo transitório pode servir a cópia obsoleta nos transportes HTTP ("Clients MAY serve stale responses if errors occur during re-fetching"); os transportes SSE e stdio levantam uma exceção em vez disso, e uma falha de autorização nunca serve uma cópia obsoleta, então ela chega ao seu fluxo de autenticação.
Um resultado server/discover é julgado pela única regra que todo resultado em cache
usa: uma vez que seu ttlMs tenha decorrido (uma dica ausente, zero, negativa ou malformada
está obsoleta imediatamente) ele é buscado novamente no próximo acesso que precisar dele,
antes que uma capacidade seja julgada, em todo transporte.
Notificações do 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
Gerenciamento de Sessão
Em uma sessão legada (um servidor falando MCP 2025-11-25 ou anterior), ambos os transportes HTTP e Streamable HTTP lidam automaticamente com servidores baseados em sessão:
- Captura de sessão: Extrai
Mcp-Session-Idda resposta de initialize - Persistência de sessão: Inclui o cabeçalho de sessão em solicitações subsequentes
- Encerramento de sessão: Envia solicitação DELETE durante a limpeza
- Retomabilidade (Streamable HTTP, SEP-1699): rastreia IDs de eventos SSE e, quando um
fluxo de resposta é interrompido, retoma via GET com
Last-Event-IDpara que o servidor possa reproduzir mensagens perdidas — honrando a diretivaretry:do servidor
Nenhuma configuração necessária - funciona automaticamente.
Uma sessão moderna (MCP 2026-07-28) não tem nada disso: sem id de sessão, sem DELETE,
sem fluxo GET e sem retomada. Um fluxo de resposta que se rompe perde a
solicitação em andamento, e o cliente a reemite uma vez como uma nova solicitação — veja
Treating the Server as Untrusted para o que
isso significa para tools/call.
Compatibilidade com Servidores
Funciona com qualquer servidor compatível com MCP:
- @modelcontextprotocol/server-filesystem
- @playwright/mcp
- FastMCP
- Servidores personalizados que implementam o protocolo MCP
Exemplo 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 o Servidor como Não Confiável
Um servidor MCP conectado controla tudo o que ele envia a você, e os transportes são escritos sob essa premissa. Você não precisa configurar nada disso — é o comportamento padrão — mas vale a pena saber o que o cliente recusará:
| Entrada controlada pelo peer | O que o cliente faz |
|---|---|
| Corpos de resposta comprimidos (somente Streamable HTTP — o único transporte que solicita gzip) | Descomprimidos incrementalmente, abandonados após max_decompressed_body_bytes (padrão de 64 MiB) |
| Streams SSE | Limite de buffer por conexão; eventos varridos incrementalmente, então um evento não terminado custa memória e CPU limitadas |
Diretivas retry: | Respeitadas, mas com piso para que retry: 0 não possa gerar um loop de reconexão |
| IDs de eventos SSE | Comprimento limitado, somente ASCII imprimível (eles são ecoados em Last-Event-ID) |
Eventos SSE legados endpoint | Devem permanecer na origem da conexão; redirecionamentos fora da origem são recusados, então cabeçalhos de credenciais configurados nunca alcançam outro host |
| URLs de descoberta OAuth de um peer | Devem ser HTTPS e são rejeitadas quando o host é um endereço literal de loopback/privado/link-local. A única exceção é uma pilha local: uma URL de servidor configurada na interface de loopback (localhost, *.localhost, 127.0.0.0/8, ::1) pode ser enviada para uma URL loopback de HTTP simples — nunca para um endereço link-local ou privado. Um desafio recusado falha de forma fechada. Nomes de host não são resolvidos, então um nome público apontando para um endereço privado não é detectado — veja a nota abaixo |
| Respostas JSON-RPC não solicitadas | Descartadas — apenas IDs com uma solicitação pendente são aceitos |
| Solicitações iniciadas pelo servidor | Respostas são limitadas por um orçamento de concorrência em vez de gerar threads ilimitadas |
Valores de esquema pattern | Correspondidos sob um orçamento de tempo para a operação inteira; um timeout falha na validação em vez de passar silenciosamente |
Mensagens de log (notifications/message) | Caracteres de controle escapados e comprimento limitado, então um servidor não pode forjar linhas de log |
| Streams de resposta quebrados (MCP 2026-07-28) | A solicitação perdida é reemitida uma vez como uma nova solicitação, tools/call incluído: a revisão diz que clientes DEVEM reemitir e torna o stream fechado o sinal de cancelamento do servidor. Um servidor que já havia concluído a ferramenta antes do stream quebrar a executa uma segunda vez. Uma rejeição de -32020 HeaderMismatch é igualmente tentada novamente uma vez, após uma atualização de tools/list. Sessões legadas mantêm a regra 2.1.0: solicitações não idempotentes nunca são reenviadas |
Resultados de cacheScope: "private" | Vinculados ao cabeçalho Authorization com o qual a solicitação saiu (SHA-256 de seus bytes) e nunca servidos sob outro. Uma credencial carregada em outro lugar — um cookie, um cabeçalho X-Api-Key, TLS do cliente — não faz parte desse contexto |
Limite conhecido: a verificação OAuth é textual. Um peer ainda pode anunciar um nome de host público cujo registro DNS aponta para dentro da sua rede; detectar isso exige filtragem no momento da resolução na camada HTTP, o que esta gem não faz. Se você executa em um ambiente onde isso importa, restrinja a saída na camada de rede.
Dois padrões relacionados que valem destacar porque afetam seus dados em vez dos dados do peer:
- Payloads nunca são gravados em logs. No nível DEBUG, o cliente registra um resumo de método/id e uma contagem de bytes, não parâmetros de solicitação, corpos de resposta ou blocos SSE brutos. Configurações de servidor são registradas com chaves que contêm credenciais mascaradas.
- Exceções de host não são refletidas ao servidor. Um handler de elicitação, amostragem
ou raízes que gera erro produz uma mensagem de erro JSON-RPC constante; o detalhe
permanece no seu log local. Quando um servidor solicita várias entradas de uma vez (MCP
2026-07-28 solicitações multi-round-trip) e uma delas falha, as respostas que seu
handler já produziu são mantidas em vez de descartadas: o loop de polling de uma tarefa
as envia com seu próximo
tasks/updatee nunca coloca uma solicitação já respondida no seu handler uma segunda vez.
Requisitos
- Ruby >= 3.3.0
- Dependências de runtime:
faraday(~> 2.0) comfaraday-follow_redirectsefaraday-retry, além debase64— todas instaladas automaticamente pela gem
O desenvolvimento usa Ruby 4.0.7 (veja .ruby-version). O CI executa a suíte em 4.0.7
mais o piso suportado, 3.3.
Licença
Disponível como código aberto sob a Licença MIT.
Contribuindo
Relatórios de bugs e pull requests são bem-vindos em https://github.com/simonx1/ruby-mcp-client.