Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,5 @@ gem "phlex-rails", "~> 2.0"
gem "tailwind_merge", "~> 1.2"

gem "wicked", "~> 2.0"

gem "fast-mcp"
41 changes: 41 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,45 @@ GEM
dotenv (= 2.8.1)
railties (>= 3.2)
drb (2.2.3)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.2.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.3.1)
dry-initializer (3.2.0)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-schema (1.16.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.1)
dry-initializer (~> 3.2)
dry-logic (~> 1.6)
dry-types (~> 1.9, >= 1.9.1)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erubi (1.12.0)
et-orbi (1.2.11)
tzinfo
fast-mcp (1.6.0)
addressable (~> 2.8)
base64
dry-schema (~> 1.14)
json (~> 2.0)
mime-types (~> 3.4)
rack (>= 2.0, < 4.0)
ffi (1.16.3)
fugit (1.11.0)
et-orbi (~> 1, >= 1.2.11)
Expand Down Expand Up @@ -176,6 +212,10 @@ GEM
marcel (1.0.4)
matrix (0.4.2)
method_source (1.1.0)
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2026.0224)
mini_mime (1.1.2)
minitest (5.22.3)
msgpack (1.8.0)
Expand Down Expand Up @@ -398,6 +438,7 @@ DEPENDENCIES
devise_invitable (~> 2.0.9)
discard
dotenv-rails (~> 2.1, >= 2.1.1)
fast-mcp
hotwire-livereload
invisible_captcha
jbuilder
Expand Down
67 changes: 67 additions & 0 deletions app/tools/application_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
class ApplicationTool < ActionTool::Base
include ActionPolicy::Behaviour

authorize :user, through: :current_user

private

def current_user
@current_user ||= begin
token = (headers&.dig("Authorization") || headers&.dig("authorization"))&.delete_prefix("Bearer ")
User.find_by_api_token(token)
end
end

def resolve_organization(organization_id = nil)
raise "Unauthorized" unless current_user

if organization_id.present?
org = Organization.find(organization_id)
access = current_user.access_info(org)
raise ActiveRecord::RecordNotFound, "Organization not found or no access" unless access
else
access = current_user.access_info
raise "No default organization. Pass organization_id argument." unless access
end

current_user.define_singleton_method(:access_info) do |o = nil|
return current_user.access_infos.find_by(organization: o) if o
access
end

access.organization
end

def format_error(exception)
case exception
when ActiveRecord::RecordNotFound
JSON.generate({ error: "Not found" })
when ActiveRecord::RecordInvalid
JSON.generate({ error: exception.record.errors.full_messages.join(", ") })
when ActionPolicy::Unauthorized
JSON.generate({ error: "Forbidden" })
else
JSON.generate({ error: exception.message })
end
end

def format_time_reg(tr)
{
id: tr.id,
notes: tr.notes,
minutes: tr.minutes,
date_worked: tr.date_worked,
assigned_task_id: tr.assigned_task_id,
user_id: tr.user_id,
start_time: tr.start_time,
created_at: tr.created_at,
updated_at: tr.updated_at,
current_minutes: tr.current_minutes,
active: tr.active?,
task_name: tr.task&.name,
project_id: tr.project&.id,
project_name: tr.project&.name,
client_name: tr.client&.name
}
end
end
27 changes: 27 additions & 0 deletions app/tools/create_client_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class CreateClientTool < ApplicationTool
description "Create a new client in the organization"

arguments do
required(:name).filled(:string).description("Client name (2-60 characters)")
optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)")
end

def call(name:, organization_id: nil)
org = resolve_organization(organization_id)

client = Client.new(name: name)
client.organization = org
authorize! client, with: ClientPolicy, to: :create?
client.save!

JSON.generate({
id: client.id,
name: client.name,
organization_id: client.organization_id,
created_at: client.created_at,
updated_at: client.updated_at
})
rescue => e
format_error(e)
end
end
51 changes: 51 additions & 0 deletions app/tools/create_project_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
class CreateProjectTool < ApplicationTool
description "Create a new project"

arguments do
required(:name).filled(:string).description("Project name (2-60 characters)")
required(:client_id).filled(:integer).description("Client ID to associate with")
optional(:description).filled(:string).description("Project description")
optional(:billable).filled(:bool).description("Whether the project is billable (default: false)")
optional(:rate_currency).filled(:string).description("Hourly rate in currency format (e.g. '1,25' for 1.25)")
optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)")
end

def call(name:, client_id:, description: nil, billable: nil, rate_currency: nil, organization_id: nil)
resolve_organization(organization_id)

project = Project.new(name: name, client_id: client_id)
project.description = description if description
project.billable = billable unless billable.nil?
project.rate_currency = rate_currency if rate_currency
project.onboarding = true # Skip assigned task validation for creation via tool

authorize! project, with: ProjectPolicy, to: :create?
project.save!

assigned_tasks = project.active_assigned_tasks.includes(:task)

JSON.generate({
id: project.id,
name: project.name,
description: project.description,
billable: project.billable,
rate: project.rate,
rate_currency: project.rate_currency,
client_id: project.client_id,
client_name: project.client.name,
created_at: project.created_at,
updated_at: project.updated_at,
assigned_tasks: assigned_tasks.map { |at|
{
id: at.id,
task_id: at.task_id,
task_name: at.task.name,
rate: at.rate,
rate_currency: at.rate_currency
}
}
})
rescue => e
format_error(e)
end
end
28 changes: 28 additions & 0 deletions app/tools/create_time_reg_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class CreateTimeRegTool < ApplicationTool
description "Create a new time registration"

arguments do
required(:assigned_task_id).filled(:integer).description("Assigned task ID")
required(:minutes).filled(:integer).description("Minutes worked (0-1440)")
required(:date_worked).filled(:string).description("Date worked (YYYY-MM-DD)")
optional(:notes).filled(:string).description("Notes about the work done (max 255 characters)")
optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)")
end

def call(assigned_task_id:, minutes:, date_worked:, notes: nil, organization_id: nil)
resolve_organization(organization_id)

time_reg = current_user.time_regs.new(
assigned_task_id: assigned_task_id,
minutes: minutes,
date_worked: date_worked,
notes: notes
)
authorize! time_reg, with: TimeRegPolicy, to: :create?
time_reg.save!

JSON.generate(format_time_reg(time_reg))
rescue => e
format_error(e)
end
end
20 changes: 20 additions & 0 deletions app/tools/delete_client_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class DeleteClientTool < ApplicationTool
description "Delete a client (soft delete)"

arguments do
required(:id).filled(:integer).description("Client ID")
optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)")
end

def call(id:, organization_id: nil)
resolve_organization(organization_id)

client = authorized_scope(Client, type: :relation, with: ClientPolicy).find(id)
authorize! client, with: ClientPolicy, to: :destroy?
client.discard!

JSON.generate({ status: "deleted", id: client.id })
rescue => e
format_error(e)
end
end
20 changes: 20 additions & 0 deletions app/tools/delete_project_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class DeleteProjectTool < ApplicationTool
description "Delete a project (soft delete)"

arguments do
required(:id).filled(:integer).description("Project ID")
optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)")
end

def call(id:, organization_id: nil)
resolve_organization(organization_id)

project = authorized_scope(Project, type: :relation, with: ProjectPolicy).find(id)
authorize! project, with: ProjectPolicy, to: :destroy?
project.discard!

JSON.generate({ status: "deleted", id: project.id })
rescue => e
format_error(e)
end
end
20 changes: 20 additions & 0 deletions app/tools/delete_time_reg_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class DeleteTimeRegTool < ApplicationTool
description "Delete a time registration (soft delete)"

arguments do
required(:id).filled(:integer).description("Time registration ID")
optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)")
end

def call(id:, organization_id: nil)
resolve_organization(organization_id)

time_reg = authorized_scope(TimeReg, type: :relation, as: :own).find(id)
authorize! time_reg, with: TimeRegPolicy, to: :destroy?
time_reg.discard!

JSON.generate({ status: "deleted", id: time_reg.id })
rescue => e
format_error(e)
end
end
28 changes: 28 additions & 0 deletions app/tools/get_current_user_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class GetCurrentUserTool < ApplicationTool
description "Get the currently authenticated user's information"

arguments do
end

def call
raise "Unauthorized" unless current_user

user = current_user
org = user.current_organization

JSON.generate({
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
locale: user.locale,
name: user.name,
created_at: user.created_at,
updated_at: user.updated_at,
has_api_token: user.api_token_digest.present?,
current_organization: org ? { id: org.id, name: org.name } : nil
})
rescue => e
format_error(e)
end
end
Loading
Loading