# 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 class UpdateOrganizationEmail < MCP::Tool description "Aggiorna email organizzazione/contatto: marca bounce, sostituisci indirizzo, annota timeline." input_schema( properties: { project_code: { type: "string" }, organization_id: { type: "integer" }, email: { type: "string", description: "Nuova email se trovata" }, email_invalid: { type: "boolean" }, bounced_email: { type: "string" }, bounce_reason: { type: "string" }, contact_id: { type: "integer" }, website: { type: "string" }, activity_subject: { type: "string" }, activity_description: { type: "string" } }, required: %w[project_code organization_id] ) class << self def call(project_code:, organization_id:, email: nil, email_invalid: nil, bounced_email: nil, bounce_reason: nil, contact_id: nil, website: nil, activity_subject: nil, activity_description: nil, server_context: nil) body = Client.compact( organization_id: organization_id, email: email, bounced_email: bounced_email, bounce_reason: bounce_reason, contact_id: contact_id, website: website, activity_subject: activity_subject, activity_description: activity_description ) body[:email_invalid] = email_invalid unless email_invalid.nil? Client.request( :patch, "/api/v1/p/#{Today.encode(project_code)}/organizations/#{organization_id}/email", body: body ) end end end class CheckMailBounces < MCP::Tool description "Legge bounce IMAP dall'account email CRM (credenziali SMTP). Matcha le società del progetto." input_schema( properties: { project_code: { type: "string" }, from_email: { type: "string", description: "es. info@matchlivetv.it" }, mail_identity_id: { type: "integer" }, since_days: { type: "integer" }, mailbox: { type: "string" }, limit: { type: "integer" } }, required: %w[project_code] ) class << self def call(project_code:, from_email: nil, mail_identity_id: nil, since_days: nil, mailbox: nil, limit: nil, server_context: nil) query = URI.encode_www_form( Client.compact( from_email: from_email, mail_identity_id: mail_identity_id, since_days: since_days, mailbox: mailbox, limit: limit ) ) path = "/api/v1/p/#{Today.encode(project_code)}/mail_bounces" path = "#{path}?#{query}" if query.present? Client.request(:get, path) 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, EminuxCrmMcp::UpdateOrganizationEmail, EminuxCrmMcp::CheckMailBounces ] ) transport = MCP::Server::Transports::StdioTransport.new(server) transport.open if $PROGRAM_NAME == __FILE__ || File.basename($PROGRAM_NAME.to_s) == "crm-mcp"