package tools import ( "context" "strings" "time" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" "git.warky.dev/wdevs/amcs/internal/config" "git.warky.dev/wdevs/amcs/internal/session" "git.warky.dev/wdevs/amcs/internal/store" thoughttypes "git.warky.dev/wdevs/amcs/internal/types" ) type PlansTool struct { store *store.DB sessions *session.ActiveProjects cfg config.SearchConfig } func NewPlansTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *PlansTool { return &PlansTool{store: db, sessions: sessions, cfg: cfg} } // --- I/O types --- type CreatePlanInput struct { Title string `json:"title" jsonschema:"plan title"` Description string `json:"description,omitempty"` Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"` Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"` Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"` Owner string `json:"owner,omitempty"` DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339 timestamp"` SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"` Tags []string `json:"tags,omitempty"` } type CreatePlanOutput struct { Plan thoughttypes.Plan `json:"plan"` } type GetPlanInput struct { ID uuid.UUID `json:"id" jsonschema:"plan id"` } type GetPlanOutput struct { Plan thoughttypes.PlanDetail `json:"plan"` } type UpdatePlanInput struct { ID uuid.UUID `json:"id" jsonschema:"plan id"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"` Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"` Owner *string `json:"owner,omitempty" jsonschema:"empty string clears the owner"` DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"` ClearDueDate bool `json:"clear_due_date,omitempty"` CompletedAt string `json:"completed_at,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"` ClearCompletedAt bool `json:"clear_completed_at,omitempty"` ReviewedBy *string `json:"reviewed_by,omitempty" jsonschema:"empty string clears the reviewer"` MarkReviewed bool `json:"mark_reviewed,omitempty" jsonschema:"set last_reviewed_at to now"` SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"` ClearSupersedesPlanID bool `json:"clear_supersedes_plan_id,omitempty"` Tags *[]string `json:"tags,omitempty" jsonschema:"replaces all tags when provided; pass [] to clear"` } type UpdatePlanOutput struct { Plan thoughttypes.Plan `json:"plan"` } type DeletePlanInput struct { ID uuid.UUID `json:"id" jsonschema:"plan id"` } type DeletePlanOutput struct { Deleted bool `json:"deleted"` } type ListPlansInput struct { Limit int `json:"limit,omitempty"` Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` Owner string `json:"owner,omitempty"` Tag string `json:"tag,omitempty"` Query string `json:"query,omitempty"` } type ListPlansOutput struct { Plans []thoughttypes.Plan `json:"plans"` } type PlanDependencyInput struct { PlanID uuid.UUID `json:"plan_id" jsonschema:"the plan that depends on another"` DependsOnPlanID uuid.UUID `json:"depends_on_plan_id" jsonschema:"the plan that must complete first"` } type PlanRelatedInput struct { PlanAID uuid.UUID `json:"plan_a_id"` PlanBID uuid.UUID `json:"plan_b_id"` } type PlanLinkOutput struct { OK bool `json:"ok"` } type PlanSkillInput struct { PlanID uuid.UUID `json:"plan_id"` SkillID uuid.UUID `json:"skill_id"` } type ListPlanSkillsInput struct { PlanID uuid.UUID `json:"plan_id"` } type ListPlanSkillsOutput struct { Skills []thoughttypes.AgentSkill `json:"skills"` } type PlanGuardrailInput struct { PlanID uuid.UUID `json:"plan_id"` GuardrailID uuid.UUID `json:"guardrail_id"` } type ListPlanGuardrailsInput struct { PlanID uuid.UUID `json:"plan_id"` } type ListPlanGuardrailsOutput struct { Guardrails []thoughttypes.AgentGuardrail `json:"guardrails"` } // --- Handlers --- func (t *PlansTool) Create(ctx context.Context, req *mcp.CallToolRequest, in CreatePlanInput) (*mcp.CallToolResult, CreatePlanOutput, error) { title := strings.TrimSpace(in.Title) if title == "" { return nil, CreatePlanOutput{}, errRequiredField("title") } project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) if err != nil { return nil, CreatePlanOutput{}, err } plan := thoughttypes.Plan{ Title: title, Description: strings.TrimSpace(in.Description), Status: thoughttypes.PlanStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.PlanStatusDraft))), Priority: thoughttypes.PlanPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.PlanPriorityMedium))), Owner: strings.TrimSpace(in.Owner), SupersedesPlanID: in.SupersedesPlanID, Tags: normalizeStringSlice(in.Tags), } if project != nil { plan.ProjectID = &project.ID } if v := strings.TrimSpace(in.DueDate); v != "" { t, err := time.Parse(time.RFC3339, v) if err != nil { return nil, CreatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format") } plan.DueDate = &t } created, err := t.store.CreatePlan(ctx, plan) if err != nil { return nil, CreatePlanOutput{}, err } return nil, CreatePlanOutput{Plan: created}, nil } func (t *PlansTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetPlanInput) (*mcp.CallToolResult, GetPlanOutput, error) { detail, err := t.store.GetPlanDetail(ctx, in.ID) if err != nil { return nil, GetPlanOutput{}, err } return nil, GetPlanOutput{Plan: detail}, nil } func (t *PlansTool) Update(ctx context.Context, _ *mcp.CallToolRequest, in UpdatePlanInput) (*mcp.CallToolResult, UpdatePlanOutput, error) { u := thoughttypes.PlanUpdate{ ReviewedBy: in.ReviewedBy, MarkReviewed: in.MarkReviewed, ClearDueDate: in.ClearDueDate, ClearCompletedAt: in.ClearCompletedAt, ClearSupersedesPlanID: in.ClearSupersedesPlanID, SupersedesPlanID: in.SupersedesPlanID, Tags: in.Tags, Owner: in.Owner, } if v := strings.TrimSpace(in.Title); v != "" { u.Title = &v } if v := strings.TrimSpace(in.Description); v != "" { u.Description = &v } if v := strings.TrimSpace(in.Status); v != "" { u.Status = &v } if v := strings.TrimSpace(in.Priority); v != "" { u.Priority = &v } if v := strings.TrimSpace(in.DueDate); v != "" && !in.ClearDueDate { parsed, err := time.Parse(time.RFC3339, v) if err != nil { return nil, UpdatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format") } u.DueDate = &parsed } if v := strings.TrimSpace(in.CompletedAt); v != "" && !in.ClearCompletedAt { parsed, err := time.Parse(time.RFC3339, v) if err != nil { return nil, UpdatePlanOutput{}, errInvalidField("completed_at", "invalid completed_at", "use RFC3339 format") } u.CompletedAt = &parsed } plan, err := t.store.UpdatePlan(ctx, in.ID, u) if err != nil { return nil, UpdatePlanOutput{}, err } return nil, UpdatePlanOutput{Plan: plan}, nil } func (t *PlansTool) Delete(ctx context.Context, _ *mcp.CallToolRequest, in DeletePlanInput) (*mcp.CallToolResult, DeletePlanOutput, error) { if err := t.store.DeletePlan(ctx, in.ID); err != nil { return nil, DeletePlanOutput{}, err } return nil, DeletePlanOutput{Deleted: true}, nil } func (t *PlansTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListPlansInput) (*mcp.CallToolResult, ListPlansOutput, error) { project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) if err != nil { return nil, ListPlansOutput{}, err } filter := thoughttypes.PlanFilter{ Limit: normalizeLimit(in.Limit, t.cfg), Status: strings.TrimSpace(in.Status), Priority: strings.TrimSpace(in.Priority), Owner: strings.TrimSpace(in.Owner), Tag: strings.TrimSpace(in.Tag), Query: strings.TrimSpace(in.Query), } if project != nil { filter.ProjectID = &project.ID } plans, err := t.store.ListPlans(ctx, filter) if err != nil { return nil, ListPlansOutput{}, err } return nil, ListPlansOutput{Plans: plans}, nil } func (t *PlansTool) AddDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if in.PlanID == in.DependsOnPlanID { return nil, PlanLinkOutput{}, errInvalidField("depends_on_plan_id", "a plan cannot depend on itself", "use a different plan id") } if err := t.store.AddPlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) RemoveDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if err := t.store.RemovePlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) AddRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if in.PlanAID == in.PlanBID { return nil, PlanLinkOutput{}, errInvalidField("plan_b_id", "a plan cannot be related to itself", "use a different plan id") } if err := t.store.AddRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) RemoveRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if err := t.store.RemoveRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if err := t.store.AddPlanSkill(ctx, in.PlanID, in.SkillID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if err := t.store.RemovePlanSkill(ctx, in.PlanID, in.SkillID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanSkillsInput) (*mcp.CallToolResult, ListPlanSkillsOutput, error) { skills, err := t.store.ListPlanSkills(ctx, in.PlanID) if err != nil { return nil, ListPlanSkillsOutput{}, err } if skills == nil { skills = []thoughttypes.AgentSkill{} } return nil, ListPlanSkillsOutput{Skills: skills}, nil } func (t *PlansTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if err := t.store.AddPlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) RemoveGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) { if err := t.store.RemovePlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil { return nil, PlanLinkOutput{}, err } return nil, PlanLinkOutput{OK: true}, nil } func (t *PlansTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanGuardrailsInput) (*mcp.CallToolResult, ListPlanGuardrailsOutput, error) { guardrails, err := t.store.ListPlanGuardrails(ctx, in.PlanID) if err != nil { return nil, ListPlanGuardrailsOutput{}, err } if guardrails == nil { guardrails = []thoughttypes.AgentGuardrail{} } return nil, ListPlanGuardrailsOutput{Guardrails: guardrails}, nil }