Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ def self.cache_disabled?
false
end

def self.mcp_server_enabled?
config = ForestAdminAgent::Facades::Container.config_from_cache
config&.dig(:enable_mcp_server) == true
rescue StandardError
# If config is not available or an error occurs, default to MCP disabled
false
end

def self.reset_cached_routes!
@mutex.synchronize do
@cached_routes = nil
Expand Down Expand Up @@ -66,6 +74,16 @@ def self.routes
{ name: 'update_field', handler: -> { Resources::UpdateField.new.routes } }
]

# Add MCP routes only if enabled via configuration
if mcp_server_enabled?
route_sources += [
{ name: 'mcp_oauth_metadata', handler: -> { Mcp::OAuthMetadata.new.routes } },
{ name: 'mcp_oauth_authorize', handler: -> { Mcp::OAuthAuthorize.new.routes } },
{ name: 'mcp_oauth_token', handler: -> { Mcp::OAuthToken.new.routes } },
{ name: 'mcp_endpoint', handler: -> { Mcp::McpEndpoint.new.routes } }
]
end

all_routes = {}

route_sources.each do |source|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
require 'faraday'
require 'json'

module ForestAdminAgent
module Mcp
class ActivityLogCreator
ACTION_TO_TYPE = {
'index' => 'read',
'search' => 'read',
'filter' => 'read',
'listHasMany' => 'read',
'actionForm' => 'read',
'action' => 'write',
'create' => 'write',
'update' => 'write',
'delete' => 'write',
'availableActions' => 'read',
'availableCollections' => 'read'
}.freeze

def self.create(forest_server_url, auth_info, action, extra = {})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues:

1. Function with many parameters (count = 4): create [qlty:function-parameters]


2. Function with high complexity (count = 5): create [qlty:function-complexity]

type = ACTION_TO_TYPE[action]
raise "Unknown action type: #{action}" unless type

forest_server_token = auth_info.dig(:extra, :forest_server_token)
rendering_id = auth_info.dig(:extra, :rendering_id)

client = Faraday.new(forest_server_url) do |conn|
conn.headers['Content-Type'] = 'application/json'
conn.headers['Forest-Application-Source'] = 'MCP'
conn.headers['Authorization'] = "Bearer #{forest_server_token}"
end

records = extra[:record_ids] || (extra[:record_id] ? [extra[:record_id]] : [])

payload = {
data: {
id: 1,
type: 'activity-logs-requests',
attributes: {
type: type,
action: action,
label: extra[:label],
records: records.map(&:to_s)
}.compact,
relationships: {
rendering: {
data: {
id: rendering_id.to_s,
type: 'renderings'
}
},
collection: {
data: if extra[:collection_name]
{
id: extra[:collection_name],
type: 'collections'
}
end
}
}
}
}

response = client.post('/api/activity-logs-requests', payload.to_json)

return if response.success?

Facades::Container.logger.log(
'Warn',
"[MCP] Failed to create activity log: #{response.body}"
)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
require 'faraday'
require 'json'

module ForestAdminAgent
module Mcp
class AgentCaller
def initialize(auth_info)
@token = auth_info[:token]
@forest_server_token = auth_info.dig(:extra, :forest_server_token)
@api_endpoint = auth_info.dig(:extra, :environment_api_endpoint)
end

def collection(name)
CollectionClient.new(name, @forest_server_token, @api_endpoint)
end

class CollectionClient
def initialize(name, token, api_endpoint)
@name = name
@token = token
@api_endpoint = api_endpoint
end

def list(params = {})
payload = build_list_payload(params)

response = http_client.post("/forest/rpc/#{@name}/list", payload.to_json)

handle_response(response)
end

private

def build_list_payload(params)
payload = {}

payload[:filters] = params[:filters] if params[:filters]

payload[:search] = params[:search] if params[:search]

if params[:sort]
payload[:sort] = [
{
field: params[:sort][:field],
ascending: params[:sort][:ascending]
}
]
end

payload
end

def handle_response(response)
unless response.success?
error_body = parse_error_body(response)
raise ForestAdminAgent::Http::Exceptions::BadRequestError, error_body
end

JSON.parse(response.body)
end

def parse_error_body(response)
body = response.body

return body if body.is_a?(String) && !body.empty?

return body['error'] || body['message'] || body.to_json if body.is_a?(Hash)

"Request failed with status #{response.status}"
end

def http_client
@http_client ||= Faraday.new(@api_endpoint) do |conn|
conn.headers['Content-Type'] = 'application/json'
conn.headers['Authorization'] = "Bearer #{@token}"
conn.ssl.verify = !ForestAdminAgent::Facades::Container.cache(:debug)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module ForestAdminAgent
module Mcp
class ErrorParser
def self.parse(error)
return error.message if error.is_a?(StandardError)

parse_from_string(error.to_s)
end

def self.parse_from_string(error_string)
return nil if error_string.nil? || error_string.empty?

# Try to parse as JSON
begin
parsed = JSON.parse(error_string)
extract_from_json(parsed)
rescue JSON::ParserError
# Not JSON, return as-is
error_string
end
end

def self.extract_from_json(parsed)
# Handle JSON:API error format
if parsed.is_a?(Hash) && parsed['errors'].is_a?(Array)
errors = parsed['errors']
return errors.filter_map { |e| e['detail'] || e['title'] || e['name'] }.join(', ')
end

# Handle simple error object
return parsed['detail'] || parsed['message'] || parsed['error'] || parsed.to_s if parsed.is_a?(Hash)

parsed.to_s
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
module ForestAdminAgent
module Mcp
class FilterSchema
OPERATORS = %w[
Equal
NotEqual
LessThan
GreaterThan
LessThanOrEqual
GreaterThanOrEqual
Match
NotContains
NotIContains
LongerThan
ShorterThan
IncludesAll
IncludesNone
Today
Yesterday
PreviousMonth
PreviousQuarter
PreviousWeek
PreviousYear
PreviousMonthToDate
PreviousQuarterToDate
PreviousWeekToDate
PreviousXDaysToDate
PreviousXDays
PreviousYearToDate
Present
Blank
Missing
In
NotIn
StartsWith
EndsWith
Contains
IStartsWith
IEndsWith
IContains
Like
ILike
Before
After
AfterXHoursAgo
BeforeXHoursAgo
Future
Past
].freeze

AGGREGATORS = %w[And Or].freeze

def self.json_schema
{
oneOf: [
leaf_schema,
branch_schema
]
}
end

def self.leaf_schema
{
type: 'object',
properties: {
field: { type: 'string' },
operator: { type: 'string', enum: OPERATORS },
value: {}
},
required: %w[field operator]
}
end

def self.branch_schema
{
type: 'object',
properties: {
aggregator: { type: 'string', enum: AGGREGATORS },
conditions: {
type: 'array',
items: { '$ref': '#' }
}
},
required: %w[aggregator conditions]
}
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module ForestAdminAgent
module Mcp
class InvalidClientError < StandardError; end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module ForestAdminAgent
module Mcp
class InvalidRequestError < StandardError; end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module ForestAdminAgent
module Mcp
class InvalidTokenError < StandardError; end
end
end
Loading
Loading