package mcp import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/m/projax/store" ) // RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server. // All tools delegate to *store.Store directly so business logic is shared // with the web UI — no duplication. func RegisterProjaxTools(s *Server, st *store.Store) { s.Register(Tool{ Name: "list_items", Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav).", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "parent_path": {"type": "string", "description": "Match items whose paths array contains a path beginning with this prefix"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "All tags must be present"}, "management": {"type": "array", "items": {"type": "string"}, "description": "All management modes must be present (e.g. ['mai'])"}, "kind": {"type": "array", "items": {"type": "string"}, "description": "Any of these kinds matches"}, "status": {"type": "string"}, "q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}, "has_repo": {"type": "boolean"}, "has_caldav": {"type": "boolean"}, "limit": {"type": "integer", "minimum": 0} } }`), Handler: listItemsTool(st), }) s.Register(Tool{ Name: "get_item", Description: "Fetch a single item by id, dot-path (e.g. 'dev.paliad'), or root slug. Multi-parent items resolve to the same row from any path.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "id": {"type": "string", "description": "uuid"}, "path": {"type": "string", "description": "Dot-path or slug"}, "include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"} } }`), Handler: getItemTool(st), }) s.Register(Tool{ Name: "create_item", Description: "Create a new projax item. parent_paths is a string[] — pass [] for a root, ['work'] for single-parent, ['work','dev'] for multi-parent.", InputSchema: json.RawMessage(`{ "type": "object", "required": ["slug", "title"], "properties": { "slug": {"type": "string"}, "title": {"type": "string"}, "parent_paths": {"type": "array", "items": {"type": "string"}}, "kind": {"type": "array", "items": {"type": "string"}}, "tags": {"type": "array", "items": {"type": "string"}}, "management": {"type": "array", "items": {"type": "string"}}, "content_md": {"type": "string"}, "status": {"type": "string"}, "metadata": {"type": "object"} } }`), Handler: createItemTool(st), }) s.Register(Tool{ Name: "update_item", Description: "Partial update of an existing item. Pass any subset of title/slug/content_md/status/tags/management/parent_paths/pinned/archived. parent_paths replaces the full parent list.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "id": {"type": "string"}, "path": {"type": "string"}, "title": {"type": "string"}, "slug": {"type": "string"}, "parent_paths": {"type": "array", "items": {"type": "string"}}, "content_md": {"type": "string"}, "status": {"type": "string"}, "pinned": {"type": "boolean"}, "archived": {"type": "boolean"}, "tags": {"type": "array", "items": {"type": "string"}}, "management": {"type": "array", "items": {"type": "string"}} } }`), Handler: updateItemTool(st), }) s.Register(Tool{ Name: "delete_item", Description: "Soft-delete an item. Refuses on live descendants unless cascade=true.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "id": {"type": "string"}, "path": {"type": "string"}, "cascade": {"type": "boolean", "description": "Soft-delete every descendant too"} } }`), Handler: deleteItemTool(st), }) s.Register(Tool{ Name: "list_links", Description: "List item_links attached to one item.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "id": {"type": "string"}, "path": {"type": "string"}, "ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"} } }`), Handler: listLinksTool(st), }) s.Register(Tool{ Name: "add_link", Description: "Add an external item_link to an item (caldav-list / gitea-repo / document / note / url / …). Pass event_date=YYYY-MM-DD to anchor a dated artifact (PER day-granular).", InputSchema: json.RawMessage(`{ "type": "object", "required": ["ref_type", "ref_id"], "properties": { "id": {"type": "string"}, "path": {"type": "string"}, "ref_type": {"type": "string"}, "ref_id": {"type": "string"}, "rel": {"type": "string", "description": "Relation, default 'contains'"}, "note": {"type": "string"}, "event_date": {"type": "string", "description": "YYYY-MM-DD; day-granular anchor for PER-cited artifacts"}, "metadata": {"type": "object"} } }`), Handler: addLinkTool(st), }) s.Register(Tool{ Name: "remove_link", Description: "Delete an item_link by id.", InputSchema: json.RawMessage(`{ "type": "object", "required": ["link_id"], "properties": {"link_id": {"type": "string"}} }`), Handler: removeLinkTool(st), }) s.Register(Tool{ Name: "search", Description: "Ranked substring search across title/slug/aliases/content_md. Buckets: exact-slug → title-prefix → title-contains → alias → content.", InputSchema: json.RawMessage(`{ "type": "object", "required": ["query"], "properties": { "query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 200} } }`), Handler: searchTool(st), }) s.Register(Tool{ Name: "tree", Description: "Return a nested tree of items. Multi-parent items appear under each ancestor branch.", InputSchema: json.RawMessage(`{ "type": "object", "properties": { "root_path": {"type": "string", "description": "Optional subtree root; default returns the whole forest"}, "depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"} } }`), Handler: treeTool(st), }) } // itemView is the JSON shape returned to MCP clients. We hand-roll it so the // field names stay snake_case and the *time.Time / *string nullability // renders as JSON null instead of being skipped (omitempty would hide them). type itemView struct { ID string `json:"id"` Kind []string `json:"kind"` Title string `json:"title"` Slug string `json:"slug"` Paths []string `json:"paths"` ParentIDs []string `json:"parent_ids"` ContentMD string `json:"content_md"` Aliases []string `json:"aliases"` Metadata map[string]any `json:"metadata"` Status string `json:"status"` Pinned bool `json:"pinned"` Archived bool `json:"archived"` StartTime any `json:"start_time"` EndTime any `json:"end_time"` Source string `json:"source"` SourceRefID any `json:"source_ref_id"` Tags []string `json:"tags"` Management []string `json:"management"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Links []linkView `json:"links,omitempty"` } type linkView struct { ID string `json:"id"` ItemID string `json:"item_id"` RefType string `json:"ref_type"` RefID string `json:"ref_id"` Rel string `json:"rel"` Note any `json:"note"` Metadata map[string]any `json:"metadata"` CreatedAt string `json:"created_at"` EventDate any `json:"event_date"` } func toItemView(it *store.Item) itemView { v := itemView{ ID: it.ID, Kind: sliceOr(it.Kind, []string{}), Title: it.Title, Slug: it.Slug, Paths: sliceOr(it.Paths, []string{}), ParentIDs: sliceOr(it.ParentIDs, []string{}), ContentMD: it.ContentMD, Aliases: sliceOr(it.Aliases, []string{}), Metadata: mapOr(it.Metadata), Status: it.Status, Pinned: it.Pinned, Archived: it.Archived, Source: it.Source, Tags: sliceOr(it.Tags, []string{}), Management: sliceOr(it.Management, []string{}), CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), UpdatedAt: it.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), } if it.StartTime != nil { v.StartTime = it.StartTime.UTC().Format("2006-01-02T15:04:05Z") } if it.EndTime != nil { v.EndTime = it.EndTime.UTC().Format("2006-01-02T15:04:05Z") } if it.SourceRefID != nil { v.SourceRefID = *it.SourceRefID } return v } func toLinkView(l *store.ItemLink) linkView { v := linkView{ ID: l.ID, ItemID: l.ItemID, RefType: l.RefType, RefID: l.RefID, Rel: l.Rel, Metadata: mapOr(l.Metadata), CreatedAt: l.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), } if l.Note != nil { v.Note = *l.Note } if l.EventDate != nil { v.EventDate = l.EventDate.UTC().Format("2006-01-02") } return v } func sliceOr[T any](v []T, fallback []T) []T { if v == nil { return fallback } return v } func mapOr(v map[string]any) map[string]any { if v == nil { return map[string]any{} } return v } // resolveItem turns an id-or-path argument pair into a concrete *store.Item. func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) { id = strings.TrimSpace(id) path = strings.TrimSpace(path) if id != "" { return st.GetByID(ctx, id) } if path != "" { return st.GetByPathOrSlug(ctx, path) } return nil, errors.New("either id or path is required") } func parseInput[T any](raw json.RawMessage, dst *T) error { if len(raw) == 0 { return nil } return json.Unmarshal(raw, dst) } // --- list_items --- func listItemsTool(st *store.Store) ToolHandler { type input struct { ParentPath string `json:"parent_path"` Tags []string `json:"tags"` Management []string `json:"management"` Kind []string `json:"kind"` Status string `json:"status"` Q string `json:"q"` HasRepo *bool `json:"has_repo"` HasCalDAV *bool `json:"has_caldav"` Limit int `json:"limit"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } items, err := st.ListByFilters(ctx, store.SearchFilters{ ParentPath: in.ParentPath, Tags: in.Tags, Management: in.Management, Kind: in.Kind, Status: in.Status, Q: in.Q, HasRepo: in.HasRepo, HasCalDAV: in.HasCalDAV, Limit: in.Limit, }) if err != nil { return nil, err } views := make([]itemView, 0, len(items)) for _, it := range items { views = append(views, toItemView(it)) } return map[string]any{"items": views, "count": len(views)}, nil } } // --- get_item --- func getItemTool(st *store.Store) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` IncludeLinks *bool `json:"include_links"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } it, err := resolveItem(ctx, st, in.ID, in.Path) if err != nil { return nil, err } view := toItemView(it) include := true if in.IncludeLinks != nil { include = *in.IncludeLinks } if include { links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types // LinksByType filters by ref_type — empty would return nothing. So // we explicitly list_all by fanning across the known types. _ = err links = nil for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} { ll, err := st.LinksByType(ctx, it.ID, t) if err != nil { continue } links = append(links, ll...) } views := make([]linkView, 0, len(links)) for _, l := range links { views = append(views, toLinkView(l)) } view.Links = views } return view, nil } } // --- create_item --- func createItemTool(st *store.Store) ToolHandler { type input struct { Slug string `json:"slug"` Title string `json:"title"` ParentPaths []string `json:"parent_paths"` Kind []string `json:"kind"` Tags []string `json:"tags"` Management []string `json:"management"` ContentMD string `json:"content_md"` Status string `json:"status"` Metadata map[string]any `json:"metadata"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } if in.Slug == "" || in.Title == "" { return nil, errors.New("slug and title are required") } parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths) if err != nil { return nil, err } kind := in.Kind if len(kind) == 0 { kind = []string{"project"} } it, err := st.Create(ctx, store.CreateInput{ Kind: kind, Title: in.Title, Slug: in.Slug, ParentIDs: parentIDs, ContentMD: in.ContentMD, Status: in.Status, Tags: in.Tags, Management: in.Management, Metadata: in.Metadata, }) if err != nil { return nil, err } return toItemView(it), nil } } func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) { out := make([]string, 0, len(paths)) for _, p := range paths { p = strings.TrimSpace(p) if p == "" { continue } it, err := st.GetByPathOrSlug(ctx, p) if err != nil { return nil, fmt.Errorf("parent path %q: %w", p, err) } out = append(out, it.ID) } return out, nil } // --- update_item --- func updateItemTool(st *store.Store) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` Title *string `json:"title"` Slug *string `json:"slug"` ParentPaths *[]string `json:"parent_paths"` ContentMD *string `json:"content_md"` Status *string `json:"status"` Pinned *bool `json:"pinned"` Archived *bool `json:"archived"` Tags *[]string `json:"tags"` Management *[]string `json:"management"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } it, err := resolveItem(ctx, st, in.ID, in.Path) if err != nil { return nil, err } patch := store.UpdateInput{ Title: it.Title, Slug: it.Slug, ParentIDs: it.ParentIDs, ContentMD: it.ContentMD, Status: it.Status, Pinned: it.Pinned, Archived: it.Archived, Tags: it.Tags, Management: it.Management, } if in.Title != nil { patch.Title = *in.Title } if in.Slug != nil { patch.Slug = *in.Slug } if in.ContentMD != nil { patch.ContentMD = *in.ContentMD } if in.Status != nil { patch.Status = *in.Status } if in.Pinned != nil { patch.Pinned = *in.Pinned } if in.Archived != nil { patch.Archived = *in.Archived } if in.Tags != nil { patch.Tags = *in.Tags } if in.Management != nil { patch.Management = *in.Management } if in.ParentPaths != nil { pids, err := resolveParentPaths(ctx, st, *in.ParentPaths) if err != nil { return nil, err } patch.ParentIDs = pids } updated, err := st.Update(ctx, it.ID, patch) if err != nil { return nil, err } return toItemView(updated), nil } } // --- delete_item --- func deleteItemTool(st *store.Store) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` Cascade bool `json:"cascade"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } it, err := resolveItem(ctx, st, in.ID, in.Path) if err != nil { return nil, err } if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil { return nil, err } return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil } } // --- list_links --- func listLinksTool(st *store.Store) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` RefType string `json:"ref_type"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } it, err := resolveItem(ctx, st, in.ID, in.Path) if err != nil { return nil, err } var links []*store.ItemLink if in.RefType != "" { links, err = st.LinksByType(ctx, it.ID, in.RefType) } else { for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} { ll, lerr := st.LinksByType(ctx, it.ID, t) if lerr != nil { continue } links = append(links, ll...) } } if err != nil { return nil, err } views := make([]linkView, 0, len(links)) for _, l := range links { views = append(views, toLinkView(l)) } return map[string]any{"links": views, "count": len(views)}, nil } } // --- add_link / remove_link --- func addLinkTool(st *store.Store) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` RefType string `json:"ref_type"` RefID string `json:"ref_id"` Rel string `json:"rel"` Note string `json:"note"` EventDate string `json:"event_date"` Metadata map[string]any `json:"metadata"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } if in.RefType == "" || in.RefID == "" { return nil, errors.New("ref_type and ref_id are required") } it, err := resolveItem(ctx, st, in.ID, in.Path) if err != nil { return nil, err } md := in.Metadata if md == nil { md = map[string]any{} } var notePtr *string if in.Note != "" { n := in.Note notePtr = &n } var datePtr *time.Time if strings.TrimSpace(in.EventDate) != "" { t, err := time.Parse("2006-01-02", strings.TrimSpace(in.EventDate)) if err != nil { return nil, fmt.Errorf("event_date must be YYYY-MM-DD: %w", err) } datePtr = &t } link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md) if err != nil { return nil, err } return toLinkView(link), nil } } func removeLinkTool(st *store.Store) ToolHandler { type input struct { LinkID string `json:"link_id"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } if in.LinkID == "" { return nil, errors.New("link_id is required") } if err := st.DeleteLink(ctx, in.LinkID); err != nil { return nil, err } return map[string]any{"deleted": in.LinkID}, nil } } // --- search --- func searchTool(st *store.Store) ToolHandler { type input struct { Query string `json:"query"` Limit int `json:"limit"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } if in.Query == "" { return nil, errors.New("query is required") } items, err := st.Search(ctx, in.Query, in.Limit) if err != nil { return nil, err } views := make([]itemView, 0, len(items)) for _, it := range items { views = append(views, toItemView(it)) } return map[string]any{"items": views, "count": len(views), "query": in.Query}, nil } } // --- tree --- type treeNode struct { Item itemView `json:"item"` Path string `json:"path"` // the path under which this node appears in the tree Children []*treeNode `json:"children"` } func treeTool(st *store.Store) ToolHandler { type input struct { RootPath string `json:"root_path"` Depth int `json:"depth"` } return func(ctx context.Context, raw json.RawMessage) (any, error) { var in input if err := parseInput(raw, &in); err != nil { return nil, fmt.Errorf("bad params: %w", err) } items, err := st.ListAll(ctx) if err != nil { return nil, err } // Build adjacency by parent id (the same row appears once per parent). byID := map[string]*store.Item{} childrenByParent := map[string][]*store.Item{} var roots []*store.Item for _, it := range items { byID[it.ID] = it if len(it.ParentIDs) == 0 { roots = append(roots, it) continue } for _, pid := range it.ParentIDs { childrenByParent[pid] = append(childrenByParent[pid], it) } } var build func(it *store.Item, path string, depth int) *treeNode build = func(it *store.Item, path string, depth int) *treeNode { n := &treeNode{Item: toItemView(it), Path: path} if in.Depth > 0 && depth >= in.Depth { return n } for _, c := range childrenByParent[it.ID] { n.Children = append(n.Children, build(c, path+"."+c.Slug, depth+1)) } return n } var out []*treeNode if in.RootPath != "" { root, err := st.GetByPathOrSlug(ctx, in.RootPath) if err != nil { return nil, err } out = append(out, build(root, in.RootPath, 0)) } else { for _, r := range roots { out = append(out, build(r, r.Slug, 0)) } } return map[string]any{"tree": out, "roots": len(out)}, nil } }