mcp.cr v1.0.0
mcp
A batteries-included Model Context Protocol tools SDK for Crystal.
Register tools on a server, serve them over stdio or Streamable HTTP, and let any MCP client (Claude Code, Cursor, …) discover and call them. The protocol — JSON-RPC 2.0 framing, the capability handshake, content envelopes, pagination, progress streaming — is handled for you. You write the tool; the SDK does the rest.
require "mcp"
server = MCP::Server.new(name: "demo", version: "1.0.0")
server.tool("greet", description: "Greets someone", schema: {
type: "object", properties: {name: {type: "string"}}, required: ["name"],
}) do |args, _progress|
"Hello, #{MCP::Arguments.new(args).require_string("name")}!"
end
MCP::Stdio.new(server).start
- Zero runtime dependencies — Crystal standard library only.
- MCP revision
2025-06-18—title/annotations/outputSchema, structured content, multi-block results,tools/listpagination,ping,logging/setLevel. - Two transports — newline-delimited JSON-RPC over stdio, and Streamable HTTP (synchronous JSON or an SSE stream carrying progress events).
- Ergonomic — a one-line
server.toolDSL, typedMCP::Argumentsaccessors, and a richMCP::ToolResult(text / image / audio / resource blocks + machine-readable structured data).
Contents
- Installation
- Quick start
- Defining tools
- Reading arguments
- Returning results
- Reporting errors
- Reporting progress
- Transports
- What the SDK handles for you
- Connecting a client
- API reference
- Scope & roadmap
- Development
Installation
Add the dependency to your shard.yml:
dependencies:
mcp:
github: mnemodoc/mcp.cr
version: ~> 1.0
Then:
shards install
Quick start
A server is an MCP::Server with one or more registered tools, served through a transport:
require "mcp"
server = MCP::Server.new(name: "weather", version: "1.0.0")
server.tool("get_weather",
description: "Returns the current weather for a city",
annotations: MCP::ToolAnnotations.new(read_only_hint: true),
schema: {
type: "object",
properties: {city: {type: "string", description: "City name"}},
required: ["city"],
}) do |args, _progress|
city = MCP::Arguments.new(args).require_string("city")
"It is 22°C and sunny in #{city}."
end
# Serve over stdio (the transport Claude Code speaks).
MCP::Stdio.new(server).start
Compile it to a binary (crystal build weather.cr) and point your client at the executable — see Connecting a client.
Defining tools
MCP::Server#tool registers a tool. Only name, description and schema are required; title, annotations and output_schema are optional:
server.tool("search_docs",
# Human-readable, shown to the model.
description: "Full-text search across the indexed documents",
# Optional display name for UIs (falls back to the tool name).
title: "Document Search",
# Behavioural hints (all optional). Clients treat these as untrusted unless
# the server is trusted — they inform UX, not enforcement.
annotations: MCP::ToolAnnotations.new(
read_only_hint: true, # does not modify its environment
idempotent_hint: true, # repeated calls have the same effect
),
# JSON Schema of the arguments. Pass a NamedTuple, a Hash, or a JSON::Any —
# it is serialised for you.
schema: {
type: "object",
properties: {
query: {type: "string", description: "Search terms"},
limit: {type: "integer", description: "Max results (default 10)"},
},
required: ["query"],
},
# Optional JSON Schema describing structuredContent (see Returning results).
output_schema: {
type: "object",
properties: {hits: {type: "array", items: {type: "object"}}},
}) do |args, progress|
# … tool body …
"results"
end
The handler block receives (args : Hash(String, JSON::Any), progress : MCP::Progress?) and returns a String or an MCP::ToolResult.
Tool annotations
| Field | Meaning | Default (client-side) |
|---|---|---|
title |
Display name | — |
read_only_hint |
Does not modify its environment | false |
destructive_hint |
May perform destructive updates | true |
idempotent_hint |
Repeated calls have no additional effect | false |
open_world_hint |
Interacts with an open/external world | true |
Only the fields you set are emitted.
Reading arguments
The handler is handed a raw Hash(String, JSON::Any). Wrap it in MCP::Arguments for typed, opt-in access:
server.tool("search_docs", description: "…", schema: { … }) do |args, _progress|
a = MCP::Arguments.new(args)
query = a.require_string("query") # String — raises MCP::ToolError if absent/not a string
limit = a.int?("limit") || 10 # Int64? — nil when absent or not an integer
fuzzy = a.bool?("fuzzy") # Bool?
tags = a.string_array?("tags") # Array(String)? — nil unless every element is a string
# …
end
| Method | Returns | On missing / wrong type |
|---|---|---|
require_string(key) |
String |
raises MCP::ToolError |
string?(key) |
String? |
nil |
int?(key) |
Int64? |
nil |
bool?(key) |
Bool? |
nil |
string_array?(key) |
Array(String)? |
nil |
raw |
Hash(String, JSON::Any) |
the underlying hash |
The ? accessors never raise — a wrong type is treated as absent.
Returning results
The simplest result is a String, auto-wrapped into a single text block:
server.tool("ping_tool", description: "…", schema: {type: "object"}) do |_args, _progress|
"pong"
end
For anything richer, return an MCP::ToolResult. It carries an ordered list of content blocks and/or a structured payload:
server.tool("render", description: "…", schema: {type: "object"}) do |_args, _progress|
MCP::ToolResult.new(content: [
MCP::TextContent.new("Here is the chart you asked for:").as(MCP::Content),
MCP::ImageContent.new(data: base64_png, mime_type: "image/png").as(MCP::Content),
])
end
Structured content
When a tool returns machine-readable data, put it in structured_content. The SDK also serialises it into a text block automatically (the spec-recommended fallback for clients that only read text), so you get both surfaces for free:
server.tool("stats", description: "…",
output_schema: {type: "object", properties: {count: {type: "integer"}}},
schema: {type: "object"}) do |_args, _progress|
MCP::ToolResult.new(
structured_content: JSON::Any.new({"count" => JSON::Any.new(42_i64)} of String => JSON::Any),
)
end
Declare an output_schema on the tool so clients know the shape. The SDK does not validate structured_content against it (that would require a JSON Schema dependency) — it trusts the tool and publishes the schema for the client to check.
Content block types
| Block | Constructor | MCP shape |
|---|---|---|
| Text | MCP::TextContent.new(text) |
{type: "text", text} |
| Image | MCP::ImageContent.new(data, mime_type) |
{type: "image", data, mimeType} |
| Audio | MCP::AudioContent.new(data, mime_type) |
{type: "audio", data, mimeType} |
| Resource link | MCP::ResourceLink.new(uri, name, description:, mime_type:) |
{type: "resource_link", …} |
| Embedded resource | MCP::EmbeddedResource.new(uri, text:, blob:, mime_type:) |
{type: "resource", resource: {…}} |
Every block also accepts optional annotations: MCP::ContentAnnotations.new(audience:, priority:, last_modified:).
Reporting errors
Raise MCP::ToolError to signal a business failure. The SDK maps it to a result with isError: true and surfaces your message to the client:
server.tool("fetch", description: "…", schema: {type: "object"}) do |args, _progress|
url = MCP::Arguments.new(args).require_string("url")
raise MCP::ToolError.new("refusing to fetch a non-https URL") unless url.starts_with?("https://")
fetch(url)
end
Any other exception is caught too: it is logged and reported to the client as a generic "internal error" (internals are never leaked). Protocol-level problems — unknown method, missing tool name — are returned as JSON-RPC errors (-32601 / -32602) rather than tool errors.
Reporting progress
Long-running tools can stream progress while they work. The second handler argument is an MCP::Progress? — non-nil only when the client requested streaming (HTTP with Accept: text/event-stream and a progressToken):
server.tool("reindex", description: "…", schema: {type: "object"}) do |_args, progress|
total = files.size
files.each_with_index do |file, i|
index(file)
progress.try &.report(progress: i + 1, total: total, message: file)
end
"indexed #{total} files"
end
#report(progress:, total: nil, message: nil) emits a notifications/progress event. A disconnected client is handled silently — reporting never interrupts the tool. Over stdio (which is request/response) progress is always nil, so the try &. guard is all you need.
Transports
Both transports take the server and expose the same lifecycle:
# stdio — newline-delimited JSON-RPC on STDIN/STDOUT (or any IO pair).
transport = MCP::Stdio.new(server) # MCP::Stdio.new(server, input, output)
# …or Streamable HTTP — POST /mcp (JSON or SSE) and GET /health.
transport = MCP::Http.new(server, host: "127.0.0.1", port: 8765)
transport.on_ready { notify_supervisor_ready } # after binding / before the read loop
transport.on_stopping { flush_metrics } # after in-flight requests drain
transport.start # blocks until #stop
Call transport.stop (e.g. from a signal trap in your application — the SDK never traps signals) for a graceful shutdown: it stops accepting work, lets in-flight requests finish, fires on_stopping, and returns from start.
The HTTP transport is unauthenticated by design (bind it to loopback, or put it behind a proxy). CORS is opt-in via cors_origin:. Request bodies are capped at MCP::Http::MAX_BODY_BYTES (4 MiB) with a 413 response.
What the SDK handles for you
You never write JSON-RPC. The shared handler answers, on every transport:
initialize— advertisesprotocolVersion2025-06-18(echoing the client's version when it sends one),serverInfo, and capabilities derived from what you registered (toolsonce a tool exists, pluslogging).tools/list— paginated with an opaque cursor (MCP::Handler::PAGE_SIZE, 50 per page); each descriptor carries your title / annotations / schemas.tools/call— argument routing, your handler, and the content envelope.ping— liveness.logging/setLevel— maps the RFC 5424 level to themcplog source.notifications/initialized— acknowledged.
Connecting a client
Build your server to a binary and register it. For Claude Code (stdio):
{
"mcpServers": {
"weather": {
"command": "/usr/local/bin/weather",
"args": []
}
}
}
For an HTTP client, run MCP::Http.new(server, host:, port:).start and point the client at http://host:port/mcp.
API reference
| Type | Purpose |
|---|---|
MCP::Server |
Tool registry + dispatch + derived capabilities |
MCP::Tool / MCP::ToolAnnotations |
A registered tool descriptor and its behavioural hints |
MCP::Arguments |
Typed, opt-in accessors over the raw argument hash |
MCP::ToolResult |
A tool's result: content blocks + structured content + is_error? |
MCP::Content & subtypes |
TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource, ContentAnnotations |
MCP::ToolError |
Raise to return an isError result |
MCP::Progress |
#report progress events from a streaming tool |
MCP::Stdio / MCP::Http |
Transports (lifecycle callbacks, graceful drain) |
MCP::Handler |
The shared JSON-RPC/MCP request handler (used by transports) |
MCP::Session |
Per-connection context threaded into the handler |
MCP::VERSION / MCP::PROTOCOL_VERSION |
"1.0.0" / "2025-06-18" |
Scope & roadmap
mcp is a tools SDK. The following are intentionally not in 1.0:
- Resources and prompts.
- Server-initiated notifications —
tools/list_changed,notifications/message, request cancellation.
These need a persistent server→client channel, which the request/response 1.0 does not build. The public API is shaped so they can be added in a backward-compatible 1.x: capabilities are derived (a new feature appears in the handshake on its own), the handler routes by method (new methods are new branches), the MCP::Content blocks are already shared with resources, and MCP::Session reserves the per-connection seam for push.
There is also no server-side JSON Schema validation of arguments or output — the schemas are published for the client to enforce, keeping the shard dependency-free.
Development
mise dev:deps # shards install
mise dev:spec # run the spec suite (Spectator)
mise dev:ameba # static analysis
mise dev:format # format src/, spec/ and examples/
mise dev:check # build-check + ameba + spec
Runnable examples live in examples/. Contributions are welcome — see CONTRIBUTING.md for the guidelines (dependency-free, test-driven, additive within 1.x).
License
MIT © Nicolas Rodriguez
mcp.cr
- 0
- 0
- 0
- 0
- 2
- about 7 hours ago
- June 18, 2026
MIT License
Thu, 18 Jun 2026 03:56:33 GMT