Skip to content

Res mcp#1936

Draft
tomaszpatrzek wants to merge 32 commits into
masterfrom
res-mcp
Draft

Res mcp#1936
tomaszpatrzek wants to merge 32 commits into
masterfrom
res-mcp

Conversation

@tomaszpatrzek

@tomaszpatrzek tomaszpatrzek commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

New contrib gem exposing RubyEventStore as an MCP (Model Context Protocol) server. Allows AI assistants
(Claude, etc.) to inspect the event store directly — without a terminal, without writing SQL, without leaving
the conversation.

How it works

The server runs as a stdio process started by the MCP client (Claude Desktop, Claude Code). It reads JSON-RPC
requests from stdin and writes responses to stdout. The MCP client decides which tool to call based on the
user's question.

Claude ←→ MCP protocol (stdio JSON-RPC) ←→ res-mcp ←→ Rails.configuration.event_store

Usage

Add to your Gemfile:

gem "ruby_event_store-mcp"

Configure in your MCP client. Add the snippet to the appropriate config file:

Claude Code

  • Project-local: .claude/settings.json
  • Global: ~/.claude/settings.json

Claude Desktop

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
{
   "mcpServers": {
     "res": {
       "command": "bundle",
       "args": ["exec", "res-mcp"],
       "cwd": "/path/to/rails/app"
     }
   }
 }

After that Claude can answer questions like:

  • "What happened to order 123? Show me the causation tree."
  • "How many events do we have and what types?"
  • "Find all OrderShipped events from last week."
  • "Show me the full lifecycle of Order abc-123."
  • "What are the 20 most recent events across all streams?"

Available tools

  • recent — most recent events across all streams (default: 20, newest first)
  • stream_show — event count, version, first/last event for a stream
  • stream_events — events in a stream with filters (type, time range, limit)
  • event_show — full event details: data, metadata, timestamps
  • event_streams — all streams an event was published or linked to
  • aggregate_history — full event history of an aggregate instance by type and ID
  • search — search across all streams by type, time range, stream
  • stats — total event count and unique event types
  • trace — causation tree for all events sharing a correlation ID

Architecture

  • No external dependencies — MCP protocol implemented from scratch (~80 lines of JSON-RPC over stdio)
  • Tools use only public event_store.* API — adapter-agnostic (InMemory, AR, Sequel, ROM)
  • Each tool is an independent class: name, schema, call(event_store, args) → String
  • MCP.server(event_store) builds a fully configured server with all tools registered
  • Full mutation test coverage with Mutant (all surviving mutations are documented equivalent mutations)

tomaszpatrzek and others added 30 commits June 17, 2026 16:01
New contrib gem: MCP (Model Context Protocol) server exposing
RubyEventStore inspection as AI tools over stdio JSON-RPC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the MCP protocol loop over stdio: initialize handshake,
tools/list, tools/call, and ping. Tools are registered via #register
and receive the event_store instance on each call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows event count, version, and first/last event for a named stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReadEvents.of centralizes filtering logic (type, after, before, from,
limit) — mirroring the same class in ruby_event_store-cli.
stream_events lists events in a stream with optional filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows full event details: ID, type, timestamps, data and metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lists all streams an event has been published or linked to.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Searches events across all streams with optional type, time range,
stream, and limit filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows total event count and unique event types, with optional
per-stream mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows the causation tree for all events sharing a correlation ID,
using the \$by_correlation_id_* stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MCP.server(event_store) builds a fully configured Server with all
seven tools registered. bin/res-mcp loads the Rails environment and
starts the stdio server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents installation, usage, MCP client configuration, and the list
of available tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test workflow runs on Ruby 3.2/3.3/3.4. Mutate workflow runs
incremental mutation testing on PRs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests the full JSON-RPC pipeline end-to-end: stdin → server dispatch
→ tool execution against a real event store → stdout response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add targeted specs to kill alive mutations across all tool specs and the
server spec: exact schema hash comparisons, iso8601 precision anchoring,
two-stream isolation tests, direct private method tests via send, and
factory coverage in mcp_spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add targeted specs for: error message content in resolve_type, stored
event_store and empty tools list in Server#initialize, argument passing
and response shape in call_tool, full type name format in search and
stream_events, external causation_id root detection and prefix
accumulation in trace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract render_node/render_last_node/render_children to eliminate dead `last` parameter
- Use *rest, last = children destructuring instead of index-based slicing
- Rewrite spec around three business scenarios (chain, branching, full order flow)
- Add tree_shape helper for readable structural assertions
- 100% mutant coverage (335/335)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract event_line helper to remove duplication between render_node and render_last_node
- Extract child_prefix as named variable
- Simplify render_children using splat destructuring (*non_last, last)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add tests to cover all surviving mutations in Server#handle and
Server#call_tool. Each test targets the specific behavioral difference
the mutation would introduce:

- params["name"] vs params.fetch("name"): test with absent "name" key
  verifies the response text includes "Unknown tool" (not a generic
  rescue "Error:" message)
- e.message vs e (to_s) in call_tool rescue: test with a custom
  exception class that overrides both to_s and message to distinct
  values; asserts the text uses message, not to_s
- request["method"] vs request.fetch("method"): test with no "method"
  key verifies -32601 is returned (not -32603 from rescue)
- request["params"] vs request.fetch("params"): test with absent
  "params" in tools/call verifies a result key is returned (call_tool
  rescues internally) not an error key (handle rescue path)
- request["id"] vs request.fetch("id") in rescue: test with a request
  that triggers handle's rescue but has no "id" field; verifies
  response id is nil instead of the fetch raising KeyError

Server#start is added to the mutant ignore list. The three
strip-variant mutations (strip/lstrip/rstrip/none) are equivalent:
Ruby's JSON.parse accepts surrounding whitespace — including the
trailing \n from each_line — per RFC 8259, so the parse result is
identical regardless of which strip variant is used. The output.sync
mutation is also in this method; it is covered by a spec asserting
sync= is called with true, but since the method is ignored by mutant,
that spec runs as a regular RSpec test rather than being driven by
mutant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four mutations were surviving in ReadEvents.of and ReadEvents.resolve_type.

limit.to_i vs limit.to_int / bare limit:
Tests with limit: "2" (string) and limit: "2abc" kill these. Strings
do not respond to to_int (NoMethodError), and specification.limit
raises ArgumentError on a raw string. The "2abc" case also documents
the to_i behavior of extracting leading digits from partial numeric
strings.

limit.to_i vs Integer(limit):
Integer("2abc") raises ArgumentError while "2abc".to_i returns 2,
so the test with limit: "2abc" returning 2 events distinguishes them.

Object.const_get vs self.const_get in resolve_type:
For fully qualified names these are equivalent (both traverse Object
to resolve the first segment), but they differ for unqualified names:
Object.const_get("LocalEvent") raises NameError while
ReadEvents.const_get("LocalEvent") would find ReadEvents::LocalEvent.
The test stubs a constant directly on ReadEvents and asserts that
resolving it by its unqualified name raises "Unknown event type",
confirming Object namespace is used, not ReadEvents' own namespace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows the full event history of an aggregate instance by constructing
the stream name from aggregate_type and aggregate_id using the
RubyEventStore convention (ClassName$id) and reading all events from
that stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
recent: shows the N most recent events across all streams, newest
first. Useful as a starting point when stream names are not yet known.
Defaults to 20 events.

aggregate_history: refactor render to build output via string
interpolation instead of array accumulation. Eliminates equivalent
nil/empty-string mutation and kills join-separator mutants with a
line-count assertion on two events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ktop

Users know the JSON snippet but often don't know where to put it.
Add explicit file paths for both clients and platforms so they can
configure res-mcp without digging through documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
settings.json holds permissions/hooks, not MCP servers. Claude Code reads
MCP servers from .mcp.json (project) or `claude mcp add`. Document the real
flow, split Claude Code vs Desktop (with cwd + paths), note install alone
doesn't register the server, and add an other-clients pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Branch was rebased onto master (2.18.0 -> 2.19.2); regenerate the
mcp Gemfile.lock so frozen/deployment installs on CI match the
current path gemspecs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant