flehmen 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7e9d8368a8bcdfff9fca48d58621bf80fa261f0ca8a1999d668db8a96f707ece
4
+ data.tar.gz: 73121618744a5f85395c3b7eff399647e58625db1923245c40c88542fd4a4ab0
5
+ SHA512:
6
+ metadata.gz: '01999308497ea1ab463e7347ee8f0c5ddb4ba8a7717ecc59925b010b81c8a898458ff5ed0d870532c831dc9d84c9836bb6e2cdb6f3d19fa4bb98d89fbb162ff8'
7
+ data.tar.gz: 95817db20d7b8d699b4b5ce42bf9c13e57fd75b847db0afdb95dbec40ba1f5100ee89b3e07fc4546d3ea7f3b6c4d3dec8ec4490f38ef939d61660d2370822802
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ryosk7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # Flehmen
2
+
3
+ <img width="410" height="410" alt="flehmen-response-cat" src="https://github.com/user-attachments/assets/07d5848a-d35f-4046-9530-69c855cfd07e" />
4
+
5
+ A Ruby gem that exposes Rails ActiveRecord models to Claude Desktop via the Model Context Protocol (MCP). It auto-discovers models, provides read-only query tools, and filters sensitive fields.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "flehmen", github: "ryosk7/flehmen"
13
+ ```
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ### Rails initializer
22
+
23
+ Create `config/initializers/flehmen.rb`:
24
+
25
+ ```ruby
26
+ Flehmen.configure do |config|
27
+ config.exclude_models = []
28
+ config.sensitive_fields = %i[
29
+ password_digest encrypted_password token secret
30
+ api_key api_secret access_token refresh_token
31
+ otp_secret reset_password_token encrypted_phone
32
+ ]
33
+ config.max_results = 100
34
+ end
35
+
36
+ Flehmen.mount_in_rails(Rails.application, path_prefix: "/mcp", localhost_only: false)
37
+ ```
38
+
39
+ ### Claude Desktop
40
+
41
+ Add to `claude_desktop_config.json`:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "flehmen": {
47
+ "command": "npx",
48
+ "args": ["mcp-remote", "http://localhost:3000/mcp/sse", "--allow-http"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ > **Note:** The URL must end with `/mcp/sse`. The underlying `fast_mcp` gem uses SSE transport, so `/mcp` alone will return 404.
55
+
56
+ ## Configuration
57
+
58
+ | Option | Type | Default | Description |
59
+ |---|---|---|---|
60
+ | `models` | `:all` or `Array` | `:all` | Models to expose. `:all` auto-discovers all `ApplicationRecord` descendants |
61
+ | `exclude_models` | `Array` | `[]` | Model classes or strings to exclude |
62
+ | `sensitive_fields` | `Array<Symbol>` | See below | Fields masked with `[FILTERED]` across all models |
63
+ | `model_sensitive_fields` | `Hash` | `{}` | Per-model sensitive fields |
64
+ | `max_results` | `Integer` | `100` | Maximum records returned by any query |
65
+ | `read_only_connection` | `Boolean` | `true` | Wraps all queries in `while_preventing_writes` to block accidental writes at the Rails level |
66
+
67
+ ### Default sensitive fields
68
+
69
+ ```ruby
70
+ %i[
71
+ password_digest encrypted_password token secret
72
+ api_key api_secret access_token refresh_token
73
+ otp_secret reset_password_token confirmation_token
74
+ unlock_token remember_token authentication_token
75
+ ]
76
+ ```
77
+
78
+ ### Example
79
+
80
+ ```ruby
81
+ Flehmen.configure do |config|
82
+ config.exclude_models = [AdminUser, "InternalLog"]
83
+ config.sensitive_fields += [:ssn, :credit_card_number]
84
+ config.model_sensitive_fields = {
85
+ "User" => [:phone_number, :address]
86
+ }
87
+ config.max_results = 50
88
+ end
89
+ ```
90
+
91
+ ## Tools
92
+
93
+ ### flehmen_list_models
94
+
95
+ Lists all discovered models.
96
+
97
+ ```
98
+ Arguments: none
99
+ ```
100
+
101
+ Returns:
102
+ ```json
103
+ [
104
+ {
105
+ "name": "User",
106
+ "table_name": "users",
107
+ "column_count": 15,
108
+ "association_count": 8,
109
+ "enum_count": 2
110
+ }
111
+ ]
112
+ ```
113
+
114
+ ### flehmen_describe_model
115
+
116
+ Returns schema details (columns, associations, enums) for a model.
117
+
118
+ ```
119
+ Arguments:
120
+ model_name (required) - e.g. "User"
121
+ ```
122
+
123
+ ### flehmen_find_record
124
+
125
+ Finds a single record by primary key.
126
+
127
+ ```
128
+ Arguments:
129
+ model_name (required)
130
+ id (required)
131
+ ```
132
+
133
+ ### flehmen_search_records
134
+
135
+ Searches records with structured conditions.
136
+
137
+ ```
138
+ Arguments:
139
+ model_name (required)
140
+ conditions (optional) - JSON string
141
+ order_by (optional) - Column name to sort by
142
+ order_dir (optional) - "asc" or "desc" (default: "asc")
143
+ limit (optional)
144
+ offset (optional)
145
+ ```
146
+
147
+ Conditions format:
148
+ ```json
149
+ [
150
+ {"field": "status", "operator": "eq", "value": "active"},
151
+ {"field": "created_at", "operator": "gte", "value": "2025-01-01"}
152
+ ]
153
+ ```
154
+
155
+ Supported operators: `eq`, `not_eq`, `gt`, `gte`, `lt`, `lte`, `like`, `not_like`, `in`, `not_in`, `null`, `not_null`
156
+
157
+ ### flehmen_count_records
158
+
159
+ Counts records matching conditions.
160
+
161
+ ```
162
+ Arguments:
163
+ model_name (required)
164
+ conditions (optional) - Same format as search_records
165
+ ```
166
+
167
+ ### flehmen_show_associations
168
+
169
+ Fetches associated records for a given record.
170
+
171
+ ```
172
+ Arguments:
173
+ model_name (required)
174
+ id (required)
175
+ association_name (required) - e.g. "posts", "company"
176
+ limit (optional) - For has_many associations
177
+ offset (optional)
178
+ ```
179
+
180
+
181
+ ## Resources
182
+
183
+ ### flehmen://schema/overview
184
+
185
+ Returns a JSON overview of all models including columns, associations, and enums.
186
+
187
+ ## Architecture
188
+
189
+ ```
190
+ Flehmen.mount_in_rails(app)
191
+ └── FastMcp.mount_in_rails (Rack middleware)
192
+ ├── GET /mcp/sse → SSE connection (keep-alive)
193
+ └── POST /mcp/messages → JSON-RPC message handling
194
+ ```
195
+
196
+ - **ModelRegistry** - Auto-discovers models on first access (lazy loading)
197
+ - **FieldFilter** - Masks sensitive fields with `[FILTERED]`
198
+ - **QueryBuilder** - Builds Arel-based queries from structured conditions (SQL injection safe)
199
+ - **Serializer** - Converts records to filtered hashes
200
+
201
+ ## Dependencies
202
+
203
+ - Ruby >= 3.1.0
204
+ - `fast-mcp` ~> 1.5
205
+ - `activerecord` >= 7.0
206
+ - `activesupport` >= 7.0
207
+ - `railties` >= 7.0
208
+
209
+ ## License
210
+
211
+ MIT
data/bin/flehmen ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+
6
+ app_path = ENV["RAILS_APP_PATH"] || Dir.pwd
7
+ ENV["RAILS_ENV"] ||= "development"
8
+
9
+ require File.join(app_path, "config", "environment")
10
+ Rails.application.eager_load!
11
+
12
+ require "flehmen"
13
+
14
+ Flehmen.boot!
15
+ Flehmen.start_server!
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ class Configuration
5
+ attr_accessor :models,
6
+ :exclude_models,
7
+ :sensitive_fields,
8
+ :model_sensitive_fields,
9
+ :max_results,
10
+ :read_only_connection
11
+
12
+ def initialize
13
+ @models = :all
14
+ @exclude_models = []
15
+ @sensitive_fields = %i[
16
+ password_digest encrypted_password token secret
17
+ api_key api_secret access_token refresh_token
18
+ otp_secret reset_password_token confirmation_token
19
+ unlock_token remember_token authentication_token
20
+ ]
21
+ @model_sensitive_fields = {}
22
+ @max_results = 100
23
+ @read_only_connection = true
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ class FieldFilter
5
+ FILTERED_PLACEHOLDER = "[FILTERED]"
6
+
7
+ def initialize(config = Flehmen.configuration)
8
+ @global_sensitive = config.sensitive_fields.map(&:to_s)
9
+ @model_sensitive = config.model_sensitive_fields.transform_keys(&:to_s)
10
+ .transform_values { |v| v.map(&:to_s) }
11
+ end
12
+
13
+ def filter_attributes(model_name, attributes_hash)
14
+ sensitive = sensitive_fields_for(model_name)
15
+ attributes_hash.each_with_object({}) do |(k, v), filtered|
16
+ filtered[k] = sensitive.include?(k.to_s) ? FILTERED_PLACEHOLDER : v
17
+ end
18
+ end
19
+
20
+ def visible_columns(model_name, all_columns)
21
+ sensitive = sensitive_fields_for(model_name)
22
+ all_columns.map do |col|
23
+ col.merge(sensitive: sensitive.include?(col[:name].to_s))
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def sensitive_fields_for(model_name)
30
+ @global_sensitive + (@model_sensitive[model_name.to_s] || [])
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ class ModelRegistry
5
+ attr_reader :models
6
+
7
+ def initialize(config = Flehmen.configuration)
8
+ @config = config
9
+ @models = {}
10
+ end
11
+
12
+ def discover!
13
+ base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
14
+ raw_models = if @config.models == :all
15
+ base_class.descendants
16
+ else
17
+ @config.models.map { |m| m.is_a?(String) ? m.constantize : m }
18
+ end
19
+
20
+ excluded = @config.exclude_models.map { |m| m.is_a?(String) ? m.constantize : m }
21
+
22
+ raw_models.each do |klass|
23
+ next if klass.abstract_class?
24
+ next if excluded.include?(klass)
25
+ next unless safe_table_exists?(klass)
26
+
27
+ register(klass)
28
+ rescue StandardError
29
+ # Skip models that fail introspection (e.g., STI subclasses with missing tables)
30
+ next
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ def model_names
37
+ @models.keys.sort
38
+ end
39
+
40
+ def find_model(name)
41
+ @models[name] || @models[name.to_s.classify]
42
+ end
43
+
44
+ private
45
+
46
+ def safe_table_exists?(klass)
47
+ klass.table_exists?
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ def register(klass)
53
+ @models[klass.name] = {
54
+ klass: klass,
55
+ table_name: klass.table_name,
56
+ columns: extract_columns(klass),
57
+ associations: extract_associations(klass),
58
+ enums: extract_enums(klass),
59
+ primary_key: klass.primary_key
60
+ }
61
+ end
62
+
63
+ def extract_columns(klass)
64
+ klass.columns.map do |col|
65
+ {
66
+ name: col.name,
67
+ type: col.type.to_s,
68
+ null: col.null,
69
+ default: col.default,
70
+ limit: col.limit
71
+ }
72
+ end
73
+ end
74
+
75
+ def extract_associations(klass)
76
+ klass.reflect_on_all_associations.map do |assoc|
77
+ {
78
+ name: assoc.name.to_s,
79
+ type: assoc.macro.to_s,
80
+ class_name: assoc.class_name,
81
+ foreign_key: assoc.foreign_key.to_s,
82
+ through: assoc.options[:through]&.to_s,
83
+ polymorphic: assoc.options[:polymorphic] || false
84
+ }
85
+ end
86
+ end
87
+
88
+ def extract_enums(klass)
89
+ return {} unless klass.respond_to?(:defined_enums)
90
+
91
+ klass.defined_enums.transform_values(&:keys)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ class QueryBuilder
5
+ ALLOWED_OPERATORS = %w[eq not_eq gt gte lt lte like not_like in not_in null not_null].freeze
6
+
7
+ def initialize(model_info, config = Flehmen.configuration)
8
+ @klass = model_info[:klass]
9
+ @column_names = model_info[:columns].map { |c| c[:name] }
10
+ @config = config
11
+ end
12
+
13
+ def build(conditions: [], order_by: nil, order_dir: "asc", limit: nil, offset: nil)
14
+ scope = @klass.all
15
+
16
+ conditions.each do |cond|
17
+ scope = apply_condition(scope, cond)
18
+ end
19
+
20
+ scope = apply_ordering(scope, order_by, order_dir)
21
+ scope = scope.limit(effective_limit(limit))
22
+ scope = scope.offset(offset.to_i) if offset && offset.to_i > 0
23
+ scope
24
+ end
25
+
26
+ private
27
+
28
+ def apply_condition(scope, cond)
29
+ field = cond["field"]&.to_s || cond[:field]&.to_s
30
+ operator = cond["operator"]&.to_s || cond[:operator]&.to_s
31
+ value = cond["value"] || cond[:value]
32
+
33
+ raise ArgumentError, "Missing field in condition" if field.nil? || field.empty?
34
+ raise ArgumentError, "Missing operator in condition" if operator.nil? || operator.empty?
35
+ raise ArgumentError, "Unknown column: #{field}" unless @column_names.include?(field)
36
+ raise ArgumentError, "Unknown operator: #{operator}" unless ALLOWED_OPERATORS.include?(operator)
37
+
38
+ table = @klass.arel_table
39
+
40
+ case operator
41
+ when "eq" then scope.where(table[field].eq(value))
42
+ when "not_eq" then scope.where(table[field].not_eq(value))
43
+ when "gt" then scope.where(table[field].gt(value))
44
+ when "gte" then scope.where(table[field].gteq(value))
45
+ when "lt" then scope.where(table[field].lt(value))
46
+ when "lte" then scope.where(table[field].lteq(value))
47
+ when "like" then scope.where(table[field].matches(sanitize_like(value)))
48
+ when "not_like" then scope.where(table[field].does_not_match(sanitize_like(value)))
49
+ when "in" then scope.where(table[field].in(Array(value).first(@config.max_results)))
50
+ when "not_in" then scope.where(table[field].not_in(Array(value).first(@config.max_results)))
51
+ when "null" then scope.where(table[field].eq(nil))
52
+ when "not_null" then scope.where(table[field].not_eq(nil))
53
+ end
54
+ end
55
+
56
+ def apply_ordering(scope, order_by, order_dir)
57
+ return scope.order(id: :asc) unless order_by
58
+
59
+ raise ArgumentError, "Unknown column: #{order_by}" unless @column_names.include?(order_by.to_s)
60
+
61
+ dir = %w[asc desc].include?(order_dir.to_s.downcase) ? order_dir.to_s.downcase.to_sym : :asc
62
+ scope.order(order_by.to_sym => dir)
63
+ end
64
+
65
+ def effective_limit(requested)
66
+ max = @config.max_results
67
+ return max unless requested
68
+
69
+ [requested.to_i, max].min
70
+ end
71
+
72
+ def sanitize_like(value)
73
+ # Escape special LIKE characters to prevent unintended wildcards
74
+ value.to_s.gsub("\\", "\\\\\\\\").gsub("%", "\\%").gsub("_", "\\_")
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Resources
7
+ class SchemaOverviewResource < FastMcp::Resource
8
+ uri "flehmen://schema/overview"
9
+ resource_name "Database Schema Overview"
10
+ description "Complete overview of all available models, their columns, associations, and enums"
11
+ mime_type "application/json"
12
+
13
+ def content
14
+ registry = Flehmen.model_registry
15
+ filter = Flehmen::FieldFilter.new
16
+
17
+ overview = registry.model_names.map do |name|
18
+ info = registry.find_model(name)
19
+ {
20
+ model: name,
21
+ table: info[:table_name],
22
+ primary_key: info[:primary_key],
23
+ columns: filter.visible_columns(name, info[:columns]),
24
+ associations: info[:associations],
25
+ enums: info[:enums]
26
+ }
27
+ end
28
+
29
+ JSON.pretty_generate(overview)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ class Serializer
5
+ def initialize(field_filter = FieldFilter.new)
6
+ @field_filter = field_filter
7
+ end
8
+
9
+ def serialize_record(record)
10
+ model_name = record.class.name
11
+ @field_filter.filter_attributes(model_name, record.attributes)
12
+ end
13
+
14
+ def serialize_records(records)
15
+ records.map { |r| serialize_record(r) }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ module Tools
5
+ class Base < FastMcp::Tool
6
+ def call(**args)
7
+ ActiveRecord::Base.while_preventing_writes(Flehmen.configuration.read_only_connection) do
8
+ execute(**args)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Tools
7
+ class CountRecordsTool < Base
8
+ tool_name "flehmen_count_records"
9
+ description 'Count records matching filter conditions. Example conditions: [{"field":"status","operator":"eq","value":"active"}]'
10
+
11
+ arguments do
12
+ required(:model_name).filled(:string).description("Name of the model class")
13
+ optional(:conditions).filled(:string).description('JSON array of conditions: [{"field":"...", "operator":"...", "value":"..."}]')
14
+ end
15
+
16
+ annotations(
17
+ read_only_hint: true,
18
+ open_world_hint: false
19
+ )
20
+
21
+ def execute(model_name:, conditions: nil)
22
+ info = Flehmen.model_registry.find_model(model_name)
23
+ return JSON.generate({ error: "Model not found: #{model_name}" }) unless info
24
+
25
+ parsed_conditions = conditions ? JSON.parse(conditions) : []
26
+
27
+ builder = Flehmen::QueryBuilder.new(info)
28
+ scope = builder.build(conditions: parsed_conditions, limit: nil)
29
+ count = scope.unscope(:limit, :offset, :order).count
30
+
31
+ JSON.generate({ model: model_name, count: count })
32
+ rescue JSON::ParserError
33
+ JSON.generate({ error: "Invalid JSON in conditions parameter" })
34
+ rescue ArgumentError => e
35
+ JSON.generate({ error: e.message })
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Tools
7
+ class DescribeModelTool < Base
8
+ tool_name "flehmen_describe_model"
9
+ description "Show the full schema for a model: columns (name, type, null, default), associations (name, type, target class), and enum definitions"
10
+
11
+ arguments do
12
+ required(:model_name).filled(:string).description("Name of the model class, e.g. 'User' or 'Post'")
13
+ end
14
+
15
+ annotations(
16
+ read_only_hint: true,
17
+ open_world_hint: false
18
+ )
19
+
20
+ def execute(model_name:)
21
+ info = Flehmen.model_registry.find_model(model_name)
22
+ return JSON.generate({ error: "Model not found: #{model_name}" }) unless info
23
+
24
+ filter = Flehmen::FieldFilter.new
25
+ result = {
26
+ model: model_name,
27
+ table_name: info[:table_name],
28
+ primary_key: info[:primary_key],
29
+ columns: filter.visible_columns(model_name, info[:columns]),
30
+ associations: info[:associations],
31
+ enums: info[:enums]
32
+ }
33
+ JSON.generate(result)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Tools
7
+ class FindRecordTool < Base
8
+ tool_name "flehmen_find_record"
9
+ description "Find a single record by its primary key (usually ID)"
10
+
11
+ arguments do
12
+ required(:model_name).filled(:string).description("Name of the model class")
13
+ required(:id).filled(:string).description("Primary key value of the record")
14
+ end
15
+
16
+ annotations(
17
+ read_only_hint: true,
18
+ open_world_hint: false
19
+ )
20
+
21
+ def execute(model_name:, id:)
22
+ info = Flehmen.model_registry.find_model(model_name)
23
+ return JSON.generate({ error: "Model not found: #{model_name}" }) unless info
24
+
25
+ record = info[:klass].find_by(info[:primary_key] => id)
26
+ return JSON.generate({ error: "Record not found: #{model_name}##{id}" }) unless record
27
+
28
+ serializer = Flehmen::Serializer.new
29
+ JSON.generate(serializer.serialize_record(record))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Tools
7
+ class ListModelsTool < Base
8
+ tool_name "flehmen_list_models"
9
+ description "List all available ActiveRecord models with their table names, column counts, and association counts"
10
+
11
+ annotations(
12
+ read_only_hint: true,
13
+ open_world_hint: false
14
+ )
15
+
16
+ def execute(**_args)
17
+ registry = Flehmen.model_registry
18
+ models = registry.model_names.map do |name|
19
+ info = registry.find_model(name)
20
+ {
21
+ name: name,
22
+ table_name: info[:table_name],
23
+ column_count: info[:columns].size,
24
+ association_count: info[:associations].size,
25
+ enum_count: info[:enums].size
26
+ }
27
+ end
28
+ JSON.generate(models)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Tools
7
+ class SearchRecordsTool < Base
8
+ tool_name "flehmen_search_records"
9
+ description 'Search records with filter conditions. Each condition has a field, operator (eq, not_eq, gt, gte, lt, lte, like, in, null, not_null), and value. Example conditions: [{"field":"status","operator":"eq","value":"active"}]'
10
+
11
+ arguments do
12
+ required(:model_name).filled(:string).description("Name of the model class")
13
+ optional(:conditions).filled(:string).description('JSON array of conditions: [{"field":"...", "operator":"...", "value":"..."}]')
14
+ optional(:order_by).filled(:string).description("Column name to order by")
15
+ optional(:order_dir).filled(:string).description("Order direction: 'asc' or 'desc'")
16
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100).description("Max records to return (capped by server config)")
17
+ optional(:offset).filled(:integer).value(gteq?: 0, lteq?: 10000).description("Number of records to skip for pagination")
18
+ end
19
+
20
+ annotations(
21
+ read_only_hint: true,
22
+ open_world_hint: false
23
+ )
24
+
25
+ def execute(model_name:, conditions: nil, order_by: nil, order_dir: "asc", limit: nil, offset: nil)
26
+ info = Flehmen.model_registry.find_model(model_name)
27
+ return JSON.generate({ error: "Model not found: #{model_name}" }) unless info
28
+
29
+ parsed_conditions = conditions ? JSON.parse(conditions) : []
30
+
31
+ builder = Flehmen::QueryBuilder.new(info)
32
+ records = builder.build(
33
+ conditions: parsed_conditions,
34
+ order_by: order_by,
35
+ order_dir: order_dir,
36
+ limit: limit,
37
+ offset: offset
38
+ )
39
+
40
+ serializer = Flehmen::Serializer.new
41
+ result = {
42
+ model: model_name,
43
+ count: records.size,
44
+ records: serializer.serialize_records(records)
45
+ }
46
+ JSON.generate(result)
47
+ rescue JSON::ParserError
48
+ JSON.generate({ error: "Invalid JSON in conditions parameter" })
49
+ rescue ArgumentError => e
50
+ JSON.generate({ error: e.message })
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flehmen
6
+ module Tools
7
+ class ShowAssociationsTool < Base
8
+ tool_name "flehmen_show_associations"
9
+ description "Navigate a record's associations. Given a model, record ID, and association name, returns the associated records."
10
+
11
+ arguments do
12
+ required(:model_name).filled(:string).description("Name of the source model class")
13
+ required(:id).filled(:string).description("Primary key of the source record")
14
+ required(:association_name).filled(:string).description("Name of the association to navigate")
15
+ optional(:limit).filled(:integer).value(gteq?: 1, lteq?: 100).description("Max associated records to return")
16
+ optional(:offset).filled(:integer).value(gteq?: 0, lteq?: 10000).description("Number of records to skip")
17
+ end
18
+
19
+ annotations(
20
+ read_only_hint: true,
21
+ open_world_hint: false
22
+ )
23
+
24
+ def execute(model_name:, id:, association_name:, limit: nil, offset: nil)
25
+ info = Flehmen.model_registry.find_model(model_name)
26
+ return JSON.generate({ error: "Model not found: #{model_name}" }) unless info
27
+
28
+ # Validate association name against declared associations
29
+ valid_associations = info[:associations].map { |a| a[:name] }
30
+ unless valid_associations.include?(association_name)
31
+ return JSON.generate({ error: "Unknown association: #{association_name}" })
32
+ end
33
+
34
+ record = info[:klass].find_by(info[:primary_key] => id)
35
+ return JSON.generate({ error: "Record not found: #{model_name}##{id}" }) unless record
36
+
37
+ assoc_meta = info[:associations].find { |a| a[:name] == association_name }
38
+ associated = record.public_send(association_name)
39
+
40
+ serializer = Flehmen::Serializer.new
41
+ max = Flehmen.configuration.max_results
42
+
43
+ if %w[has_many has_and_belongs_to_many].include?(assoc_meta[:type])
44
+ effective_limit = limit ? [limit.to_i, max].min : max
45
+ scope = associated.limit(effective_limit)
46
+ scope = scope.offset(offset.to_i) if offset && offset.to_i > 0
47
+ records = scope.to_a
48
+
49
+ JSON.generate({
50
+ source: "#{model_name}##{id}",
51
+ association: association_name,
52
+ type: assoc_meta[:type],
53
+ count: records.size,
54
+ records: serializer.serialize_records(records)
55
+ })
56
+ else
57
+ # belongs_to / has_one
58
+ if associated
59
+ JSON.generate({
60
+ source: "#{model_name}##{id}",
61
+ association: association_name,
62
+ type: assoc_meta[:type],
63
+ record: serializer.serialize_record(associated)
64
+ })
65
+ else
66
+ JSON.generate({
67
+ source: "#{model_name}##{id}",
68
+ association: association_name,
69
+ type: assoc_meta[:type],
70
+ record: nil
71
+ })
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flehmen
4
+ VERSION = "0.1.0"
5
+ end
data/lib/flehmen.rb ADDED
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+ require_relative "flehmen/version"
5
+ require_relative "flehmen/configuration"
6
+ require_relative "flehmen/model_registry"
7
+ require_relative "flehmen/field_filter"
8
+ require_relative "flehmen/query_builder"
9
+ require_relative "flehmen/serializer"
10
+ require_relative "flehmen/tools/base"
11
+ require_relative "flehmen/tools/list_models_tool"
12
+ require_relative "flehmen/tools/describe_model_tool"
13
+ require_relative "flehmen/tools/find_record_tool"
14
+ require_relative "flehmen/tools/search_records_tool"
15
+ require_relative "flehmen/tools/count_records_tool"
16
+ require_relative "flehmen/tools/show_associations_tool"
17
+ require_relative "flehmen/resources/schema_overview_resource"
18
+
19
+ module Flehmen
20
+ class << self
21
+ attr_writer :configuration
22
+
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ end
30
+
31
+ def reset_configuration!
32
+ @configuration = Configuration.new
33
+ @model_registry = nil
34
+ end
35
+
36
+ # Lazily discover models on first access
37
+ def model_registry
38
+ @model_registry || boot!
39
+ end
40
+
41
+ def boot!
42
+ @model_registry = ModelRegistry.new(configuration)
43
+ @model_registry.discover!
44
+ end
45
+
46
+ def start_server!
47
+ server = build_server
48
+ server.start
49
+ end
50
+
51
+ def mount_in_rails(app, options = {})
52
+ opts = {
53
+ name: "flehmen",
54
+ version: Flehmen::VERSION,
55
+ path_prefix: options.delete(:path_prefix) || "/mcp"
56
+ }.merge(options)
57
+
58
+ FastMcp.mount_in_rails(app, opts) do |server|
59
+ register_tools(server)
60
+ register_resources(server)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def build_server
67
+ server = FastMcp::Server.new(
68
+ name: "flehmen",
69
+ version: Flehmen::VERSION
70
+ )
71
+ register_tools(server)
72
+ register_resources(server)
73
+ server
74
+ end
75
+
76
+ def register_tools(server)
77
+ server.register_tool(Tools::ListModelsTool)
78
+ server.register_tool(Tools::DescribeModelTool)
79
+ server.register_tool(Tools::FindRecordTool)
80
+ server.register_tool(Tools::SearchRecordsTool)
81
+ server.register_tool(Tools::CountRecordsTool)
82
+ server.register_tool(Tools::ShowAssociationsTool)
83
+ end
84
+
85
+ def register_resources(server)
86
+ server.register_resource(Resources::SchemaOverviewResource)
87
+ end
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flehmen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ryosk7
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: fast-mcp
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activesupport
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: railties
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ description: A generic Ruby gem that auto-discovers ActiveRecord models and provides
69
+ read-only query tools via the Model Context Protocol (MCP) for Claude Desktop integration.
70
+ executables:
71
+ - flehmen
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - LICENSE.txt
76
+ - README.md
77
+ - bin/flehmen
78
+ - lib/flehmen.rb
79
+ - lib/flehmen/configuration.rb
80
+ - lib/flehmen/field_filter.rb
81
+ - lib/flehmen/model_registry.rb
82
+ - lib/flehmen/query_builder.rb
83
+ - lib/flehmen/resources/schema_overview_resource.rb
84
+ - lib/flehmen/serializer.rb
85
+ - lib/flehmen/tools/base.rb
86
+ - lib/flehmen/tools/count_records_tool.rb
87
+ - lib/flehmen/tools/describe_model_tool.rb
88
+ - lib/flehmen/tools/find_record_tool.rb
89
+ - lib/flehmen/tools/list_models_tool.rb
90
+ - lib/flehmen/tools/search_records_tool.rb
91
+ - lib/flehmen/tools/show_associations_tool.rb
92
+ - lib/flehmen/version.rb
93
+ homepage: https://github.com/ryosk7/flehmen
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.1.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.6.7
112
+ specification_version: 4
113
+ summary: MCP server gem that exposes Rails ActiveRecord models to Claude Desktop
114
+ test_files: []