Module: OpenLoam::Mcp
- Defined in:
- lib/open_loam/mcp.rb,
lib/open_loam/mcp/server.rb
Overview
An MCP (Model Context Protocol) server surface exposing OpenLoam to an AI agent — tools-only v1 (L-302). It lets an agent DISCOVER the domain (entities, schema, policy, workflow) and READ tenant-scoped records, and PROPOSE writes that are STAGED for human approval — never committed. Everything runs inside the tenant + actor of the API token the server authenticated with; whatever that user may do, no more. Approval authority stays with a human in the admin.
Two layers: the tool methods (pure, assume OpenLoam::Current is established) and
handle_jsonrpc (a pure JSON-RPC dispatcher for initialize / tools/list /
tools/call). The stdio transport (newline-delimited JSON-RPC) lives in
OpenLoam::Mcp::Server.
SECURITY posture, reusing OpenLoam's existing gates:
* entity names resolve against an allowlist (API-exposed TenantRecord
descendants), never a bare constantize;
* query filters/order are whitelisted to real columns (or a known custom
field, which carries the L-711 read-ACL); a filter on a field the role
can't read is refused (no inference oracle);
* query output emits only policy-readable fields per record (encrypted
values decrypted, blind-index columns dropped);
* a staged write accepts only policy-writable columns, refuses the workflow
column (that goes through a transition), and refuses id/tenant_id/
lock_version.
Defined Under Namespace
Constant Summary collapse
- PROTOCOL_VERSION =
"2025-06-18".freeze
- MAX_LIMIT =
100- FILTER_OPS =
%w[eq neq contains gt gte lt lte present].freeze
- TOOLS =
[ { name: "list_entities", description: "List the business entities available in this OpenLoam tenant.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "describe_entity", description: "Describe one entity: its columns and types, custom fields, workflow, and which fields the current role may read/write.", inputSchema: { type: "object", properties: { entity: { type: "string", description: "Entity name, e.g. \"Equipment\"." } }, required: [ "entity" ], additionalProperties: false } }, { name: "query_entity", description: "Read tenant-scoped records of an entity. Returns only fields the current role may read. Filters and sort are whitelisted; limit is capped at 100.", inputSchema: { type: "object", properties: { entity: { type: "string" }, filters: { type: "array", items: { type: "object", properties: { field: { type: "string" }, op: { type: "string", enum: FILTER_OPS }, value: {} }, required: [ "field" ], additionalProperties: false } }, order: { type: "string", description: "A real column name to sort by." }, dir: { type: "string", enum: [ "asc", "desc" ] }, limit: { type: "integer", minimum: 1, maximum: MAX_LIMIT } }, required: [ "entity" ], additionalProperties: false } }, { name: "stage_write", description: "PROPOSE an update to one record. It is staged as a OpenLoam PendingAction for a human manager to approve — it does NOT take effect until approved. Only policy-writable columns are accepted; the workflow column is refused (use a transition).", inputSchema: { type: "object", properties: { entity: { type: "string" }, id: { type: "integer" }, changes: { type: "object", description: "field => new value, real writable columns only." } }, required: [ "entity", "id", "changes" ], additionalProperties: false } } ].freeze
Class Method Summary collapse
- .api_exposed?(model) ⇒ Boolean
-
.apply_filters(model, scope, filters, policy) ⇒ Object
--- internals ---.
- .apply_order(model, scope, order, dir) ⇒ Object
-
.call_tool(name, args) ⇒ Object
--- protocol ---.
- .column_filter(model, scope, field, op, value) ⇒ Object
- .custom_field?(model, field) ⇒ Boolean
- .describe_entity(entity:) ⇒ Object
-
.entities ⇒ Object
--- entity allowlist ---.
- .err(id, code, message) ⇒ Object
-
.handle_jsonrpc(request) ⇒ Object
Pure JSON-RPC dispatch.
-
.list_entities ⇒ Object
--- tools ---.
- .ok(id, **result) ⇒ Object
- .query_entity(entity:, filters: [], order: nil, dir: "asc", limit: MAX_LIMIT) ⇒ Object
- .resolve!(entity) ⇒ Object
- .serialize(record, policy) ⇒ Object
- .stage_write(entity:, id:, changes:) ⇒ Object
Class Method Details
.api_exposed?(model) ⇒ Boolean
102 103 104 |
# File 'lib/open_loam/mcp.rb', line 102 def api_exposed?(model) "Api::#{model.model_name.plural.camelize}Controller".safe_constantize.present? end |
.apply_filters(model, scope, filters, policy) ⇒ Object
--- internals ---
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/open_loam/mcp.rb', line 231 def apply_filters(model, scope, filters, policy) Array(filters).each do |raw| field = raw["field"].to_s op = (raw["op"] || "eq").to_s raise ToolError, "unknown op #{op.inspect}" unless FILTER_OPS.include?(op) value = raw["value"] if model.column_names.include?(field) raise ToolError, "#{field} is not readable for this role" unless policy.readable?(field) scope = column_filter(model, scope, field, op, value) elsif custom_field?(model, field) # CustomFieldIndex.filter carries its own L-711 read-ACL for the current # actor; surface its refusal as a clean tool error. begin scope = scope.merge(OpenLoam::CustomFieldIndex.filter(model, field, op, value)) rescue OpenLoam::FieldAccessError => error raise ToolError, error. end else raise ToolError, "unknown field #{field.inspect}" end end scope end |
.apply_order(model, scope, order, dir) ⇒ Object
270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/open_loam/mcp.rb', line 270 def apply_order(model, scope, order, dir) return scope.order(id: :asc) if order.blank? raise ToolError, "unknown sort column #{order.inspect}" unless model.column_names.include?(order.to_s) # Same gate apply_filters enforces. Values are redacted from the result, # but ordering by a restricted column still leaks its ranking — which is # the inference oracle the filter gate exists to close. policy = OpenLoam::Policy.for_model(model, OpenLoam::Current.actor) raise ToolError, "#{order} is not readable for this role" unless policy.readable?(order.to_s) direction = dir.to_s.casecmp("desc").zero? ? :desc : :asc scope.reorder(order.to_s => direction) end |
.call_tool(name, args) ⇒ Object
--- protocol ---
193 194 195 196 197 198 199 200 201 202 |
# File 'lib/open_loam/mcp.rb', line 193 def call_tool(name, args) kwargs = (args || {}).transform_keys(&:to_sym) case name when "list_entities" then list_entities when "describe_entity" then describe_entity(**kwargs.slice(:entity)) when "query_entity" then query_entity(**kwargs.slice(:entity, :filters, :order, :dir, :limit)) when "stage_write" then stage_write(**kwargs.slice(:entity, :id, :changes)) else raise ToolError, "unknown tool #{name.inspect}" end end |
.column_filter(model, scope, field, op, value) ⇒ Object
256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/open_loam/mcp.rb', line 256 def column_filter(model, scope, field, op, value) column = model.connection.quote_column_name(field) # field is a real column name (whitelisted) case op when "eq" then scope.where(field => value) when "neq" then scope.where.not(field => value) when "contains" then scope.where("#{column} LIKE ?", "%#{value.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" }}%") when "present" then scope.where.not(field => [ nil, "" ]) when "gt" then scope.where("#{column} > ?", value) when "gte" then scope.where("#{column} >= ?", value) when "lt" then scope.where("#{column} < ?", value) when "lte" then scope.where("#{column} <= ?", value) end end |
.custom_field?(model, field) ⇒ Boolean
301 302 303 |
# File 'lib/open_loam/mcp.rb', line 301 def custom_field?(model, field) model.respond_to?(:custom_field_definitions) && model.custom_field_definitions.exists?(name: field) end |
.describe_entity(entity:) ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/open_loam/mcp.rb', line 118 def describe_entity(entity:) model = resolve!(entity) policy = OpenLoam::Policy.for(model.new) columns = model.columns.reject { |c| c.name.end_with?("_hash") }.map do |col| { name: col.name, type: col..type.to_s, readable: policy.readable?(col.name), writable: policy.writable?(col.name) } end custom = if model.respond_to?(:custom_field_definitions) model.custom_field_definitions.map do |definition| { name: definition.name, type: definition.field_type, readable: policy.custom_field_readable?(definition.name), writable: policy.custom_field_writable?(definition.name) } end else [] end workflow = if model.respond_to?(:open_loam_workflow) && model.open_loam_workflow wf = model.open_loam_workflow { column: wf.column, states: wf.states, transitions: wf.transitions.values.map { |t| { name: t.name.to_s, from: t.from, to: t.to, roles: Array(t.roles).map(&:to_s) } } } end { name: model.name, columns: columns, custom_fields: custom, workflow: workflow }.compact end |
.entities ⇒ Object
--- entity allowlist ---
92 93 94 95 96 97 98 99 100 |
# File 'lib/open_loam/mcp.rb', line 92 def entities # Models are lazy-loaded (Zeitwerk), so `descendants` is only complete after # an eager load — same as OpenLoam::OpenApi's discovery. Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) OpenLoam::TenantRecord.descendants .reject { |model| model.name.blank? } .select { |model| api_exposed?(model) } .sort_by(&:name) end |
.err(id, code, message) ⇒ Object
309 310 311 |
# File 'lib/open_loam/mcp.rb', line 309 def err(id, code, ) { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => } } end |
.handle_jsonrpc(request) ⇒ Object
Pure JSON-RPC dispatch. Returns a response Hash, or nil for a notification (no reply). Never raises for tool faults — those become an isError result.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/open_loam/mcp.rb', line 206 def handle_jsonrpc(request) id = request["id"] case request["method"] when "initialize" ok(id, protocolVersion: request.dig("params", "protocolVersion") || PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: "open_loam", version: OpenLoam::VERSION }) when "tools/list" ok(id, tools: TOOLS) when "tools/call" begin data = call_tool(request.dig("params", "name"), request.dig("params", "arguments")) ok(id, content: [ { type: "text", text: JSON.generate(data) } ]) rescue ToolError, OpenLoam::Error, ActiveRecord::RecordNotFound => error ok(id, isError: true, content: [ { type: "text", text: error. } ]) end when "notifications/initialized", "notifications/cancelled", nil nil # notifications get no response else err(id, -32601, "method not found: #{request["method"]}") end end |
.list_entities ⇒ Object
--- tools ---
114 115 116 |
# File 'lib/open_loam/mcp.rb', line 114 def list_entities { entities: entities.map { |m| { name: m.name, plural: m.model_name.plural } } } end |
.ok(id, **result) ⇒ Object
305 306 307 |
# File 'lib/open_loam/mcp.rb', line 305 def ok(id, **result) { "jsonrpc" => "2.0", "id" => id, "result" => result } end |
.query_entity(entity:, filters: [], order: nil, dir: "asc", limit: MAX_LIMIT) ⇒ Object
146 147 148 149 150 151 152 153 154 155 |
# File 'lib/open_loam/mcp.rb', line 146 def query_entity(entity:, filters: [], order: nil, dir: "asc", limit: MAX_LIMIT) model = resolve!(entity) policy = OpenLoam::Policy.for(model.new) scope = apply_filters(model, model.all, filters, policy) scope = apply_order(model, scope, order, dir) capped = [ [ limit.to_i, 1 ].max, MAX_LIMIT ].min records = scope.limit(capped).map { |record| serialize(record, policy) } { entity: model.name, count: records.size, records: records } end |
.resolve!(entity) ⇒ Object
106 107 108 109 110 |
# File 'lib/open_loam/mcp.rb', line 106 def resolve!(entity) name = entity.to_s entities.find { |m| m.name == name || m.model_name.plural == name } || raise(ToolError, "unknown entity #{entity.inspect}") end |
.serialize(record, policy) ⇒ Object
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/open_loam/mcp.rb', line 284 def serialize(record, policy) model = record.class encrypted = model.respond_to?(:open_loam_encrypted_attributes) ? model.open_loam_encrypted_attributes.map(&:to_s) : [] json = {} policy.readable_fields(model.column_names).each do |col| next if col.end_with?("_hash") json[col] = encrypted.include?(col) ? record.public_send(col) : record[col] end if model.respond_to?(:custom_field_definitions) model.custom_field_definitions.each do |definition| json["cf_#{definition.name}"] = record.custom_field(definition.name) if policy.custom_field_readable?(definition.name) end end json end |
.stage_write(entity:, id:, changes:) ⇒ Object
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/open_loam/mcp.rb', line 157 def stage_write(entity:, id:, changes:) model = resolve!(entity) record = model.find(id) # tenant-scoped policy = OpenLoam::Policy.for(record) workflow_column = model.respond_to?(:open_loam_workflow) ? model.open_loam_workflow&.column.to_s : nil clean = {} (changes || {}).each do |field, value| field = field.to_s # deleted_at belongs with the rest: staging a direct soft-delete would # bypass a destroy? override and the audited action label, which is # exactly what OpenLoam::Bulk and BusinessRules both refuse. raise ToolError, "#{field} cannot be set" if %w[id tenant_id lock_version deleted_at].include?(field) raise ToolError, "#{field} changes go through a workflow transition, not a direct write" if field == workflow_column if model.column_names.include?(field) raise ToolError, "#{field} is not writable for this role" unless policy.writable?(field) clean[field] = value elsif custom_field?(model, field) raise ToolError, "custom-field writes over MCP are not supported in v1" else raise ToolError, "unknown field #{field.inspect}" end end raise ToolError, "no writable changes" if clean.empty? action = OpenLoam::PendingActions.stage( summary: "MCP proposal: update #{model.name}##{id}", on: record, action: :update, changes: clean ) { staged: true, pending_action_id: action.id, summary: action.summary, changes: clean, note: "Staged for human approval — not applied until a manager approves it." } end |