Network-agnostic JSON-RPC 2.0 server that exposes Ruby modules, classes, and instances as callable RPC endpoints. UltimateJsonRpc handles JSON-RPC message parsing, method dispatch, and response serialization — transport (HTTP, WebSocket, stdio, TCP, etc.) is the caller's responsibility.
Add to your Gemfile:
gem "ultimate_json_rpc"require "ultimate_json_rpc"
# Define your service
module Calculator
def self.add(a, b) = a + b
def self.divide(a, b) = a.to_f / b
end
# Create a server and expose objects
server = UltimateJsonRpc::Server.new(name: "My API", version: "1.0")
server.expose(Calculator, descriptions: { add: "Add two numbers" })
server.expose(some_instance, namespace: "greeter")
# Handle a JSON-RPC request string, get a JSON-RPC response string
request = '{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}'
response = server.handle(request)
# => '{"jsonrpc":"2.0","result":5,"id":1}'server.expose_method("double", description: "Double a number") { |n| n * 2 }
server.expose_method("greet") { |name:, greeting: "Hello"| "#{greeting}, #{name}!" }All setup methods return self, and the server is callable via to_proc:
server = UltimateJsonRpc::Server.new
.expose(Calculator, namespace: "calc")
.expose_method("ping") { "pong" }
.use { |req, next_call| next_call.call }
# Use to_proc for mapping over requests
responses = json_requests.map(&server)Declare return types for discovery metadata (purely informational, no runtime enforcement):
server.expose_method("add", returns: { "type" => "number" }) { |a, b| a + b }
server.expose(Calculator, returns: { add: { "type" => "number" } })Built-in rpc.discover returns an OpenRPC 1.3.2-compatible document:
request = '{"jsonrpc":"2.0","method":"rpc.discover","id":1}'
server.handle(request)
# => '{"jsonrpc":"2.0","result":{"openrpc":"1.3.2","info":{"title":"My API","version":"1.0"},"methods":[...]},"id":1}'Add cross-cutting concerns like logging, auth, or rate limiting:
server.use do |request, next_call|
puts "Calling #{request.method_name}"
result = next_call.call
puts "Done"
result
end
# Reject unauthorized calls
server.use do |request, next_call|
raise UltimateJsonRpc::ApplicationError.new(code: 403, message: "Forbidden") unless authorized?(request)
next_call.call
endMiddleware runs in registration order (first registered = outermost wrapper).
Target specific methods or namespaces with only: / except:, supporting glob patterns:
# Only run for admin namespace methods
server.use(only: ["admin.*"]) do |request, next_call|
raise UltimateJsonRpc::ApplicationError.new(code: 403, message: "Forbidden") unless admin?(request)
next_call.call
end
# Run for everything except health checks
server.use(except: ["ping", "health"]) do |request, next_call|
log(request)
next_call.call
endMiddleware can pass data to handlers via request.context:
server.use do |request, next_call|
request.context[:user] = authenticate(request)
next_call.call
endRead-only lifecycle hooks for observability — they can't alter responses or swallow errors:
server.on(:request) { |request| puts "→ #{request.method_name}" }
server.on(:response) { |request, result, duration| puts "← #{request.method_name} (#{duration}s)" }
server.on(:error) { |request, error, duration| log_error(error, method: request.method_name) }Hook errors are rescued and logged via Kernel.warn, never breaking dispatch.
Swap the JSON encoder/decoder (defaults to stdlib JSON):
require "oj"
Oj.mimic_JSON
server = UltimateJsonRpc::Server.new(json: Oj)Any object responding to parse(string) and generate(object) works.
Opt-in parallel processing for batch requests:
server = UltimateJsonRpc::Server.new(concurrent_batches: true)
# Batch items are processed in parallel using threads
# Respects max_batch_size and request timeoutSet a per-server timeout (in seconds) to prevent slow handlers from blocking:
server = UltimateJsonRpc::Server.new(timeout: 5)
# Handlers exceeding 5 seconds receive a -32001 "Request timeout" errorDeclare parameter schemas to validate incoming params before dispatch:
server.expose_method("add", params_schema: {
a: { "type" => "number" },
b: { "type" => "number" }
}) { |a, b| a + b }
server.expose(Calculator, params_schema: {
add: { left: { "type" => "number" }, right: { "type" => "number" } }
})Supported JSON Schema keywords: type (string, number, integer, boolean, array, object, null) and enum. On mismatch, returns -32602 Invalid params with a descriptive message. Schemas also appear in rpc.discover output.
Mark methods as deprecated in discovery metadata (purely informational, no runtime enforcement):
server.expose_method("old_add", deprecated: true) { |a, b| a + b }
server.expose_method("old_multiply", deprecated: "Use multiply_v2 instead") { |a, b| a * b }
server.expose(Calculator, deprecated: { add: "Use add_v2" })Deprecated methods still work normally but appear flagged in rpc.discover output.
Collect per-method timing statistics:
require "ultimate_json_rpc/extras/profiler"
profiler = UltimateJsonRpc::Extras::Profiler.new(server)
# ... handle requests ...
profiler["add"] # => {count: 150, min: 0.0001, max: 0.05, avg: 0.002, p50: ..., p95: ..., p99: ...}
profiler.stats # => all methods
profiler.reset # clear dataLogger-agnostic observability:
require "ultimate_json_rpc/extras/logging"
UltimateJsonRpc::Extras::Logging.new(server, Logger.new($stdout)) # logs at INFO by default
UltimateJsonRpc::Extras::Logging.new(server, Rails.logger, level: :debug) # custom levelSuccessful responses log at the specified level; errors always log at ERROR.
Sliding-window rate limiting with per-caller keying:
require "ultimate_json_rpc/extras/rate_limit"
UltimateJsonRpc::Extras::RateLimiter.new(server, max: 100, period: 60) # 100 req/min global
UltimateJsonRpc::Extras::RateLimiter.new(server, max: 10, period: 60, only: ["expensive.*"]) # per-method
UltimateJsonRpc::Extras::RateLimiter.new(server, max: 50, period: 60, key: :api_key) # per-caller via contextDeclarative access control with glob patterns:
server.authorize("admin.*") { |req| req.context[:role] == :admin }
server.authorize("users.delete", code: 1001, message: "Insufficient permissions") do |req|
req.context[:permissions]&.include?("delete")
endReturns ApplicationError (code 403 by default) when the block returns falsy.
Register application-specific error codes for discovery:
server.register_error(code: 42, message: "InsufficientFunds", description: "Account balance too low")
server.register_error(code: 43, message: "AccountLocked")Registered errors appear in rpc.discover under components.errors, so consumers know which error codes to expect.
Raise ApplicationError for custom error codes, or ServerError for implementation-defined errors:
raise UltimateJsonRpc::ApplicationError.new(code: 42, message: "Custom error", data: { "detail" => "info" })
raise UltimateJsonRpc::ServerError.new(code: -32_001, message: "Server shutting down")Ruby's ArgumentError automatically maps to JSON-RPC Invalid params (-32602). All other exceptions become Internal error (-32603).
By default, internal error details (exception messages) are hidden from clients:
server = UltimateJsonRpc::Server.new # expose_errors: false (default)
# Internal errors return generic "Internal server error" in the data field
server = UltimateJsonRpc::Server.new(expose_errors: true)
# Internal errors include the actual exception message in the data fieldApplicationError, ServerError, and MethodNotFound always expose their details regardless of this setting, since those are intentionally raised by your code.
Protocol-level errors (InvalidRequest, InvalidParams, ArgumentError, and unhandled exceptions) are gated by expose_errors. InvalidParams is a JSON-RPC schema check (type mismatch, enum violation) — for application-level business validation, raise ApplicationError instead.
Lock the server after setup to prevent accidental modifications:
server.freeze # expose, expose_method, use will now raise FrozenError
server.handle(request) # still works- JSON-RPC 2.0 compliant (requests, notifications, batch requests)
- Expose modules — singleton methods become RPC methods
- Expose instances — public methods become RPC methods
- Expose blocks —
server.expose_method("name") { ... }for standalone methods - Namespacing —
server.expose(obj, namespace: "ns")makes methods callable asns.method_name - Method filtering —
server.expose(obj, only: [:add])orexcept: [:internal] - Method descriptions —
descriptions:hash ordescription:keyword for discovery - Return type annotations —
returns:hash or keyword for discovery metadata - Service metadata —
name:andversion:appear inrpc.discoverresponses - Positional and keyword params — arrays map to positional args, objects map to keyword args
- Service discovery — built-in
rpc.discoverreturns OpenRPC 1.3.2-compatible schema - Middleware —
server.use { |request, next_call| ... }for cross-cutting concerns - Scoped middleware —
only:/except:with glob patterns to target specific methods or namespaces - Instrumentation hooks —
on(:request),on(:response),on(:error)for read-only observability - Parameter validation —
params_schema:with JSON Schema types and enum constraints - Request timeout —
Server.new(timeout: 5)prevents slow handlers from blocking - Method deprecation — mark methods as deprecated in discovery metadata
- Error catalog —
register_errordocuments app error codes inrpc.discover - Authorization —
authorize("admin.*") { |req| ... }for declarative access control - Error handling — standard JSON-RPC error codes,
ApplicationError, andServerError - Error visibility —
expose_errors: trueto include exception messages in error responses - Rate limiting — sliding-window
rate_limit(max:, period:)with per-caller keying - Security — dangerous methods (eval, system, exec, etc.) are automatically blocked
- Chainable API — all setup methods return
self - Callable —
to_procenablesrequests.map(&server) - Rack adapter —
UltimateJsonRpc::Transport::Rack.new(server)for instant HTTP deployment - MCP adapter —
UltimateJsonRpc::Extras::MCP.new(server).runfor AI tool integration (Claude, Cursor, etc.) - stdio adapter —
UltimateJsonRpc::Transport::Stdio.new(server).runfor CLI/MCP-style integrations - Test helpers —
rpc_call,assert_rpc_success,assert_rpc_errorfor cleaner tests - Concurrent batches —
concurrent_batches: trueprocesses batch items in parallel - Custom JSON —
json: Ojto swap the JSON encoder/decoder - Batch size limit —
max_batch_size: 100(default) prevents oversized batch requests - Freezable —
server.freezelocks configuration after setup
UltimateJsonRpc is transport-agnostic. Here are common setups:
Use the built-in Rack adapter:
# config.ru
require "ultimate_json_rpc"
require "ultimate_json_rpc/transport/rack"
server = UltimateJsonRpc::Server.new(name: "My API")
server.expose(Calculator)
run UltimateJsonRpc::Transport::Rack.new(server)UltimateJsonRpc::Transport::Rack handles Content-Type, returns 200 for responses, 204 for notifications, and 405 for non-POST requests.
Expose methods as AI tools via MCP:
require "ultimate_json_rpc/extras/mcp"
server = UltimateJsonRpc::Server.new(name: "My Tools", version: "1.0")
server.expose(Calculator, descriptions: { add: "Add two numbers" })
UltimateJsonRpc::Extras::MCP.new(server).runUltimateJsonRpc::Extras::MCP handles the MCP lifecycle (initialize, tools/list, tools/call), maps methods to tool definitions with input schemas, and runs over stdio for integration with Claude, Cursor, and other AI tools.
Use the WebSocket adapter with any Rack-compatible library (e.g., faye-websocket):
require "ultimate_json_rpc/transport/websocket"
ws_handler = UltimateJsonRpc::Transport::WebSocket.new(server)
# In your Rack app:
ws = Faye::WebSocket.new(env)
ws_handler.call(env, ws)See examples/websocket_server.ru for a complete runnable example.
Use the built-in TCP adapter for internal microservices:
require "ultimate_json_rpc/transport/tcp"
server = UltimateJsonRpc::Server.new
server.expose(Calculator)
UltimateJsonRpc::Transport::TCP.new(server, port: 4000).runUltimateJsonRpc::Transport::TCP accepts newline-delimited JSON-RPC over TCP, handles multiple concurrent clients via threads, and supports SIGINT/SIGTERM for graceful shutdown.
Use the built-in stdio adapter:
require "ultimate_json_rpc"
require "ultimate_json_rpc/transport/stdio"
server = UltimateJsonRpc::Server.new
server.expose(Calculator)
UltimateJsonRpc::Transport::Stdio.new(server).runUltimateJsonRpc::Transport::Stdio reads newline-delimited JSON-RPC from stdin, writes responses to stdout, skips empty lines, and handles SIGINT/SIGTERM for graceful shutdown.
If you've already parsed the JSON (e.g. from a WebSocket frame), use handle_parsed to skip the parse step:
data = JSON.parse(raw_json)
response = server.handle_parsed(data)Capture exchanges for replay and regression testing:
require "ultimate_json_rpc/extras/recorder"
recorder = UltimateJsonRpc::Extras::Recorder.new(server)
server.handle(request_json)
recorder.exchanges # => [{"method" => "add", "params" => [2, 3], "result" => 5, "duration" => 0.001}]
# Write JSONL to a file
recorder = UltimateJsonRpc::Extras::Recorder.new(server, output: File.open("exchanges.jsonl", "a"))UltimateJsonRpc ships with optional test helpers for cleaner assertions:
require "ultimate_json_rpc/extras/test_helpers"
class MyTest < Minitest::Test
include UltimateJsonRpc::Extras::TestHelpers
def test_addition
response = rpc_call(server, "add", params: [2, 3])
assert_rpc_success response, 5
end
def test_method_not_found
response = rpc_call(server, "nonexistent")
assert_rpc_error response, code: -32_601
end
def test_notification
assert_rpc_notification server, "add", params: [1, 2]
end
endbin/setup # Install dependencies
rake test # Run tests
rake rubocop # Run linter
rake # Run both
bin/console # Interactive consoleBug reports and pull requests are welcome on GitHub at https://github.com/rubakas/ultimate_json_rpc.