ruby_event_store-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +82 -0
- data/bin/res-mcp +18 -0
- data/lib/ruby_event_store/mcp/read_events.rb +21 -0
- data/lib/ruby_event_store/mcp/server.rb +83 -0
- data/lib/ruby_event_store/mcp/tools/aggregate_history.rb +48 -0
- data/lib/ruby_event_store/mcp/tools/event_show.rb +47 -0
- data/lib/ruby_event_store/mcp/tools/event_streams.rb +39 -0
- data/lib/ruby_event_store/mcp/tools/recent.rb +42 -0
- data/lib/ruby_event_store/mcp/tools/search.rb +51 -0
- data/lib/ruby_event_store/mcp/tools/stats.rb +47 -0
- data/lib/ruby_event_store/mcp/tools/stream_events.rb +53 -0
- data/lib/ruby_event_store/mcp/tools/stream_show.rb +52 -0
- data/lib/ruby_event_store/mcp/tools/trace.rb +78 -0
- data/lib/ruby_event_store/mcp/version.rb +7 -0
- data/lib/ruby_event_store/mcp.rb +32 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ff5bf2fc507301e40e1e307280b1e3dcbb7ea00a8babf6dc9bf5684a72a0c0b2
|
|
4
|
+
data.tar.gz: 823bd757d780fb861a7423ca4e80e767662f472dcda92640a95811983f3b0570
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 975772a2e847f88f07b84b3d2bca336e6a4e4267e09028e6980d7d0f61c48c691207a6fbbcaa27b593b03e3df9c268eefe51adcffaf53acc8d5a72f9c6121272
|
|
7
|
+
data.tar.gz: 93ec27cded4a7f5e9b45a40bf31cd25a51866d74258117984738a6e03b6f17021d4350030d8f8145449ff61b24e4d4b1666b9aff0e12f4985daf0c013f87a492
|
data/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ruby_event_store-mcp
|
|
2
|
+
|
|
3
|
+
Model Context Protocol (MCP) server for [RubyEventStore](https://railseventstore.org). Exposes your event store as AI tools so Claude (and other MCP clients) can inspect streams, events, and causal relationships directly.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Rails app's `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "ruby_event_store-mcp"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Run from the root of your Rails application:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle exec res-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The server communicates over stdio using the MCP protocol. Installing the gem only provides the `res-mcp` binary — you still have to register it with your MCP client. Every client takes the same server definition; only the file it lives in differs.
|
|
22
|
+
|
|
23
|
+
### Claude Code
|
|
24
|
+
|
|
25
|
+
Add a `.mcp.json` to your project root (or run `claude mcp add res -- bundle exec res-mcp`):
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"res": {
|
|
31
|
+
"command": "bundle",
|
|
32
|
+
"args": ["exec", "res-mcp"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Launched from the project directory, Claude Code runs the server there, so no `cwd` is needed. On the next launch Claude Code asks you to trust the server — approve it, then run `/mcp` to confirm `res` is connected with its tools.
|
|
39
|
+
|
|
40
|
+
### Claude Desktop
|
|
41
|
+
|
|
42
|
+
Add the same block with an explicit `cwd` pointing at your app's root, in `claude_desktop_config.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"res": {
|
|
48
|
+
"command": "bundle",
|
|
49
|
+
"args": ["exec", "res-mcp"],
|
|
50
|
+
"cwd": "/path/to/your/rails/app"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Config file locations:
|
|
57
|
+
|
|
58
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
59
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
60
|
+
|
|
61
|
+
### Other MCP clients
|
|
62
|
+
|
|
63
|
+
Cursor, Windsurf, Cline and others take the same `mcpServers` block in their own config file. VS Code's built-in MCP support uses a `servers` key with `"type": "stdio"` instead. The `bundle exec res-mcp` command is the portable part.
|
|
64
|
+
|
|
65
|
+
## Requirements
|
|
66
|
+
|
|
67
|
+
- Ruby >= 3.0
|
|
68
|
+
- A Rails application with `Rails.configuration.event_store` configured
|
|
69
|
+
|
|
70
|
+
## Available tools
|
|
71
|
+
|
|
72
|
+
| Tool | Description |
|
|
73
|
+
|---------------------|------------------------------------------------------------------|
|
|
74
|
+
| `recent` | Most recent events across all streams (default: 20, newest first)|
|
|
75
|
+
| `stream_show` | Event count, version, first/last event for a stream |
|
|
76
|
+
| `stream_events` | List events in a stream (filter by type, time range, limit) |
|
|
77
|
+
| `event_show` | Full event details: data, metadata, timestamps |
|
|
78
|
+
| `event_streams` | All streams an event has been published or linked to |
|
|
79
|
+
| `aggregate_history` | Full event history of an aggregate instance by type and ID |
|
|
80
|
+
| `search` | Search events by type, time range, or stream |
|
|
81
|
+
| `stats` | Total event count and unique event types |
|
|
82
|
+
| `trace` | Causation tree for all events sharing a correlation ID |
|
data/bin/res-mcp
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/ruby_event_store/mcp"
|
|
5
|
+
|
|
6
|
+
env_file = File.expand_path("config/environment.rb")
|
|
7
|
+
|
|
8
|
+
abort "Could not find config/environment.rb. Run `res-mcp` from the root of your Rails application." unless File.exist?(env_file)
|
|
9
|
+
|
|
10
|
+
require env_file
|
|
11
|
+
|
|
12
|
+
abort <<~MSG unless defined?(Rails) && Rails.configuration.respond_to?(:event_store)
|
|
13
|
+
Could not find event store instance after loading config/environment.rb.
|
|
14
|
+
|
|
15
|
+
Expected Rails.configuration.event_store to be set (standard RES setup).
|
|
16
|
+
MSG
|
|
17
|
+
|
|
18
|
+
RubyEventStore::MCP.server(Rails.configuration.event_store).start
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
class ReadEvents
|
|
6
|
+
def self.of(specification, type: nil, after: nil, before: nil, from: nil, limit: nil)
|
|
7
|
+
specification = specification.of_type(resolve_type(type)) if type
|
|
8
|
+
specification = specification.newer_than(Time.parse(after)) if after
|
|
9
|
+
specification = specification.older_than(Time.parse(before)) if before
|
|
10
|
+
specification = specification.from(from) if from
|
|
11
|
+
limit ? specification.limit(limit.to_i).to_a : specification.to_a
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.resolve_type(name)
|
|
15
|
+
Object.const_get(name)
|
|
16
|
+
rescue NameError
|
|
17
|
+
raise "Unknown event type: #{name}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyEventStore
|
|
6
|
+
module MCP
|
|
7
|
+
class Server
|
|
8
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
9
|
+
|
|
10
|
+
attr_reader :event_store, :name, :version, :tools
|
|
11
|
+
|
|
12
|
+
def initialize(event_store:, name: "ruby-event-store", version: VERSION)
|
|
13
|
+
@event_store = event_store
|
|
14
|
+
@name = name
|
|
15
|
+
@version = version
|
|
16
|
+
@tools = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register(tool)
|
|
20
|
+
tools << tool
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start(input: $stdin, output: $stdout)
|
|
25
|
+
output.sync = true
|
|
26
|
+
input.each_line do |line|
|
|
27
|
+
request = JSON.parse(line.strip)
|
|
28
|
+
response = handle(request)
|
|
29
|
+
output.puts(JSON.generate(response)) if response
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def handle(request)
|
|
36
|
+
id = request["id"]
|
|
37
|
+
method = request["method"]
|
|
38
|
+
|
|
39
|
+
case method
|
|
40
|
+
when "initialize"
|
|
41
|
+
result = {
|
|
42
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
43
|
+
capabilities: { tools: {} },
|
|
44
|
+
serverInfo: { name: name, version: version }
|
|
45
|
+
}
|
|
46
|
+
jsonrpc_result(id, result)
|
|
47
|
+
when "notifications/initialized"
|
|
48
|
+
nil
|
|
49
|
+
when "tools/list"
|
|
50
|
+
jsonrpc_result(id, { tools: tools.map(&:schema) })
|
|
51
|
+
when "tools/call"
|
|
52
|
+
call_tool(id, request["params"])
|
|
53
|
+
when "ping"
|
|
54
|
+
jsonrpc_result(id, {})
|
|
55
|
+
else
|
|
56
|
+
jsonrpc_error(id, -32601, "Method not found: #{method}")
|
|
57
|
+
end
|
|
58
|
+
rescue => e
|
|
59
|
+
jsonrpc_error(request["id"], -32603, e.message)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call_tool(id, params)
|
|
63
|
+
tool_name = params["name"]
|
|
64
|
+
arguments = params["arguments"] || {}
|
|
65
|
+
tool = tools.find { |t| t.name == tool_name }
|
|
66
|
+
return jsonrpc_result(id, { content: [{ type: "text", text: "Unknown tool: #{tool_name}" }], isError: true }) unless tool
|
|
67
|
+
|
|
68
|
+
result = tool.call(event_store, arguments)
|
|
69
|
+
jsonrpc_result(id, { content: [{ type: "text", text: result }] })
|
|
70
|
+
rescue => e
|
|
71
|
+
jsonrpc_result(id, { content: [{ type: "text", text: "Error: #{e.message}" }], isError: true })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def jsonrpc_result(id, result)
|
|
75
|
+
{ jsonrpc: "2.0", id: id, result: result }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def jsonrpc_error(id, code, message)
|
|
79
|
+
{ jsonrpc: "2.0", id: id, error: { code: code, message: message } }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class AggregateHistory
|
|
7
|
+
def name
|
|
8
|
+
"aggregate_history"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def schema
|
|
12
|
+
{
|
|
13
|
+
name: name,
|
|
14
|
+
description: "Show the full event history of an aggregate instance",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
aggregate_type: { type: "string", description: "Aggregate class name (e.g. Order, Payment::Invoice)" },
|
|
19
|
+
aggregate_id: { type: "string", description: "Aggregate ID (UUID or other identifier)" }
|
|
20
|
+
},
|
|
21
|
+
required: %w[aggregate_type aggregate_id]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(event_store, args)
|
|
27
|
+
aggregate_type = args.fetch("aggregate_type")
|
|
28
|
+
aggregate_id = args.fetch("aggregate_id")
|
|
29
|
+
stream_name = "#{aggregate_type}$#{aggregate_id}"
|
|
30
|
+
events = events(event_store, stream_name)
|
|
31
|
+
render(stream_name, events)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def events(event_store, stream_name)
|
|
37
|
+
event_store.read.stream(stream_name).to_a
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render(stream_name, events)
|
|
41
|
+
header = "Aggregate: #{stream_name}\nEvents: #{events.size}"
|
|
42
|
+
return header if events.empty?
|
|
43
|
+
"#{header}\n\n#{events.map { |e| "#{e.timestamp.iso8601(3)} #{e.event_type} [#{e.event_id}]" }.join("\n")}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyEventStore
|
|
6
|
+
module MCP
|
|
7
|
+
module Tools
|
|
8
|
+
class EventShow
|
|
9
|
+
def name
|
|
10
|
+
"event_show"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def schema
|
|
14
|
+
{
|
|
15
|
+
name: name,
|
|
16
|
+
description: "Show full event details including data, metadata, and timestamps",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
event_id: { type: "string", description: "Event ID (UUID)" }
|
|
21
|
+
},
|
|
22
|
+
required: ["event_id"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(event_store, args)
|
|
28
|
+
event = event_store.read.event!(args.fetch("event_id"))
|
|
29
|
+
format_event(event)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def format_event(event)
|
|
35
|
+
[
|
|
36
|
+
"Event ID: #{event.event_id}",
|
|
37
|
+
"Type: #{event.event_type}",
|
|
38
|
+
"Timestamp: #{event.timestamp.iso8601(3)}",
|
|
39
|
+
"Valid at: #{event.valid_at.iso8601(3)}",
|
|
40
|
+
"Data: #{JSON.pretty_generate(event.data)}",
|
|
41
|
+
"Metadata: #{JSON.pretty_generate(event.metadata.to_h)}"
|
|
42
|
+
].join("\n")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class EventStreams
|
|
7
|
+
def name
|
|
8
|
+
"event_streams"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def schema
|
|
12
|
+
{
|
|
13
|
+
name: name,
|
|
14
|
+
description: "List all streams the event has been published or linked to",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
event_id: { type: "string", description: "Event ID (UUID)" }
|
|
19
|
+
},
|
|
20
|
+
required: ["event_id"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(event_store, args)
|
|
26
|
+
streams = event_store.streams_of(args.fetch("event_id"))
|
|
27
|
+
return "(no streams — event not found or not linked to any stream)" if streams.empty?
|
|
28
|
+
format_streams(streams)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def format_streams(streams)
|
|
34
|
+
streams.map(&:name).join("\n")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class Recent
|
|
7
|
+
DEFAULT_LIMIT = 20
|
|
8
|
+
|
|
9
|
+
def name
|
|
10
|
+
"recent"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def schema
|
|
14
|
+
{
|
|
15
|
+
name: name,
|
|
16
|
+
description: "Show the most recent events across all streams",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
limit: { type: "integer", description: "Number of events to return (default: #{DEFAULT_LIMIT})" }
|
|
21
|
+
},
|
|
22
|
+
required: []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(event_store, args)
|
|
28
|
+
limit = args.fetch("limit", DEFAULT_LIMIT).to_i
|
|
29
|
+
events = event_store.read.limit(limit).backward.to_a
|
|
30
|
+
return "(no events)" if events.empty?
|
|
31
|
+
render(events)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def render(events)
|
|
37
|
+
events.map { |e| "#{e.timestamp.iso8601(3)} #{e.event_type} [#{e.event_id}]" }.join("\n")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../read_events"
|
|
4
|
+
|
|
5
|
+
module RubyEventStore
|
|
6
|
+
module MCP
|
|
7
|
+
module Tools
|
|
8
|
+
class Search
|
|
9
|
+
def name
|
|
10
|
+
"search"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def schema
|
|
14
|
+
{
|
|
15
|
+
name: name,
|
|
16
|
+
description: "Search events across all streams by type, time range, or stream name",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
type: { type: "string", description: "Filter by event type class name" },
|
|
21
|
+
after: { type: "string", description: "Filter events newer than timestamp (ISO8601)" },
|
|
22
|
+
before: { type: "string", description: "Filter events older than timestamp (ISO8601)" },
|
|
23
|
+
stream: { type: "string", description: "Limit search to a specific stream" },
|
|
24
|
+
limit: { type: "integer", description: "Max number of events (default: 50)" }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(event_store, args)
|
|
31
|
+
specification = args.key?("stream") ? event_store.read.stream(args.fetch("stream")) : event_store.read
|
|
32
|
+
events = ReadEvents.of(
|
|
33
|
+
specification,
|
|
34
|
+
type: args["type"],
|
|
35
|
+
after: args["after"],
|
|
36
|
+
before: args["before"],
|
|
37
|
+
limit: args.fetch("limit", 50)
|
|
38
|
+
)
|
|
39
|
+
return "(no events found)" if events.empty?
|
|
40
|
+
format_events(events)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def format_events(events)
|
|
46
|
+
events.map { |e| "#{e.timestamp.iso8601(3)} #{e.event_type} [#{e.event_id}]" }.join("\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class Stats
|
|
7
|
+
def name
|
|
8
|
+
"stats"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def schema
|
|
12
|
+
{
|
|
13
|
+
name: name,
|
|
14
|
+
description: "Show event count and unique event types. Use stream to get per-stream stats.",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
stream: { type: "string", description: "Show stats for a specific stream" }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(event_store, args)
|
|
25
|
+
specification = args.key?("stream") ? event_store.read.stream(args.fetch("stream")) : event_store.read
|
|
26
|
+
format_stats(specification, stream: args["stream"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def format_stats(specification, stream:)
|
|
32
|
+
lines = []
|
|
33
|
+
lines << "Stream: #{stream}" if stream
|
|
34
|
+
lines << "Events: #{specification.count}"
|
|
35
|
+
lines.concat(format_event_types(specification))
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_event_types(specification)
|
|
40
|
+
types = specification.map(&:event_type).uniq.sort
|
|
41
|
+
return [] if types.empty?
|
|
42
|
+
["\nEvent types:"] + types.map { |t| " #{t}" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../read_events"
|
|
4
|
+
|
|
5
|
+
module RubyEventStore
|
|
6
|
+
module MCP
|
|
7
|
+
module Tools
|
|
8
|
+
class StreamEvents
|
|
9
|
+
def name
|
|
10
|
+
"stream_events"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def schema
|
|
14
|
+
{
|
|
15
|
+
name: name,
|
|
16
|
+
description: "List events in a stream with optional filters",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
stream_name: { type: "string", description: "Stream name" },
|
|
21
|
+
limit: { type: "integer", description: "Max number of events (default: 20)" },
|
|
22
|
+
type: { type: "string", description: "Filter by event type class name" },
|
|
23
|
+
after: { type: "string", description: "Filter events newer than timestamp (ISO8601)" },
|
|
24
|
+
before: { type: "string", description: "Filter events older than timestamp (ISO8601)" },
|
|
25
|
+
from: { type: "string", description: "Start reading from event ID (exclusive)" }
|
|
26
|
+
},
|
|
27
|
+
required: ["stream_name"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(event_store, args)
|
|
33
|
+
events = ReadEvents.of(
|
|
34
|
+
event_store.read.stream(args.fetch("stream_name")),
|
|
35
|
+
type: args["type"],
|
|
36
|
+
after: args["after"],
|
|
37
|
+
before: args["before"],
|
|
38
|
+
from: args["from"],
|
|
39
|
+
limit: args.fetch("limit", 20)
|
|
40
|
+
)
|
|
41
|
+
return "(no events)" if events.empty?
|
|
42
|
+
format_events(events)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def format_events(events)
|
|
48
|
+
events.map { |e| "#{e.timestamp.iso8601(3)} #{e.event_type} [#{e.event_id}]" }.join("\n")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class StreamShow
|
|
7
|
+
def name
|
|
8
|
+
"stream_show"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def schema
|
|
12
|
+
{
|
|
13
|
+
name: name,
|
|
14
|
+
description: "Show event count, version, and first/last event for a stream",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
stream_name: { type: "string", description: "Stream name" }
|
|
19
|
+
},
|
|
20
|
+
required: ["stream_name"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(event_store, args)
|
|
26
|
+
stream_name = args.fetch("stream_name")
|
|
27
|
+
specification = event_store.read.stream(stream_name)
|
|
28
|
+
format_stream(stream_name, specification)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def format_stream(stream_name, specification)
|
|
34
|
+
count = specification.count
|
|
35
|
+
lines = ["Stream: #{stream_name}", "Events: #{count}"]
|
|
36
|
+
lines.concat(format_bounds(specification, count)) if count > 0
|
|
37
|
+
lines.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def format_bounds(specification, count)
|
|
41
|
+
first = specification.first
|
|
42
|
+
last = specification.last
|
|
43
|
+
[
|
|
44
|
+
"Version: #{count - 1}",
|
|
45
|
+
"First: #{first.timestamp.iso8601(3)} (#{first.event_type})",
|
|
46
|
+
"Last: #{last.timestamp.iso8601(3)} (#{last.event_type})"
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyEventStore
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class Trace
|
|
7
|
+
def name
|
|
8
|
+
"trace"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def schema
|
|
12
|
+
{
|
|
13
|
+
name: name,
|
|
14
|
+
description: "Show the causation tree for all events sharing a correlation ID",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
correlation_id: { type: "string", description: "Correlation ID (UUID)" }
|
|
19
|
+
},
|
|
20
|
+
required: ["correlation_id"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(event_store, args)
|
|
26
|
+
events = events_for(event_store, args.fetch("correlation_id"))
|
|
27
|
+
return "(no events found for correlation ID #{args.fetch("correlation_id")})" if events.empty?
|
|
28
|
+
build_tree(events)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def events_for(event_store, correlation_id)
|
|
34
|
+
event_store.read.stream("$by_correlation_id_#{correlation_id}").to_a
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_tree(events)
|
|
38
|
+
by_causation = events.group_by { |e| e.metadata[:causation_id] }
|
|
39
|
+
roots = root_events(events)
|
|
40
|
+
lines = []
|
|
41
|
+
roots.each do |e|
|
|
42
|
+
lines << "#{e.event_type} [#{e.event_id}]"
|
|
43
|
+
render_children(e, by_causation, "", lines)
|
|
44
|
+
end
|
|
45
|
+
lines.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def root_events(events)
|
|
49
|
+
event_ids = events.map(&:event_id)
|
|
50
|
+
events.reject { |e| event_ids.include?(e.metadata[:causation_id]) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_node(event, by_causation, prefix, lines)
|
|
54
|
+
lines << event_line(event, prefix, "├── ")
|
|
55
|
+
child_prefix = prefix + "│ "
|
|
56
|
+
render_children(event, by_causation, child_prefix, lines)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render_last_node(event, by_causation, prefix, lines)
|
|
60
|
+
lines << event_line(event, prefix, "└── ")
|
|
61
|
+
child_prefix = prefix + " "
|
|
62
|
+
render_children(event, by_causation, child_prefix, lines)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def event_line(event, prefix, connector)
|
|
66
|
+
"#{prefix}#{connector}#{event.event_type} [#{event.event_id}]"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_children(event, by_causation, prefix, lines)
|
|
70
|
+
*non_last, last = by_causation[event.event_id]
|
|
71
|
+
return if last.nil?
|
|
72
|
+
non_last.each { |child| render_node(child, by_causation, prefix, lines) }
|
|
73
|
+
render_last_node(last, by_causation, prefix, lines)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mcp/version"
|
|
4
|
+
require_relative "mcp/read_events"
|
|
5
|
+
require_relative "mcp/server"
|
|
6
|
+
require_relative "mcp/tools/stream_show"
|
|
7
|
+
require_relative "mcp/tools/stream_events"
|
|
8
|
+
require_relative "mcp/tools/event_show"
|
|
9
|
+
require_relative "mcp/tools/event_streams"
|
|
10
|
+
require_relative "mcp/tools/search"
|
|
11
|
+
require_relative "mcp/tools/stats"
|
|
12
|
+
require_relative "mcp/tools/trace"
|
|
13
|
+
require_relative "mcp/tools/aggregate_history"
|
|
14
|
+
require_relative "mcp/tools/recent"
|
|
15
|
+
|
|
16
|
+
module RubyEventStore
|
|
17
|
+
module MCP
|
|
18
|
+
def self.server(event_store)
|
|
19
|
+
Server
|
|
20
|
+
.new(event_store: event_store)
|
|
21
|
+
.register(Tools::StreamShow.new)
|
|
22
|
+
.register(Tools::StreamEvents.new)
|
|
23
|
+
.register(Tools::EventShow.new)
|
|
24
|
+
.register(Tools::EventStreams.new)
|
|
25
|
+
.register(Tools::Search.new)
|
|
26
|
+
.register(Tools::Stats.new)
|
|
27
|
+
.register(Tools::Trace.new)
|
|
28
|
+
.register(Tools::AggregateHistory.new)
|
|
29
|
+
.register(Tools::Recent.new)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_event_store-mcp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Arkency
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ruby_event_store
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 1.0.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 1.0.0
|
|
26
|
+
email: dev@arkency.com
|
|
27
|
+
executables:
|
|
28
|
+
- res-mcp
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files:
|
|
31
|
+
- README.md
|
|
32
|
+
files:
|
|
33
|
+
- README.md
|
|
34
|
+
- bin/res-mcp
|
|
35
|
+
- lib/ruby_event_store/mcp.rb
|
|
36
|
+
- lib/ruby_event_store/mcp/read_events.rb
|
|
37
|
+
- lib/ruby_event_store/mcp/server.rb
|
|
38
|
+
- lib/ruby_event_store/mcp/tools/aggregate_history.rb
|
|
39
|
+
- lib/ruby_event_store/mcp/tools/event_show.rb
|
|
40
|
+
- lib/ruby_event_store/mcp/tools/event_streams.rb
|
|
41
|
+
- lib/ruby_event_store/mcp/tools/recent.rb
|
|
42
|
+
- lib/ruby_event_store/mcp/tools/search.rb
|
|
43
|
+
- lib/ruby_event_store/mcp/tools/stats.rb
|
|
44
|
+
- lib/ruby_event_store/mcp/tools/stream_events.rb
|
|
45
|
+
- lib/ruby_event_store/mcp/tools/stream_show.rb
|
|
46
|
+
- lib/ruby_event_store/mcp/tools/trace.rb
|
|
47
|
+
- lib/ruby_event_store/mcp/version.rb
|
|
48
|
+
homepage: https://railseventstore.org
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
homepage_uri: https://railseventstore.org
|
|
53
|
+
source_code_uri: https://github.com/RailsEventStore/rails_event_store
|
|
54
|
+
bug_tracker_uri: https://github.com/RailsEventStore/rails_event_store/issues
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '3.0'
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 3.7.1
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Model Context Protocol server for Ruby Event Store
|
|
73
|
+
test_files: []
|