Espone API JSON e MCP HTTP per far lavorare gli agenti sul CRM.
Gli agenti autenticati con token Bearer possono leggere today/pipeline e annotare attività, con host MCP allineati a quelli di produzione. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"eminuxcrm": {
|
||||
"url": "http://localhost:3001/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${env:CRM_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Come gli agenti usano eminuxCRM (MCP) per task, pipeline e follow-up
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Quando l'utente chiede di interagire con il CRM, usa i tool MCP **eminuxcrm** (non curl). Serve il CRM su localhost:3001 e `CRM_API_TOKEN`.
|
||||
|
||||
Flusso: `list_projects` → `today` → `search` / `get_organization` → `create_activity` / `complete_task` / `create_task` / `update_opportunity_stage`.
|
||||
|
||||
Non inviare mailing, non cancellare record, non gestire utenti. Annota in timeline quello che fai.
|
||||
@@ -27,3 +27,7 @@ SMTP_DOMAIN=
|
||||
|
||||
# Timezone
|
||||
TZ=Europe/Rome
|
||||
|
||||
# Agenti (Cursor CLI / Claude Code / Codex). Crea il token in CRM → Token API.
|
||||
# CRM_API_TOKEN=
|
||||
# CRM_MCP_URL=http://localhost:3001/mcp
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"eminuxcrm": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3001/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${CRM_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# eminuxCRM — istruzioni per agenti (Cursor CLI, Claude Code, Codex)
|
||||
|
||||
Quando l'utente chiede di **interagire con il CRM**, lavorare su prospect, pipeline, task, follow-up o "cosa fare oggi", usa i tool MCP **eminuxcrm**. Non usare `curl` sull'API se i tool sono disponibili.
|
||||
|
||||
## Come collegarti
|
||||
|
||||
1. Il CRM deve essere avviato (`docker compose up`, porta **3001**).
|
||||
2. Crea un token in UI: **Token API** (menu utente).
|
||||
3. Esporta il token **prima** di lanciare l'agente:
|
||||
|
||||
```bash
|
||||
export CRM_API_TOKEN='crm_…'
|
||||
```
|
||||
|
||||
Endpoint MCP: `http://localhost:3001/mcp` (Bearer). In produzione usa l’URL pubblico del CRM (`https://<host>/mcp`). Config già nel repo: `.cursor/mcp.json` (Cursor / Cursor CLI) e `.mcp.json` (Claude Code). Per il CRM in LAN/produzione cambia `url` e tieni il token in `CRM_API_TOKEN`.
|
||||
|
||||
## Flusso di lavoro
|
||||
|
||||
1. `list_projects` — ottieni i `project_code` (matchlivetv, riskmeter, cardoo, …).
|
||||
2. `today` con quel codice — task scaduti / oggi / in arrivo e opportunità ferme.
|
||||
3. `search` o `get_organization` per il contesto.
|
||||
4. Agisci con `create_activity`, `complete_task`, `create_task`, `update_opportunity_stage`.
|
||||
|
||||
## Limiti
|
||||
|
||||
- Non inviare mailing, non cancellare record, non gestire utenti o SMTP.
|
||||
- Restai nello scope dei progetti abilitati per il token.
|
||||
- Annota sempre in timeline (`create_activity`) quello che fai, così l'utente lo vede in scheda.
|
||||
|
||||
Frase tipo: *«Interagisci col CRM su MatchLiveTV: dimmi cosa c'è da fare oggi e completa i follow-up ovvi.»*
|
||||
@@ -0,0 +1,3 @@
|
||||
# eminuxCRM — Claude Code
|
||||
|
||||
Leggi [AGENTS.md](AGENTS.md). I tool MCP `eminuxcrm` sono in `.mcp.json` (`http://localhost:3001/mcp`). Serve `CRM_API_TOKEN` nell'ambiente e il CRM avviato.
|
||||
@@ -11,6 +11,7 @@ gem "tailwindcss-rails"
|
||||
gem "bcrypt", "~> 3.1.7"
|
||||
gem "pagy", "~> 9.3"
|
||||
gem "csv"
|
||||
gem "mcp"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "bootsnap", require: false
|
||||
gem "image_processing", "~> 1.2"
|
||||
|
||||
@@ -113,6 +113,7 @@ GEM
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
hana (1.3.7)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
@@ -129,6 +130,11 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (2.21.2)
|
||||
json_schemer (2.5.0)
|
||||
bigdecimal
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
simpleidn (~> 0.2)
|
||||
language_server-protocol (3.17.0.6)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
@@ -143,6 +149,8 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.3)
|
||||
mcp (1.4.0)
|
||||
json_schemer (>= 2.4)
|
||||
mini_magick (5.3.3)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
@@ -273,6 +281,7 @@ GEM
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
simpleidn (0.3.0)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
tailwindcss-rails (4.6.0)
|
||||
@@ -318,6 +327,7 @@ DEPENDENCIES
|
||||
debug
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
mcp
|
||||
pagy (~> 9.3)
|
||||
pg (~> 1.1)
|
||||
propshaft
|
||||
|
||||
@@ -163,6 +163,66 @@ Pipeline stages: Da contattare → Contattato → Ha risposto → Interessato
|
||||
4. Metti un reverse proxy (Caddy/Nginx) con HTTPS davanti alla porta 3000
|
||||
5. Esegui backup periodici con `bin/backup`
|
||||
|
||||
## API JSON per agenti
|
||||
|
||||
I client HTML restano su cookie di sessione. Gli agenti (Cursor, Claude Code, Codex) usano un token Bearer.
|
||||
|
||||
1. Nel CRM: **Token API** (menu utente, oppure Impostazioni piattaforma se sei admin)
|
||||
2. Crea un token: il valore `crm_…` si vede **una sola volta**
|
||||
3. Header: `Authorization: Bearer crm_…`
|
||||
|
||||
Endpoint v1 (tutti tranne `GET /api/v1/projects` richiedono `:project_code`):
|
||||
|
||||
| Metodo | Path | Ruolo |
|
||||
|--------|------|--------|
|
||||
| GET | `/api/v1/projects` | progetti accessibili |
|
||||
| GET | `/api/v1/p/:project_code/today` | task scaduti / oggi / in arrivo + opportunità ferme |
|
||||
| GET | `/api/v1/p/:project_code/search?q=` | org, contatti, opportunità |
|
||||
| GET | `/api/v1/p/:project_code/organizations/:id` | scheda operativa |
|
||||
| POST | `/api/v1/p/:project_code/tasks` | crea task |
|
||||
| POST | `/api/v1/p/:project_code/tasks/:id/complete` | completa task |
|
||||
| POST | `/api/v1/p/:project_code/activities` | annota timeline |
|
||||
| PATCH | `/api/v1/p/:project_code/opportunities/:id/stage` | cambia stage pipeline |
|
||||
|
||||
L’agente agisce come l’utente del token (stessi progetti, stesso `created_by`). Fuori da questa v1: mailing, utenti, delete, import CSV.
|
||||
|
||||
Esempio:
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer crm_…" \
|
||||
http://localhost:3001/api/v1/p/matchlivetv/today
|
||||
```
|
||||
|
||||
## MCP (Cursor CLI / Claude Code / Codex)
|
||||
|
||||
Gli agenti parlano con il CRM su **HTTP**: `http://localhost:3001/mcp` (stesso Bearer del token API). Non serve Ruby sull’host.
|
||||
|
||||
1. Avvia il CRM (`docker compose up`)
|
||||
2. Crea un token in **Token API**
|
||||
3. Esporta il token e lancia l’agente dalla root del repo:
|
||||
|
||||
```bash
|
||||
export CRM_API_TOKEN='crm_…'
|
||||
# Cursor CLI:
|
||||
agent "Interagisci col CRM su MatchLiveTV: cosa c'è da fare oggi?"
|
||||
# Claude Code:
|
||||
claude "Interagisci col CRM su MatchLiveTV: cosa c'è da fare oggi?"
|
||||
```
|
||||
|
||||
Config già nel repo (il token sta solo in env, non nei file):
|
||||
|
||||
- Cursor / Cursor CLI: [`.cursor/mcp.json`](.cursor/mcp.json)
|
||||
- Claude Code: [`.mcp.json`](.mcp.json)
|
||||
- Codex: copia [mcp/codex.config.toml.example](mcp/codex.config.toml.example) in `~/.codex/config.toml`
|
||||
|
||||
Istruzioni per gli agenti: [AGENTS.md](AGENTS.md).
|
||||
|
||||
Tool: `list_projects`, `today`, `search`, `get_organization`, `create_task`, `complete_task`, `create_activity`, `update_opportunity_stage`.
|
||||
|
||||
Stdio (`mcp/server.rb` / `bin/crm-mcp`) resta come alternativa se un client non parla HTTP.
|
||||
|
||||
Esempio in chat: *«Interagisci col CRM e fai alcune cose per me su MatchLiveTV.»*
|
||||
|
||||
## Sviluppo locale senza Docker (opzionale)
|
||||
|
||||
```bash
|
||||
@@ -174,8 +234,7 @@ bin/dev # server + tailwind watch
|
||||
## Volutamente fuori scope (fase successiva)
|
||||
|
||||
- Integrazioni Gmail/SMTP avanzate, sync email, calendario
|
||||
- Stripe, webhook, API REST pubblica
|
||||
- Multi-tenant / multi-progetto
|
||||
- Stripe, webhook
|
||||
- Dark mode, BI avanzata, microservizi
|
||||
|
||||
## Licenza
|
||||
|
||||
@@ -7,5 +7,6 @@ class AdminController < ApplicationController
|
||||
@active_users_count = User.active.count
|
||||
@projects_count = Project.count
|
||||
@mail_identities_count = MailIdentity.count
|
||||
@api_tokens_count = current_user.api_tokens.active.count
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module Api
|
||||
class BaseController < ActionController::API
|
||||
wrap_parameters false
|
||||
before_action :authenticate_api_token!
|
||||
|
||||
attr_reader :current_user, :current_api_token
|
||||
|
||||
private
|
||||
|
||||
def authenticate_api_token!
|
||||
token = ApiToken.authenticate(bearer_token)
|
||||
unless token
|
||||
render json: { error: "Non autenticato" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
@current_api_token = token
|
||||
@current_user = token.user
|
||||
Current.user = @current_user
|
||||
token.touch_last_used!
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
header = request.authorization.to_s.presence || request.headers["Authorization"].to_s
|
||||
header[/Bearer\s+(.+)/i, 1]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Api
|
||||
module V1
|
||||
class ActivitiesController < BaseController
|
||||
def create
|
||||
render_agent Crm::AgentSession.new(current_user).create_activity(params[:project_code], activity_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def activity_params
|
||||
source = params[:activity].presence || params
|
||||
source.permit(:organization_id, :activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id, :lost_reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module Api
|
||||
module V1
|
||||
class BaseController < Api::BaseController
|
||||
private
|
||||
|
||||
def render_agent(result)
|
||||
render json: result.body, status: result.status
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Api
|
||||
module V1
|
||||
class OpportunitiesController < BaseController
|
||||
def update_stage
|
||||
source = params[:opportunity].presence || params
|
||||
render_agent Crm::AgentSession.new(current_user).update_opportunity_stage(
|
||||
params[:project_code],
|
||||
params[:id],
|
||||
pipeline_stage: source[:pipeline_stage],
|
||||
lost_reason: source[:lost_reason],
|
||||
notes: source[:notes]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class OrganizationsController < BaseController
|
||||
def show
|
||||
render_agent Crm::AgentSession.new(current_user).organization(params[:project_code], params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class ProjectsController < BaseController
|
||||
def index
|
||||
render_agent Crm::AgentSession.new(current_user).projects
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class SearchController < BaseController
|
||||
def show
|
||||
render_agent Crm::AgentSession.new(current_user).search(params[:project_code], params[:q])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Api
|
||||
module V1
|
||||
class TasksController < BaseController
|
||||
def create
|
||||
render_agent Crm::AgentSession.new(current_user).create_task(params[:project_code], task_params)
|
||||
end
|
||||
|
||||
def complete
|
||||
render_agent Crm::AgentSession.new(current_user).complete_task(params[:project_code], params[:id])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def task_params
|
||||
source = params[:task].presence || params
|
||||
source.permit(
|
||||
:title, :description, :organization_id, :contact_id, :opportunity_id,
|
||||
:assigned_user_id, :due_at, :priority, :task_type
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Api
|
||||
module V1
|
||||
class TodayController < BaseController
|
||||
def show
|
||||
render_agent Crm::AgentSession.new(current_user).today(params[:project_code])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class ApiTokensController < ApplicationController
|
||||
before_action :set_api_token, only: :destroy
|
||||
|
||||
def index
|
||||
@page_title = "Token API"
|
||||
@api_tokens = current_user.api_tokens.active.order(created_at: :desc)
|
||||
@revealed_token = session.delete(:revealed_api_token)
|
||||
end
|
||||
|
||||
def create
|
||||
token = ApiToken.issue!(user: current_user, name: params[:name])
|
||||
session[:revealed_api_token] = token.plaintext
|
||||
redirect_to api_tokens_path, notice: "Token creato. Copialo ora: non sarà più visibile."
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_token.revoke!
|
||||
redirect_to api_tokens_path, notice: "Token revocato."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_api_token
|
||||
@api_token = current_user.api_tokens.active.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class McpController < Api::BaseController
|
||||
def handle
|
||||
request.body.rewind if request.body.respond_to?(:rewind)
|
||||
|
||||
server = EminuxCrmMcp.server_for(user: current_user)
|
||||
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
||||
server,
|
||||
stateless: true,
|
||||
serve_subscriptions_listen: false,
|
||||
enable_json_response: true,
|
||||
allowed_hosts: EminuxCrmMcp.allowed_hosts,
|
||||
dns_rebinding_protection: !Rails.env.test?
|
||||
)
|
||||
|
||||
status, headers, body = transport.handle_request(request)
|
||||
headers.each { |key, value| response.set_header(key, value) }
|
||||
self.status = status
|
||||
self.response_body = body
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class ApiToken < ApplicationRecord
|
||||
PREFIX = "crm_"
|
||||
|
||||
belongs_to :user
|
||||
|
||||
attr_accessor :plaintext
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :token_prefix, presence: true
|
||||
|
||||
scope :active, -> { where(revoked_at: nil) }
|
||||
|
||||
def self.digest(token)
|
||||
Digest::SHA256.hexdigest(token.to_s)
|
||||
end
|
||||
|
||||
def self.authenticate(plaintext)
|
||||
return if plaintext.blank?
|
||||
|
||||
token = active.find_by(token_digest: digest(plaintext))
|
||||
return unless token&.user&.active?
|
||||
|
||||
token
|
||||
end
|
||||
|
||||
def self.issue!(user:, name:)
|
||||
raw = "#{PREFIX}#{SecureRandom.urlsafe_base64(32)}"
|
||||
record = create!(
|
||||
user: user,
|
||||
name: name.to_s.strip.presence || "Agente",
|
||||
token_digest: digest(raw),
|
||||
token_prefix: raw[0, 8]
|
||||
)
|
||||
record.plaintext = raw
|
||||
record
|
||||
end
|
||||
|
||||
def revoked?
|
||||
revoked_at.present?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update!(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
def masked
|
||||
"#{token_prefix}…"
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ class User < ApplicationRecord
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :user_projects, dependent: :destroy
|
||||
has_many :projects, through: :user_projects
|
||||
has_many :api_tokens, dependent: :destroy
|
||||
|
||||
ROLES = %w[admin user].freeze
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
module Crm
|
||||
class AgentSession
|
||||
Result = Struct.new(:ok, :status, :body, keyword_init: true)
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
Current.user = user
|
||||
end
|
||||
|
||||
def projects
|
||||
ok(projects: @user.accessible_projects.map { |project| project_json(project) })
|
||||
end
|
||||
|
||||
def today(project_code)
|
||||
with_project(project_code) do
|
||||
tasks = tasks_scope
|
||||
stalled = opportunities_scope.open_stage
|
||||
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
||||
.includes(:organization, :assigned_user)
|
||||
|
||||
ok(
|
||||
project: project_json(@project),
|
||||
overdue_tasks: tasks.overdue.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
||||
today_tasks: tasks.due_today.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
||||
upcoming_tasks: tasks.upcoming.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
||||
stalled_opportunities: stalled.map { |o| opportunity_json(o) }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def search(project_code, query)
|
||||
with_project(project_code) do
|
||||
query = query.to_s.strip
|
||||
if query.blank?
|
||||
return ok(query: query, organizations: [], contacts: [], opportunities: [])
|
||||
end
|
||||
|
||||
organizations = organizations_scope.search(query).includes(:assigned_user).limit(20)
|
||||
contacts = Contact.joins(:organization).merge(organizations_scope).search(query).includes(:organization).limit(20)
|
||||
opportunities = opportunities_scope.joins(:organization)
|
||||
.where("opportunities.name ILIKE :q OR organizations.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(query)}%")
|
||||
.includes(:organization, :assigned_user)
|
||||
.limit(20)
|
||||
|
||||
ok(
|
||||
query: query,
|
||||
organizations: organizations.map { |o| organization_summary(o) },
|
||||
contacts: contacts.map { |c| contact_json(c).merge(organization_name: c.organization.name) },
|
||||
opportunities: opportunities.map { |o| opportunity_json(o) }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def organization(project_code, id)
|
||||
with_project(project_code) do
|
||||
org = organizations_scope.includes(:assigned_user, :contacts).find(id)
|
||||
open_opportunities = org.opportunities.for_project(@project).open_stage.includes(:assigned_user).order(updated_at: :desc)
|
||||
pending_tasks = org.tasks.pending.ordered.includes(:assigned_user, :contact, :opportunity)
|
||||
recent_activities = org.activities.includes(:user, :contact, :opportunity).recent_first.limit(20)
|
||||
|
||||
ok(
|
||||
organization: organization_json(org),
|
||||
contacts: org.contacts.primary_first.map { |c| contact_json(c) },
|
||||
open_opportunities: open_opportunities.map { |o| opportunity_json(o) },
|
||||
pending_tasks: pending_tasks.map { |t| task_json(t) },
|
||||
recent_activities: recent_activities.map { |a| activity_json(a) }
|
||||
)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
err("Organizzazione non trovata", status: :not_found)
|
||||
end
|
||||
end
|
||||
|
||||
def create_task(project_code, attrs)
|
||||
with_project(project_code) do
|
||||
attrs = attrs.to_h.symbolize_keys
|
||||
organization = organizations_scope.find(attrs[:organization_id])
|
||||
task = Task.new(attrs.slice(:title, :description, :contact_id, :opportunity_id, :assigned_user_id, :due_at, :priority, :task_type))
|
||||
task.organization = organization
|
||||
task.assigned_user ||= @user
|
||||
task.priority = "normal" if task.priority.blank?
|
||||
task.task_type = "follow_up" if task.task_type.blank?
|
||||
|
||||
if task.opportunity_id.present? && opportunities_scope.where(id: task.opportunity_id).none?
|
||||
return err("Opportunità non trovata", status: :not_found)
|
||||
end
|
||||
|
||||
if task.save
|
||||
ok({ task: task_json(task) }, status: :created)
|
||||
else
|
||||
validation_error(task)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
err("Organizzazione non trovata", status: :not_found)
|
||||
end
|
||||
end
|
||||
|
||||
def complete_task(project_code, id)
|
||||
with_project(project_code) do
|
||||
task = tasks_scope.find(id)
|
||||
unless task.complete!(user: @user)
|
||||
return err("Il task non può essere completato", status: :unprocessable_entity)
|
||||
end
|
||||
|
||||
ok(task: task_json(task.reload))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
err("Task non trovato", status: :not_found)
|
||||
end
|
||||
end
|
||||
|
||||
def create_activity(project_code, attrs)
|
||||
with_project(project_code) do
|
||||
attrs = attrs.to_h.symbolize_keys
|
||||
organization = organizations_scope.find(attrs[:organization_id])
|
||||
activity = organization.activities.build(attrs.slice(:activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id))
|
||||
activity.user = @user
|
||||
activity.happened_at ||= Time.current
|
||||
|
||||
if activity.opportunity_id.present? && opportunities_scope.where(id: activity.opportunity_id).none?
|
||||
return err("Opportunità non trovata", status: :not_found)
|
||||
end
|
||||
|
||||
if activity.save
|
||||
maybe_update_pipeline_from_activity!(activity, lost_reason: attrs[:lost_reason])
|
||||
ok({ activity: activity_json(activity) }, status: :created)
|
||||
else
|
||||
validation_error(activity)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
err("Organizzazione non trovata", status: :not_found)
|
||||
end
|
||||
end
|
||||
|
||||
def update_opportunity_stage(project_code, id, pipeline_stage:, lost_reason: nil, notes: nil)
|
||||
with_project(project_code) do
|
||||
opportunity = opportunities_scope.find(id)
|
||||
new_stage = pipeline_stage.to_s
|
||||
unless Catalog::PIPELINE_STAGES.key?(new_stage)
|
||||
return err("Stage non valido", status: :unprocessable_entity)
|
||||
end
|
||||
|
||||
opportunity.move_to_stage!(new_stage, lost_reason: lost_reason, notes: notes, user: @user)
|
||||
ok(opportunity: opportunity_json(opportunity.reload))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
err("Opportunità non trovata", status: :not_found)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
validation_error(e.record)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_project(code)
|
||||
project = Project.active.find_by(code: code.to_s)
|
||||
return err("Progetto non trovato", status: :not_found) if project.nil?
|
||||
return err("Progetto non accessibile", status: :forbidden) unless @user.can_access_project?(project)
|
||||
|
||||
@project = project
|
||||
Current.project = project
|
||||
yield
|
||||
end
|
||||
|
||||
def organizations_scope
|
||||
Organization.for_project(@project)
|
||||
end
|
||||
|
||||
def opportunities_scope
|
||||
Opportunity.for_project(@project)
|
||||
end
|
||||
|
||||
def tasks_scope
|
||||
Task.for_project(@project)
|
||||
end
|
||||
|
||||
def ok(body = nil, status: :ok, **fields)
|
||||
Result.new(ok: true, status: status, body: body || fields)
|
||||
end
|
||||
|
||||
def err(message, status:, extra: {})
|
||||
Result.new(ok: false, status: status, body: { error: message }.merge(extra))
|
||||
end
|
||||
|
||||
def validation_error(record)
|
||||
err(record.errors.full_messages.to_sentence, status: :unprocessable_entity, extra: { errors: record.errors.full_messages })
|
||||
end
|
||||
|
||||
def maybe_update_pipeline_from_activity!(activity, lost_reason: nil)
|
||||
opportunity = activity.opportunity || activity.organization.opportunities.for_project(@project).open_stage.order(updated_at: :desc).first
|
||||
return unless opportunity
|
||||
|
||||
stage_map = {
|
||||
"email_sent" => "contacted",
|
||||
"email_received" => "replied",
|
||||
"call" => "contacted",
|
||||
"demo" => "demo_trial",
|
||||
"trial_started" => "demo_trial",
|
||||
"first_use" => "first_use",
|
||||
"proposal_sent" => "proposal",
|
||||
"won" => "won",
|
||||
"lost" => "lost"
|
||||
}
|
||||
target = stage_map[activity.activity_type]
|
||||
return unless target
|
||||
return if opportunity.won? || opportunity.lost?
|
||||
return if Catalog::PIPELINE_ORDER.index(opportunity.pipeline_stage).to_i >= Catalog::PIPELINE_ORDER.index(target).to_i
|
||||
|
||||
reason = lost_reason.presence || "other" if target == "lost"
|
||||
opportunity.move_to_stage!(target, lost_reason: reason, user: @user)
|
||||
end
|
||||
|
||||
def user_json(user)
|
||||
return if user.nil?
|
||||
|
||||
{ id: user.id, name: user.full_name }
|
||||
end
|
||||
|
||||
def project_json(project)
|
||||
{ id: project.id, code: project.code, name: project.name, description: project.description }
|
||||
end
|
||||
|
||||
def organization_summary(org)
|
||||
{
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
status: org.status,
|
||||
status_label: org.status_label,
|
||||
organization_type: org.organization_type,
|
||||
organization_type_label: org.organization_type_label,
|
||||
sport: org.sport,
|
||||
city: org.city,
|
||||
region: org.region,
|
||||
country: org.country,
|
||||
email: org.email,
|
||||
phone: org.phone,
|
||||
website: org.website,
|
||||
lead_source: org.lead_source,
|
||||
assigned_user: user_json(org.assigned_user)
|
||||
}
|
||||
end
|
||||
|
||||
def organization_json(org)
|
||||
organization_summary(org).merge(
|
||||
address: org.address,
|
||||
province: org.province,
|
||||
legal_name: org.legal_name,
|
||||
vat_number: org.vat_number,
|
||||
notes: org.notes,
|
||||
commercial_fit: org.commercial_fit,
|
||||
streaming_status: org.streaming_status,
|
||||
team_gender: org.team_gender
|
||||
)
|
||||
end
|
||||
|
||||
def contact_json(contact)
|
||||
{
|
||||
id: contact.id,
|
||||
organization_id: contact.organization_id,
|
||||
first_name: contact.first_name,
|
||||
last_name: contact.last_name,
|
||||
full_name: contact.full_name,
|
||||
role: contact.role,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
mobile: contact.mobile,
|
||||
primary_contact: contact.primary_contact,
|
||||
preferred_contact_method: contact.preferred_contact_method
|
||||
}
|
||||
end
|
||||
|
||||
def opportunity_json(opportunity)
|
||||
{
|
||||
id: opportunity.id,
|
||||
organization_id: opportunity.organization_id,
|
||||
organization_name: opportunity.organization&.name,
|
||||
project_id: opportunity.project_id,
|
||||
name: opportunity.name,
|
||||
pipeline_stage: opportunity.pipeline_stage,
|
||||
pipeline_stage_label: opportunity.pipeline_stage_label,
|
||||
estimated_value: opportunity.estimated_value,
|
||||
probability: opportunity.probability,
|
||||
product: opportunity.product,
|
||||
lost_reason: opportunity.lost_reason,
|
||||
notes: opportunity.notes,
|
||||
assigned_user: user_json(opportunity.assigned_user),
|
||||
stage_changed_at: opportunity.stage_changed_at&.iso8601,
|
||||
expected_close_date: opportunity.expected_close_date&.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
def task_json(task)
|
||||
{
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
due_at: task.due_at&.iso8601,
|
||||
priority: task.priority,
|
||||
priority_label: task.priority_label,
|
||||
task_type: task.task_type,
|
||||
task_type_label: task.task_type_label,
|
||||
status: task.status,
|
||||
status_label: task.status_label,
|
||||
organization_id: task.organization_id,
|
||||
organization_name: task.organization&.name,
|
||||
contact_id: task.contact_id,
|
||||
opportunity_id: task.opportunity_id,
|
||||
assigned_user: user_json(task.assigned_user),
|
||||
completed_at: task.completed_at&.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
def activity_json(activity)
|
||||
{
|
||||
id: activity.id,
|
||||
activity_type: activity.activity_type,
|
||||
activity_type_label: activity.activity_type_label,
|
||||
subject: activity.subject,
|
||||
description: activity.description,
|
||||
happened_at: activity.happened_at&.iso8601,
|
||||
organization_id: activity.organization_id,
|
||||
contact_id: activity.contact_id,
|
||||
opportunity_id: activity.opportunity_id,
|
||||
user: user_json(activity.user)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -28,5 +28,13 @@
|
||||
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @mail_identities_count %> account</p>
|
||||
<div class="mt-4 text-sm font-medium">Configura SMTP →</div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to api_tokens_path, class: "rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Agenti</div>
|
||||
<h2 class="mt-2 text-xl font-semibold tracking-tight">Token API</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Autenticazione Bearer per Cursor, Claude Code e Codex. Il valore in chiaro si vede una sola volta.</p>
|
||||
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @api_tokens_count %> attivi</p>
|
||||
<div class="mt-4 text-sm font-medium">Gestisci token →</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Token API</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
Servono agli agenti (Cursor, Claude Code, Codex) per lavorare sul CRM. Il valore in chiaro si vede una sola volta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if @revealed_token.present? %>
|
||||
<div class="rounded-xl border border-amber-300 bg-amber-50 p-5 text-amber-950 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100">
|
||||
<p class="text-sm font-semibold">Nuovo token — copialo ora</p>
|
||||
<p class="mt-1 text-xs">Non verrà più mostrato. Incollalo in <code>CRM_API_TOKEN</code> nella config MCP.</p>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-white px-3 py-2 text-sm text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100"><%= @revealed_token %></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<section class="<%= card_class %> p-5">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Nuovo token</h2>
|
||||
<%= form_with url: api_tokens_path, method: :post, class: "mt-4 flex flex-col gap-3 sm:flex-row sm:items-end" do %>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs font-medium text-zinc-500" for="name">Nome</label>
|
||||
<%= text_field_tag :name, nil, placeholder: "es. Cursor sul portatile", required: true, class: input_class %>
|
||||
</div>
|
||||
<%= submit_tag "Crea token", class: "#{btn_primary} sm:mb-0" %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="<%= card_class %>">
|
||||
<% if @api_tokens.empty? %>
|
||||
<p class="p-5 text-sm text-zinc-500">Nessun token attivo.</p>
|
||||
<% else %>
|
||||
<ul class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<% @api_tokens.each do |token| %>
|
||||
<li class="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<div class="font-medium"><%= token.name %></div>
|
||||
<div class="mt-0.5 text-xs text-zinc-500">
|
||||
<%= token.masked %>
|
||||
· creato <%= format_dt(token.created_at) %>
|
||||
<% if token.last_used_at %>
|
||||
· usato <%= format_dt(token.last_used_at) %>
|
||||
<% else %>
|
||||
· mai usato
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= button_to "Revoca", api_token_path(token), method: :delete, class: "#{btn_danger} text-sm", data: { turbo_confirm: "Revocare questo token?" } %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</section>
|
||||
</div>
|
||||
@@ -66,6 +66,7 @@
|
||||
<% end %>
|
||||
<li><%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %></li>
|
||||
<li><%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %></li>
|
||||
<li><%= link_to "Token API (agenti)", api_tokens_path, class: "hover:underline" %></li>
|
||||
<li><%= link_to "Cambio password", edit_password_path, class: "hover:underline" %></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<%= link_to "Impostazioni", admin_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||
<%= link_to "Utenti", users_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||
<% end %>
|
||||
<%= link_to "Token API", api_tokens_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||
<%= link_to "Password", edit_password_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||
<%= button_to "Esci", logout_path, method: :delete, class: "block w-full px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||
</div>
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
||||
require "bundler/setup"
|
||||
load File.expand_path("../mcp/server.rb", __dir__)
|
||||
@@ -4,5 +4,6 @@
|
||||
# Use this to limit dissemination of sensitive information.
|
||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||
Rails.application.config.filter_parameters += [
|
||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
|
||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc,
|
||||
:authorization, :plaintext
|
||||
]
|
||||
|
||||
@@ -35,6 +35,7 @@ it:
|
||||
mail_template: Template email
|
||||
mailing: Invio email
|
||||
mailing_recipient: Destinatario
|
||||
api_token: Token API
|
||||
attributes:
|
||||
organization:
|
||||
name: Nome
|
||||
|
||||
@@ -22,6 +22,24 @@ Rails.application.routes.draw do
|
||||
resources :projects, only: %i[create edit update destroy]
|
||||
resources :users, except: %i[show]
|
||||
resources :mail_identities, except: %i[show]
|
||||
resources :api_tokens, only: %i[index create destroy]
|
||||
match "/mcp", to: "mcp#handle", via: %i[get post delete]
|
||||
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
get "projects", to: "projects#index"
|
||||
|
||||
scope "/p/:project_code" do
|
||||
get "today", to: "today#show"
|
||||
get "search", to: "search#show"
|
||||
get "organizations/:id", to: "organizations#show", as: :organization
|
||||
post "tasks", to: "tasks#create"
|
||||
post "tasks/:id/complete", to: "tasks#complete", as: :complete_task
|
||||
post "activities", to: "activities#create"
|
||||
patch "opportunities/:id/stage", to: "opportunities#update_stage", as: :opportunity_stage
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ogni progetto = istanza CRM dedicata
|
||||
scope "/p/:project_code" do
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class CreateApiTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :api_tokens do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :name, null: false
|
||||
t.string :token_digest, null: false
|
||||
t.string :token_prefix, null: false
|
||||
t.datetime :last_used_at
|
||||
t.datetime :revoked_at
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :api_tokens, :token_digest, unique: true
|
||||
add_index :api_tokens, :revoked_at
|
||||
end
|
||||
end
|
||||
Generated
+16
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@@ -64,6 +64,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do
|
||||
t.index ["user_id"], name: "index_activities_on_user_id"
|
||||
end
|
||||
|
||||
create_table "api_tokens", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "last_used_at"
|
||||
t.string "name", null: false
|
||||
t.datetime "revoked_at"
|
||||
t.string "token_digest", null: false
|
||||
t.string "token_prefix", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.index ["revoked_at"], name: "index_api_tokens_on_revoked_at"
|
||||
t.index ["token_digest"], name: "index_api_tokens_on_token_digest", unique: true
|
||||
t.index ["user_id"], name: "index_api_tokens_on_user_id"
|
||||
end
|
||||
|
||||
create_table "contacts", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_id"
|
||||
@@ -384,6 +398,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do
|
||||
add_foreign_key "activities", "opportunities"
|
||||
add_foreign_key "activities", "organizations"
|
||||
add_foreign_key "activities", "users"
|
||||
add_foreign_key "api_tokens", "users"
|
||||
add_foreign_key "contacts", "organizations"
|
||||
add_foreign_key "mail_templates", "projects"
|
||||
add_foreign_key "mailing_recipients", "contacts"
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "json"
|
||||
require "mcp"
|
||||
|
||||
module EminuxCrmMcp
|
||||
INSTRUCTIONS = <<~TEXT.freeze
|
||||
Sei collegato a eminuxCRM. Quando l'utente chiede di lavorare su prospect, pipeline, task o follow-up, usa questi tool (non curl).
|
||||
1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …).
|
||||
2. today per capire cosa fare oggi.
|
||||
3. search / get_organization per il contesto.
|
||||
4. create_activity, complete_task, create_task, update_opportunity_stage per agire.
|
||||
Non inviare mailing, non cancellare record, non gestire utenti.
|
||||
TEXT
|
||||
|
||||
module_function
|
||||
|
||||
def allowed_hosts
|
||||
hosts = %w[localhost 127.0.0.1 [::1] web]
|
||||
hosts.concat(configured_public_hosts)
|
||||
hosts << "www.example.com" if defined?(Rails) && Rails.env.test?
|
||||
hosts.map { |host| normalize_host(host) }.compact.uniq
|
||||
end
|
||||
|
||||
def configured_public_hosts
|
||||
values = [ ENV["APP_HOST"] ]
|
||||
values.concat(ENV.fetch("RAILS_ALLOWED_HOSTS", "").split(","))
|
||||
values
|
||||
end
|
||||
|
||||
def normalize_host(value)
|
||||
host = value.to_s.strip
|
||||
return if host.blank?
|
||||
|
||||
host = host.sub(%r{\Ahttps?://}i, "")
|
||||
host.split("/").first
|
||||
end
|
||||
|
||||
def server_for(user:)
|
||||
MCP::Server.new(
|
||||
name: "eminuxcrm",
|
||||
version: "1.0.0",
|
||||
instructions: INSTRUCTIONS,
|
||||
server_context: { user: user },
|
||||
tools: [
|
||||
ListProjects,
|
||||
Today,
|
||||
Search,
|
||||
GetOrganization,
|
||||
CreateTask,
|
||||
CompleteTask,
|
||||
CreateActivity,
|
||||
UpdateOpportunityStage
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def json_response(result)
|
||||
MCP::Tool::Response.new(
|
||||
[ { type: "text", text: JSON.pretty_generate(result.body.as_json) } ],
|
||||
error: !result.ok
|
||||
)
|
||||
end
|
||||
|
||||
def session_from(server_context)
|
||||
user = extract_user(server_context)
|
||||
raise "MCP senza utente autenticato" if user.nil?
|
||||
|
||||
Crm::AgentSession.new(user)
|
||||
end
|
||||
|
||||
def extract_user(ctx)
|
||||
return if ctx.nil?
|
||||
return ctx[:user] if ctx.is_a?(Hash)
|
||||
return ctx.user if ctx.respond_to?(:user) && ctx.method(:user).arity.zero?
|
||||
|
||||
inner = ctx.instance_variable_get(:@context) if ctx.respond_to?(:instance_variable_get)
|
||||
return inner[:user] if inner.is_a?(Hash)
|
||||
|
||||
ctx[:user] if ctx.respond_to?(:[])
|
||||
end
|
||||
|
||||
class ListProjects < MCP::Tool
|
||||
description "Elenca i progetti CRM accessibili all'utente del token (codici da usare negli altri tool)."
|
||||
|
||||
class << self
|
||||
def call(server_context: nil)
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).projects)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Today < MCP::Tool
|
||||
description "Cosa fare oggi: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" }
|
||||
},
|
||||
required: %w[project_code]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, server_context: nil)
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).today(project_code))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Search < MCP::Tool
|
||||
description "Cerca organizzazioni, contatti e opportunità nel progetto."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
q: { type: "string", description: "Testo di ricerca" }
|
||||
},
|
||||
required: %w[project_code q]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, q:, server_context: nil)
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).search(project_code, q))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class GetOrganization < MCP::Tool
|
||||
description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
id: { type: "integer", description: "ID organizzazione" }
|
||||
},
|
||||
required: %w[project_code id]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, id:, server_context: nil)
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).organization(project_code, id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CreateTask < MCP::Tool
|
||||
description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
organization_id: { type: "integer" },
|
||||
title: { type: "string" },
|
||||
due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" },
|
||||
description: { type: "string" },
|
||||
contact_id: { type: "integer" },
|
||||
opportunity_id: { type: "integer" },
|
||||
assigned_user_id: { type: "integer" },
|
||||
priority: { type: "string", description: "low, normal, high, urgent" },
|
||||
task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" }
|
||||
},
|
||||
required: %w[project_code organization_id title due_at]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil,
|
||||
opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil)
|
||||
attrs = {
|
||||
organization_id: organization_id,
|
||||
title: title,
|
||||
due_at: due_at,
|
||||
description: description,
|
||||
contact_id: contact_id,
|
||||
opportunity_id: opportunity_id,
|
||||
assigned_user_id: assigned_user_id,
|
||||
priority: priority,
|
||||
task_type: task_type
|
||||
}.compact
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_task(project_code, attrs))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CompleteTask < MCP::Tool
|
||||
description "Segna un task come completato e registra l'attività in timeline."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
id: { type: "integer", description: "ID task" }
|
||||
},
|
||||
required: %w[project_code id]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, id:, server_context: nil)
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).complete_task(project_code, id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CreateActivity < MCP::Tool
|
||||
description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
organization_id: { type: "integer" },
|
||||
activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other" },
|
||||
subject: { type: "string" },
|
||||
description: { type: "string" },
|
||||
happened_at: { type: "string", description: "ISO8601; default ora" },
|
||||
contact_id: { type: "integer" },
|
||||
opportunity_id: { type: "integer" }
|
||||
},
|
||||
required: %w[project_code organization_id activity_type subject]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil,
|
||||
contact_id: nil, opportunity_id: nil, server_context: nil)
|
||||
attrs = {
|
||||
organization_id: organization_id,
|
||||
activity_type: activity_type,
|
||||
subject: subject,
|
||||
description: description,
|
||||
happened_at: happened_at,
|
||||
contact_id: contact_id,
|
||||
opportunity_id: opportunity_id
|
||||
}.compact
|
||||
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_activity(project_code, attrs))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class UpdateOpportunityStage < MCP::Tool
|
||||
description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
id: { type: "integer", description: "ID opportunità" },
|
||||
pipeline_stage: { type: "string" },
|
||||
lost_reason: { type: "string", description: "Obbligatorio se stage = lost" },
|
||||
notes: { type: "string" }
|
||||
},
|
||||
required: %w[project_code id pipeline_stage]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil)
|
||||
EminuxCrmMcp.json_response(
|
||||
EminuxCrmMcp.session_from(server_context).update_opportunity_stage(
|
||||
project_code,
|
||||
id,
|
||||
pipeline_stage: pipeline_stage,
|
||||
lost_reason: lost_reason,
|
||||
notes: notes
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"eminuxcrm": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3001/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${CRM_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Codex legge ~/.codex/config.toml (o CODEX_HOME).
|
||||
# Crea un token in CRM → Token API, poi:
|
||||
|
||||
[mcp_servers.eminuxcrm]
|
||||
url = "http://localhost:3001/mcp"
|
||||
bearer_token_env_var = "CRM_API_TOKEN"
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"eminuxcrm": {
|
||||
"url": "http://localhost:3001/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${env:CRM_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "json"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
require "mcp"
|
||||
|
||||
module EminuxCrmMcp
|
||||
module Client
|
||||
module_function
|
||||
|
||||
def crm_url
|
||||
ENV.fetch("CRM_URL", "http://localhost:3001").to_s.sub(%r{/+\z}, "")
|
||||
end
|
||||
|
||||
def api_token
|
||||
ENV.fetch("CRM_API_TOKEN", "")
|
||||
end
|
||||
|
||||
def request(method, path, body: nil)
|
||||
if api_token.strip.empty?
|
||||
return error_payload("Imposta CRM_API_TOKEN (token creato in CRM → Token API).")
|
||||
end
|
||||
|
||||
uri = URI.parse("#{crm_url}#{path}")
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
http.open_timeout = 10
|
||||
http.read_timeout = 30
|
||||
|
||||
request = http_class(method).new(uri)
|
||||
request["Authorization"] = "Bearer #{api_token}"
|
||||
request["Accept"] = "application/json"
|
||||
if body
|
||||
request["Content-Type"] = "application/json"
|
||||
request.body = JSON.generate(body)
|
||||
end
|
||||
|
||||
response = http.request(request)
|
||||
payload = parse_json(response.body)
|
||||
text = JSON.pretty_generate(payload)
|
||||
MCP::Tool::Response.new([ { type: "text", text: text } ], error: response.code.to_i >= 400)
|
||||
rescue StandardError => e
|
||||
error_payload("#{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
def http_class(method)
|
||||
{
|
||||
get: Net::HTTP::Get,
|
||||
post: Net::HTTP::Post,
|
||||
patch: Net::HTTP::Patch
|
||||
}.fetch(method)
|
||||
end
|
||||
|
||||
def parse_json(raw)
|
||||
JSON.parse(raw.to_s)
|
||||
rescue JSON::ParserError
|
||||
{ error: "Risposta non JSON", body: raw.to_s[0, 2000] }
|
||||
end
|
||||
|
||||
def error_payload(message)
|
||||
MCP::Tool::Response.new([ { type: "text", text: message } ], error: true)
|
||||
end
|
||||
|
||||
def compact(hash)
|
||||
hash.each_with_object({}) do |(key, value), acc|
|
||||
acc[key] = value unless value.nil? || value == ""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class ListProjects < MCP::Tool
|
||||
description "Elenca i progetti CRM accessibili all'utente del token."
|
||||
|
||||
class << self
|
||||
def call(server_context: nil)
|
||||
Client.request(:get, "/api/v1/projects")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Today < MCP::Tool
|
||||
description "Cosa fare oggi nel progetto: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" }
|
||||
},
|
||||
required: %w[project_code]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, server_context: nil)
|
||||
Client.request(:get, "/api/v1/p/#{encode(project_code)}/today")
|
||||
end
|
||||
|
||||
def encode(value)
|
||||
URI.encode_www_form_component(value.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Search < MCP::Tool
|
||||
description "Cerca organizzazioni, contatti e opportunità nel progetto."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" },
|
||||
q: { type: "string", description: "Testo di ricerca" }
|
||||
},
|
||||
required: %w[project_code q]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, q:, server_context: nil)
|
||||
query = URI.encode_www_form(q: q)
|
||||
Client.request(:get, "/api/v1/p/#{Today.encode(project_code)}/search?#{query}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class GetOrganization < MCP::Tool
|
||||
description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
id: { type: "integer", description: "ID organizzazione" }
|
||||
},
|
||||
required: %w[project_code id]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, id:, server_context: nil)
|
||||
Client.request(:get, "/api/v1/p/#{Today.encode(project_code)}/organizations/#{id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CreateTask < MCP::Tool
|
||||
description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
organization_id: { type: "integer" },
|
||||
title: { type: "string" },
|
||||
due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" },
|
||||
description: { type: "string" },
|
||||
contact_id: { type: "integer" },
|
||||
opportunity_id: { type: "integer" },
|
||||
assigned_user_id: { type: "integer" },
|
||||
priority: { type: "string", description: "low, normal, high, urgent" },
|
||||
task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" }
|
||||
},
|
||||
required: %w[project_code organization_id title due_at]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil,
|
||||
opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil)
|
||||
Client.request(
|
||||
:post,
|
||||
"/api/v1/p/#{Today.encode(project_code)}/tasks",
|
||||
body: Client.compact(
|
||||
organization_id: organization_id,
|
||||
title: title,
|
||||
due_at: due_at,
|
||||
description: description,
|
||||
contact_id: contact_id,
|
||||
opportunity_id: opportunity_id,
|
||||
assigned_user_id: assigned_user_id,
|
||||
priority: priority,
|
||||
task_type: task_type
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CompleteTask < MCP::Tool
|
||||
description "Segna un task come completato e registra l'attività in timeline."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
id: { type: "integer", description: "ID task" }
|
||||
},
|
||||
required: %w[project_code id]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, id:, server_context: nil)
|
||||
Client.request(:post, "/api/v1/p/#{Today.encode(project_code)}/tasks/#{id}/complete")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CreateActivity < MCP::Tool
|
||||
description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
organization_id: { type: "integer" },
|
||||
activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other, …" },
|
||||
subject: { type: "string" },
|
||||
description: { type: "string" },
|
||||
happened_at: { type: "string", description: "ISO8601; default ora" },
|
||||
contact_id: { type: "integer" },
|
||||
opportunity_id: { type: "integer" }
|
||||
},
|
||||
required: %w[project_code organization_id activity_type subject]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil,
|
||||
contact_id: nil, opportunity_id: nil, server_context: nil)
|
||||
Client.request(
|
||||
:post,
|
||||
"/api/v1/p/#{Today.encode(project_code)}/activities",
|
||||
body: Client.compact(
|
||||
organization_id: organization_id,
|
||||
activity_type: activity_type,
|
||||
subject: subject,
|
||||
description: description,
|
||||
happened_at: happened_at,
|
||||
contact_id: contact_id,
|
||||
opportunity_id: opportunity_id
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class UpdateOpportunityStage < MCP::Tool
|
||||
description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)."
|
||||
input_schema(
|
||||
properties: {
|
||||
project_code: { type: "string" },
|
||||
id: { type: "integer", description: "ID opportunità" },
|
||||
pipeline_stage: { type: "string" },
|
||||
lost_reason: { type: "string", description: "Obbligatorio se stage = lost" },
|
||||
notes: { type: "string" }
|
||||
},
|
||||
required: %w[project_code id pipeline_stage]
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil)
|
||||
Client.request(
|
||||
:patch,
|
||||
"/api/v1/p/#{Today.encode(project_code)}/opportunities/#{id}/stage",
|
||||
body: Client.compact(
|
||||
pipeline_stage: pipeline_stage,
|
||||
lost_reason: lost_reason,
|
||||
notes: notes
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
server = MCP::Server.new(
|
||||
name: "eminuxcrm",
|
||||
version: "1.0.0",
|
||||
tools: [
|
||||
EminuxCrmMcp::ListProjects,
|
||||
EminuxCrmMcp::Today,
|
||||
EminuxCrmMcp::Search,
|
||||
EminuxCrmMcp::GetOrganization,
|
||||
EminuxCrmMcp::CreateTask,
|
||||
EminuxCrmMcp::CompleteTask,
|
||||
EminuxCrmMcp::CreateActivity,
|
||||
EminuxCrmMcp::UpdateOpportunityStage
|
||||
]
|
||||
)
|
||||
|
||||
transport = MCP::Server::Transports::StdioTransport.new(server)
|
||||
transport.open if $PROGRAM_NAME == __FILE__ || File.basename($PROGRAM_NAME.to_s) == "crm-mcp"
|
||||
@@ -0,0 +1,130 @@
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::ApiTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@admin_token = ApiToken.issue!(user: users(:admin), name: "test-admin")
|
||||
@marco_token = ApiToken.issue!(user: users(:marco), name: "test-marco")
|
||||
@org = organizations(:acme)
|
||||
@task = tasks(:follow_up)
|
||||
@opportunity = opportunities(:deal)
|
||||
end
|
||||
|
||||
test "rejects missing token" do
|
||||
get "/api/v1/projects", as: :json
|
||||
assert_response :unauthorized
|
||||
assert_equal "Non autenticato", json_body["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid token" do
|
||||
get "/api/v1/projects", headers: bearer("crm_invalid"), as: :json
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "lists accessible projects" do
|
||||
get "/api/v1/projects", headers: bearer(@marco_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
codes = json_body["projects"].map { |p| p["code"] }
|
||||
assert_includes codes, "matchlivetv"
|
||||
assert_not_includes codes, "riskmeter"
|
||||
end
|
||||
|
||||
test "forbids project the user cannot access" do
|
||||
get "/api/v1/p/riskmeter/today", headers: bearer(@marco_token.plaintext), as: :json
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "returns 404 for unknown project" do
|
||||
get "/api/v1/p/sconosciuto/today", headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "today includes due tasks" do
|
||||
get "/api/v1/p/matchlivetv/today", headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
ids = json_body["today_tasks"].map { |t| t["id"] }
|
||||
assert_includes ids, @task.id
|
||||
assert_equal "matchlivetv", json_body["project"]["code"]
|
||||
end
|
||||
|
||||
test "search finds organization" do
|
||||
get "/api/v1/p/matchlivetv/search", params: { q: "ASD Test" }, headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
org_ids = json_body["organizations"].map { |o| o["id"] }
|
||||
assert_includes org_ids, @org.id
|
||||
end
|
||||
|
||||
test "shows organization card" do
|
||||
get "/api/v1/p/matchlivetv/organizations/#{@org.id}", headers: bearer(@admin_token.plaintext), as: :json
|
||||
assert_response :success
|
||||
assert_equal @org.name, json_body["organization"]["name"]
|
||||
assert json_body["contacts"].any?
|
||||
assert json_body["open_opportunities"].any?
|
||||
assert json_body["pending_tasks"].any?
|
||||
end
|
||||
|
||||
test "creates activity" do
|
||||
assert_difference -> { Activity.count }, 1 do
|
||||
post "/api/v1/p/matchlivetv/activities",
|
||||
params: {
|
||||
organization_id: @org.id,
|
||||
activity_type: "note",
|
||||
subject: "Nota agente",
|
||||
description: "Creato via API"
|
||||
},
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
end
|
||||
assert_response :created
|
||||
assert_equal "Nota agente", json_body["activity"]["subject"]
|
||||
end
|
||||
|
||||
test "creates and completes task" do
|
||||
post "/api/v1/p/matchlivetv/tasks",
|
||||
params: {
|
||||
organization_id: @org.id,
|
||||
title: "Richiamare dopo demo",
|
||||
due_at: 1.day.from_now.iso8601,
|
||||
task_type: "call",
|
||||
priority: "high"
|
||||
},
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :created
|
||||
task_id = json_body["task"]["id"]
|
||||
|
||||
post "/api/v1/p/matchlivetv/tasks/#{task_id}/complete",
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :success
|
||||
assert_equal "completed", json_body["task"]["status"]
|
||||
end
|
||||
|
||||
test "updates opportunity stage" do
|
||||
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
|
||||
params: { pipeline_stage: "demo_trial", notes: "Demo fissata dall'agente" },
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :success
|
||||
assert_equal "demo_trial", json_body["opportunity"]["pipeline_stage"]
|
||||
@opportunity.reload
|
||||
assert_equal "demo_trial", @opportunity.pipeline_stage
|
||||
end
|
||||
|
||||
test "rejects lost stage without reason" do
|
||||
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
|
||||
params: { pipeline_stage: "lost" },
|
||||
headers: bearer(@admin_token.plaintext),
|
||||
as: :json
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bearer(plaintext)
|
||||
{ "Authorization" => "Bearer #{plaintext}" }
|
||||
end
|
||||
|
||||
def json_body
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApiTokensControllerTest < ActionDispatch::IntegrationTest
|
||||
test "requires login" do
|
||||
get api_tokens_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "user can create and revoke own token" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
assert_difference -> { users(:marco).api_tokens.active.count }, 1 do
|
||||
post api_tokens_path, params: { name: "Codex" }
|
||||
end
|
||||
assert_redirected_to api_tokens_path
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
assert_match(/crm_/, response.body)
|
||||
|
||||
token = users(:marco).api_tokens.active.last
|
||||
delete api_token_path(token)
|
||||
assert_redirected_to api_tokens_path
|
||||
assert token.reload.revoked?
|
||||
end
|
||||
|
||||
test "non admin can open token page" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
get api_tokens_path
|
||||
assert_response :success
|
||||
assert_match(/Token API/, response.body)
|
||||
end
|
||||
end
|
||||
@@ -87,6 +87,7 @@ class AuthenticationTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
assert_match(/Impostazioni piattaforma/, response.body)
|
||||
assert_match(/Utenti/, response.body)
|
||||
assert_match(/Token API/, response.body)
|
||||
end
|
||||
|
||||
test "non admin cannot open platform settings" do
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
require "test_helper"
|
||||
|
||||
class McpControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@token = ApiToken.issue!(user: users(:admin), name: "mcp-test")
|
||||
@marco = ApiToken.issue!(user: users(:marco), name: "mcp-marco")
|
||||
end
|
||||
|
||||
test "rejects missing token" do
|
||||
post "/mcp", params: rpc("initialize"), as: :json, headers: mcp_headers(nil)
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "initialize advertises CRM tools" do
|
||||
post "/mcp",
|
||||
params: rpc("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0" }
|
||||
}),
|
||||
as: :json,
|
||||
headers: mcp_headers(@token.plaintext)
|
||||
|
||||
assert_response :success
|
||||
names = Array(json_rpc.dig("result", "capabilities", "tools")).presence
|
||||
listed = tool_names_from_initialize.presence || fetch_tool_names
|
||||
assert_includes listed, "list_projects"
|
||||
assert_includes listed, "today"
|
||||
assert_includes listed, "create_activity"
|
||||
assert names || listed.any?
|
||||
end
|
||||
|
||||
test "list_projects returns accessible projects" do
|
||||
result = call_tool("list_projects", {}, token: @marco.plaintext)
|
||||
codes = result.fetch("projects").map { |p| p["code"] }
|
||||
assert_includes codes, "matchlivetv"
|
||||
assert_not_includes codes, "riskmeter"
|
||||
end
|
||||
|
||||
test "today requires project access" do
|
||||
result = call_tool("today", { project_code: "riskmeter" }, token: @marco.plaintext, expect_error: true)
|
||||
assert_equal "Progetto non accessibile", result["error"]
|
||||
end
|
||||
|
||||
test "today returns tasks for matchlivetv" do
|
||||
result = call_tool("today", { project_code: "matchlivetv" })
|
||||
ids = result.fetch("today_tasks").map { |t| t["id"] }
|
||||
assert_includes ids, tasks(:follow_up).id
|
||||
end
|
||||
|
||||
test "allowed_hosts includes production APP_HOST and RAILS_ALLOWED_HOSTS" do
|
||||
previous_app = ENV["APP_HOST"]
|
||||
previous_allowed = ENV["RAILS_ALLOWED_HOSTS"]
|
||||
ENV["APP_HOST"] = "crm.eminux.it"
|
||||
ENV["RAILS_ALLOWED_HOSTS"] = "crm.eminux.it, 192.168.1.158, localhost"
|
||||
|
||||
hosts = EminuxCrmMcp.allowed_hosts
|
||||
assert_includes hosts, "crm.eminux.it"
|
||||
assert_includes hosts, "192.168.1.158"
|
||||
assert_includes hosts, "localhost"
|
||||
ensure
|
||||
ENV["APP_HOST"] = previous_app
|
||||
ENV["RAILS_ALLOWED_HOSTS"] = previous_allowed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mcp_headers(plaintext)
|
||||
headers = {
|
||||
"Accept" => "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version" => "2025-06-18"
|
||||
}
|
||||
headers["Authorization"] = "Bearer #{plaintext}" if plaintext.present?
|
||||
headers
|
||||
end
|
||||
|
||||
def rpc(method, params = {}, id: 1)
|
||||
{ jsonrpc: "2.0", id: id, method: method, params: params }
|
||||
end
|
||||
|
||||
def json_rpc
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def initialize_mcp!(token)
|
||||
post "/mcp",
|
||||
params: rpc("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0" }
|
||||
}),
|
||||
as: :json,
|
||||
headers: mcp_headers(token)
|
||||
assert_response :success, response.body
|
||||
end
|
||||
|
||||
def fetch_tool_names
|
||||
initialize_mcp!(@token.plaintext)
|
||||
post "/mcp", params: rpc("tools/list", {}, id: 2), as: :json, headers: mcp_headers(@token.plaintext)
|
||||
assert_response :success, response.body
|
||||
Array(json_rpc.dig("result", "tools")).map { |t| t["name"] }
|
||||
end
|
||||
|
||||
def tool_names_from_initialize
|
||||
Array(json_rpc.dig("result", "tools")).map { |t| t["name"] }
|
||||
end
|
||||
|
||||
def call_tool(name, arguments, token: @token.plaintext, expect_error: false)
|
||||
initialize_mcp!(token)
|
||||
post "/mcp",
|
||||
params: rpc("tools/call", { name: name, arguments: arguments }, id: 2),
|
||||
as: :json,
|
||||
headers: mcp_headers(token)
|
||||
assert_response :success, response.body
|
||||
payload = json_rpc
|
||||
text = payload.dig("result", "content", 0, "text") || payload.dig("result", "content", 0, :text)
|
||||
refute_nil text, payload.inspect
|
||||
parsed = JSON.parse(text)
|
||||
if expect_error
|
||||
assert payload.dig("result", "isError") || parsed["error"].present?, payload.inspect
|
||||
else
|
||||
assert_nil parsed["error"], payload.inspect
|
||||
end
|
||||
parsed
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApiTokenTest < ActiveSupport::TestCase
|
||||
test "issue returns plaintext once and authenticates" do
|
||||
token = ApiToken.issue!(user: users(:marco), name: "Cursor")
|
||||
assert token.plaintext.start_with?("crm_")
|
||||
assert_equal token.plaintext[0, 8], token.token_prefix
|
||||
|
||||
found = ApiToken.authenticate(token.plaintext)
|
||||
assert_equal token.id, found.id
|
||||
end
|
||||
|
||||
test "revoked or inactive user does not authenticate" do
|
||||
token = ApiToken.issue!(user: users(:marco), name: "tmp")
|
||||
raw = token.plaintext
|
||||
token.revoke!
|
||||
assert_nil ApiToken.authenticate(raw)
|
||||
end
|
||||
|
||||
test "blank token does not authenticate" do
|
||||
assert_nil ApiToken.authenticate(nil)
|
||||
assert_nil ApiToken.authenticate("")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user