package tools import ( "context" "fmt" "strings" "time" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" "git.warky.dev/wdevs/amcs/internal/store" ext "git.warky.dev/wdevs/amcs/internal/types" ) type CRMTool struct { store *store.DB } func NewCRMTool(db *store.DB) *CRMTool { return &CRMTool{store: db} } // add_professional_contact type AddContactInput struct { Name string `json:"name" jsonschema:"contact's full name"` Company string `json:"company,omitempty"` Title string `json:"title,omitempty" jsonschema:"job title"` Email string `json:"email,omitempty"` Phone string `json:"phone,omitempty"` LinkedInURL string `json:"linkedin_url,omitempty"` HowWeMet string `json:"how_we_met,omitempty"` Tags []string `json:"tags,omitempty"` Notes string `json:"notes,omitempty"` FollowUpDate *time.Time `json:"follow_up_date,omitempty"` } type AddContactOutput struct { Contact ext.ProfessionalContact `json:"contact"` } func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) { if strings.TrimSpace(in.Name) == "" { return nil, AddContactOutput{}, errInvalidInput("name is required") } if in.Tags == nil { in.Tags = []string{} } contact, err := t.store.AddProfessionalContact(ctx, ext.ProfessionalContact{ Name: strings.TrimSpace(in.Name), Company: strings.TrimSpace(in.Company), Title: strings.TrimSpace(in.Title), Email: strings.TrimSpace(in.Email), Phone: strings.TrimSpace(in.Phone), LinkedInURL: strings.TrimSpace(in.LinkedInURL), HowWeMet: strings.TrimSpace(in.HowWeMet), Tags: in.Tags, Notes: strings.TrimSpace(in.Notes), FollowUpDate: in.FollowUpDate, }) if err != nil { return nil, AddContactOutput{}, err } return nil, AddContactOutput{Contact: contact}, nil } // search_contacts type SearchContactsInput struct { Query string `json:"query,omitempty" jsonschema:"search text matching name, company, title, or notes"` Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"` } type SearchContactsOutput struct { Contacts []ext.ProfessionalContact `json:"contacts"` } func (t *CRMTool) SearchContacts(ctx context.Context, _ *mcp.CallToolRequest, in SearchContactsInput) (*mcp.CallToolResult, SearchContactsOutput, error) { contacts, err := t.store.SearchContacts(ctx, in.Query, in.Tags) if err != nil { return nil, SearchContactsOutput{}, err } if contacts == nil { contacts = []ext.ProfessionalContact{} } return nil, SearchContactsOutput{Contacts: contacts}, nil } // log_interaction type LogInteractionInput struct { ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"` InteractionType string `json:"interaction_type" jsonschema:"one of: meeting, email, call, coffee, event, linkedin, other"` OccurredAt *time.Time `json:"occurred_at,omitempty" jsonschema:"when it happened (defaults to now)"` Summary string `json:"summary" jsonschema:"summary of the interaction"` FollowUpNeeded bool `json:"follow_up_needed,omitempty"` FollowUpNotes string `json:"follow_up_notes,omitempty"` } type LogInteractionOutput struct { Interaction ext.ContactInteraction `json:"interaction"` } func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) { if strings.TrimSpace(in.Summary) == "" { return nil, LogInteractionOutput{}, errInvalidInput("summary is required") } occurredAt := time.Now() if in.OccurredAt != nil { occurredAt = *in.OccurredAt } interaction, err := t.store.LogInteraction(ctx, ext.ContactInteraction{ ContactID: in.ContactID, InteractionType: in.InteractionType, OccurredAt: occurredAt, Summary: strings.TrimSpace(in.Summary), FollowUpNeeded: in.FollowUpNeeded, FollowUpNotes: strings.TrimSpace(in.FollowUpNotes), }) if err != nil { return nil, LogInteractionOutput{}, err } return nil, LogInteractionOutput{Interaction: interaction}, nil } // get_contact_history type GetContactHistoryInput struct { ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"` } type GetContactHistoryOutput struct { History ext.ContactHistory `json:"history"` } func (t *CRMTool) GetHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetContactHistoryInput) (*mcp.CallToolResult, GetContactHistoryOutput, error) { history, err := t.store.GetContactHistory(ctx, in.ContactID) if err != nil { return nil, GetContactHistoryOutput{}, err } return nil, GetContactHistoryOutput{History: history}, nil } // create_opportunity type CreateOpportunityInput struct { ContactID *uuid.UUID `json:"contact_id,omitempty"` Title string `json:"title" jsonschema:"opportunity title"` Description string `json:"description,omitempty"` Stage string `json:"stage,omitempty" jsonschema:"one of: identified, in_conversation, proposal, negotiation, won, lost (default: identified)"` Value *float64 `json:"value,omitempty" jsonschema:"monetary value"` ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"` Notes string `json:"notes,omitempty"` } type CreateOpportunityOutput struct { Opportunity ext.Opportunity `json:"opportunity"` } func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) { if strings.TrimSpace(in.Title) == "" { return nil, CreateOpportunityOutput{}, errInvalidInput("title is required") } stage := strings.TrimSpace(in.Stage) if stage == "" { stage = "identified" } opp, err := t.store.CreateOpportunity(ctx, ext.Opportunity{ ContactID: in.ContactID, Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), Stage: stage, Value: in.Value, ExpectedCloseDate: in.ExpectedCloseDate, Notes: strings.TrimSpace(in.Notes), }) if err != nil { return nil, CreateOpportunityOutput{}, err } return nil, CreateOpportunityOutput{Opportunity: opp}, nil } // get_follow_ups_due type GetFollowUpsDueInput struct { DaysAhead int `json:"days_ahead,omitempty" jsonschema:"look ahead window in days (default: 7)"` } type GetFollowUpsDueOutput struct { Contacts []ext.ProfessionalContact `json:"contacts"` } func (t *CRMTool) GetFollowUpsDue(ctx context.Context, _ *mcp.CallToolRequest, in GetFollowUpsDueInput) (*mcp.CallToolResult, GetFollowUpsDueOutput, error) { contacts, err := t.store.GetFollowUpsDue(ctx, in.DaysAhead) if err != nil { return nil, GetFollowUpsDueOutput{}, err } if contacts == nil { contacts = []ext.ProfessionalContact{} } return nil, GetFollowUpsDueOutput{Contacts: contacts}, nil } // link_thought_to_contact type LinkThoughtToContactInput struct { ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"` ThoughtID uuid.UUID `json:"thought_id" jsonschema:"id of the thought to link"` } type LinkThoughtToContactOutput struct { Contact ext.ProfessionalContact `json:"contact"` } func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) { thought, err := t.store.GetThought(ctx, in.ThoughtID) if err != nil { return nil, LinkThoughtToContactOutput{}, fmt.Errorf("thought not found: %w", err) } appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content) if err := t.store.AppendThoughtToContactNotes(ctx, in.ContactID, appendText); err != nil { return nil, LinkThoughtToContactOutput{}, err } contact, err := t.store.GetContact(ctx, in.ContactID) if err != nil { return nil, LinkThoughtToContactOutput{}, err } return nil, LinkThoughtToContactOutput{Contact: contact}, nil }