mcpc.cr v0.2.0
mcpc.cr
An MCP client as a Crystal shard. It talks to Model Context Protocol servers over stdio or Streamable HTTP, and hides which revision of the protocol the server speaks.
Think of it as a travel adaptor: your app has one plug, and the shard works out which socket is in the wall.
Install
Add the shard to shard.yml, then run shards install.
dependencies:
mcpc:
github: nogginly/mcpc.cr
It needs Crystal 1.20 or later and nothing beyond the standard library. It is tested on Linux, macOS and Windows.
Quick start
require "mcpc"
client = MCPC::Client.stdio("npx", ["-y", "@modelcontextprotocol/server-everything"])
client.connect
client.list_tools.each { |tool| puts tool.name }
result = client.call_tool("echo", {"message" => "hello"})
puts result.text
client.close
For a remote server, swap the first line. The rest stays the same.
client = MCPC::Client.http("https://example.com/mcp", {"Authorization" => "Bearer #{token}"})
A runnable version is in samples/everything.cr.
How a session goes
flowchart TD
A[Client.stdio or Client.http] --> B[on_event / on_elicitation]
B --> C[connect]
C --> D{{Server answers server/discover?}}
D -- yes --> E[Stateless era, 2026-07-28]
D -- no --> F[Handshake era, 2025-11-25 and older]
E --> G[list, call, read, subscribe]
F --> G
G --> H[close]
style E stroke:#2a9d4f,stroke-width:3px
connect returns a ServerInfo: the revision in use, the server's name, its capabilities and its instructions. Your code is the same on both branches.
What you can ask a server
| Method | Returns |
|---|---|
list_tools |
Array(Tool), every page |
call_tool(name, arguments) |
CallToolResult |
list_resources |
Array(Resource) |
list_resource_templates |
Array(ResourceTemplate) |
read_resource(uri) |
Array(ResourceContents) |
list_prompts |
Array(Prompt) |
get_prompt(name, arguments) |
PromptResult |
subscribe(filter) |
Subscription |
request(method, params) |
raw JSON::Any, for anything not listed above |
Each call blocks the calling fiber until the answer arrives. Run calls in separate fibers to make them concurrent.
Tool results
A tool that fails is not an exception. It returns a result with error? set, so a model can read the reason and try again.
result = client.call_tool("get_weather", {"city" => "Atlantis"})
if result.error?
puts "tool failed: #{result.text}"
else
result.content.each do |block|
case block
when MCPC::TextContent then puts block.text
when MCPC::ImageContent then save(block.data, block.mime_type)
when MCPC::ResourceLink then puts "see #{block.uri}"
when MCPC::EmbeddedResource then puts block.resource.text
end
end
puts result.structured_content
end
Events
Register one handler. It receives everything the server or the connection reports unasked.
client.on_event do |event|
case event
when MCPC::Event::Progress then puts "#{event.token}: #{event.progress} of #{event.total}"
when MCPC::Event::LogMessage then puts "[#{event.level}] #{event.data}"
when MCPC::Event::ToolsChanged then tools = client.list_tools
when MCPC::Event::PromptsChanged then prompts = client.list_prompts
when MCPC::Event::ResourcesChanged then resources = client.list_resources
when MCPC::Event::ResourceUpdated then client.read_resource(event.uri)
when MCPC::Event::SubscriptionClosed then puts "subscription ended: #{event.reason}"
when MCPC::Event::Disconnected then puts "connection lost: #{event.reason}"
end
end
Events arrive in order on a fiber of their own, so the handler may call the client, as above.
Per-request options
cancellation = MCPC::Cancellation.new
options = MCPC::RequestOptions.new(
timeout: 30.seconds, # instead of Config#request_timeout
progress_token: "import-1", # ask for Event::Progress carrying this token
cancellation: cancellation, # lets another fiber give up on the call
)
spawn { client.call_tool("import", {"file" => "big.csv"}, options) }
cancellation.cancel("user pressed stop") # the call raises MCPC::CancelledError
A call that times out or is cancelled is also cancelled on the server.
Answering a server's questions
A tool may need something from the user halfway through, such as a confirmation. Register a handler before connect; servers are told the client can answer only if one exists by then.
client.on_elicitation do |request|
puts request.message # written by the server: untrusted
puts request.requested_schema # JSON Schema of the fields wanted
request.accept({"guests" => 4}) # or request.decline, or request.cancel
end
The two protocol eras ask in different ways. The handler is the same for both.
Change notifications
filter = MCPC::SubscriptionFilter.new(tools: true, resource_uris: ["file:///notes/todo.md"])
subscription = client.subscribe(filter)
subscription.filter # what the server agreed to send
# ... Event::ToolsChanged and Event::ResourceUpdated now arrive at on_event ...
subscription.close
Handshake-era servers send list changes without being asked, so you may see those events before subscribing.
Errors
Everything raised is an MCPC::Error.
| Error | Meaning |
|---|---|
RPCError |
The server refused the request. Has code and data. |
TimeoutError |
No answer in time. |
CancelledError |
Your Cancellation was cancelled. |
InputRequiredError |
The server wants input and there is no handler, or it kept asking. |
ProtocolError |
The server sent something malformed. |
UnsupportedVersionError |
Client and server share no protocol revision. |
TransportError |
The server could not be reached. |
ConnectionClosedError |
The connection ended mid-request. A TransportError. |
HTTPError |
An HTTP refusal such as 401. Has status and body. A TransportError. |
SessionExpiredError |
The HTTP session is gone; make a new client. An HTTPError. |
NotConnectedError |
Called before connect or after close. |
Configuration
config = MCPC::Config.new
config.client_name = "my-app"
config.client_version = "1.2.0"
config.request_timeout = 20.seconds # default 60 s
config.probe_timeout = 2.seconds # default 5 s; see below
config.max_input_rounds = 4 # default 8
config.log_level = "warning" # ask for Event::LogMessage at this level and above
client = MCPC::Client.stdio("my-server", config: config)
probe_timeout is how long connect waits for an older server to react to the modern greeting before falling back. A stdio server that ignores unknown requests costs you that long, once.
To send a stdio server's stderr somewhere other than your own, build the transport yourself:
transport = MCPC::Transport::Stdio.new("my-server", stderr: File.open("server.log", "a"))
client = MCPC::Client.new(transport)
Checking a server: mcpc-probe
The shard comes with a command-line tool that connects to a server and reports what it advertises. Use it to find out whether a server works with this client before writing any code.
shards build
bin/mcpc-probe https://example.com/mcp
bin/mcpc-probe -- npx -y @modelcontextprotocol/server-everything
Target npx -y @modelcontextprotocol/server-everything
Result connected
Server mcp-servers/everything 1.0.0
Protocol 2025-11-25 (handshake era)
Capabilities
tools {"listChanged":true}
prompts {"listChanged":true}
...
Tools (13)
echo Echoes back the input string
get-sum Returns the sum of two numbers
...
It also lists resources, resource templates and prompts, and prints the server's instructions.
| Option | Does |
|---|---|
-H, --header "Name: value" |
Sends an HTTP header |
--header-env NAME=VAR |
Sends header NAME with the value of environment variable VAR |
--bearer-env VAR |
Sends Authorization: Bearer with the token in VAR |
--timeout SECONDS |
Gives up on a request after this long; default 30 |
--json |
Reports as JSON, for scripts |
Prefer the -env options for secrets: a value typed on the command line is visible to other processes and lands in your shell history.
MY_TOKEN=... bin/mcpc-probe --bearer-env MY_TOKEN https://example.com/mcp
| Exit status | Meaning |
|---|---|
| 0 | Connected, and everything the server advertises could be read |
| 1 | Could not connect, or an advertised list could not be read |
| 2 | The command line was wrong |
- Put a server command after
--, so that its flags are not taken for the probe's own. - A server that requires the OAuth flow will be reported as refusing the credentials. The shard has no OAuth yet; a static token or API key works.
- From a project that depends on the shard, build the probe with
crystal build lib/mcpc/apps/probe/main.cr -o bin/mcpc-probe.
Windows
A server started through a batch file, such as npx, must be launched through the command interpreter, because Windows cannot run a batch file directly.
client = MCPC::Client.stdio("cmd.exe", ["/c", "npx", "-y", "@modelcontextprotocol/server-everything"])
bin\mcpc-probe -- cmd.exe /c npx -y @modelcontextprotocol/server-everything
Trust
Tool descriptions, prompts, annotations, ServerInfo#instructions and elicitation messages are written by the server. Show them or pass them to a model as untrusted input. The shard never acts on them.
What it covers
| Protocol revisions | 2026-07-28 (stateless), and 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 (handshake) |
| Transports | stdio, Streamable HTTP |
| Server features | tools, resources, resource templates, prompts, logging, progress, cancellation, subscriptions |
| Client features | form-mode elicitation |
Not built yet: OAuth, the deprecated HTTP+SSE transport, sampling, roots, URL-mode elicitation and the tasks extension. DESIGN.md lists the rest and says why.
More
- DESIGN.md: how it works inside.
- DEVELOPMENT.md: building, testing and contributing.
mcpc.cr
- 0
- 0
- 0
- 0
- 1
- about 2 hours ago
- November 12, 2026
Mozilla Public License 2.0
Fri, 13 Nov 2026 05:18:15 GMT