feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools delegate to *store.Store (no business-logic duplication). - handler.go: handleRPC routes initialize / tools/list / tools/call / ping / notifications/initialized; Bearer-token middleware; results flow through the standard MCP content[].text envelope; tool errors surface as isError: true (transport errors stay JSON-RPC errors). - tools.go: 10 tools — list_items / get_item / create_item / update_item / delete_item / list_links / add_link / remove_link / search / tree. Multi-parent in/out — parent_paths[] string array, resolved per call. itemView/linkView keep the wire shape snake_case and stable. - mcp_test.go + tools_test.go: protocol primitives (no DB) plus a full create → get → search → delete round-trip skipping cleanly when the DB env is absent. Multi-parent assertion discovers the test pair from the live DB rather than hard-coding a row. store extensions: - ListByFilters(SearchFilters) with parent_path/tags/management/kind/ status/q/has_repo/has_caldav predicates. - Search(q, limit) ranked across title/slug/aliases/content_md. - GetByPathOrSlug for callers that don't know the full path. - SoftDeleteCascade refuses on live descendants unless cascade=true. web: - New optional Server.MCP http.Handler. main.go mounts an mcp.Server when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses the Supabase-cookie auth middleware (its own Bearer auth applies). - Off cleanly when the token is unset. ops: - ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out, Bearer header). - .mcp.json adds an http-transport entry for clients that speak HTTP+MCP natively. - deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret. - docs/design.md §7 added: tool list, multi-parent semantics, env contract, transport + bridge.
This commit is contained in:
219
store/store.go
219
store/store.go
@@ -443,3 +443,222 @@ func (s *Store) SoftDelete(ctx context.Context, id string) error {
|
||||
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrHasLiveChildren is returned by SoftDeleteCascade when the caller did not
|
||||
// request cascade=true and the item has at least one undeleted descendant.
|
||||
var ErrHasLiveChildren = errors.New("projax: item has live children — pass cascade=true to soft-delete the whole subtree")
|
||||
|
||||
// SoftDeleteCascade soft-deletes the item and, if cascade is true, every
|
||||
// descendant (any row whose paths array contains a prefix matching this
|
||||
// item's primary path). Without cascade, it refuses when there is at least
|
||||
// one live descendant.
|
||||
func (s *Store) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
|
||||
it, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
primary := it.PrimaryPath()
|
||||
// Children are any other live row that names this id in their parent_ids
|
||||
// (direct children) or has a path with the primary path as a `.`-prefix
|
||||
// (transitive descendants).
|
||||
var childCount int
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
select count(*) from projax.items
|
||||
where deleted_at is null
|
||||
and id <> $1
|
||||
and ($1::uuid = any(parent_ids)
|
||||
or exists (select 1 from unnest(paths) p where p like $2 || '.%'))
|
||||
`, id, primary).Scan(&childCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count children: %w", err)
|
||||
}
|
||||
if childCount > 0 && !cascade {
|
||||
return ErrHasLiveChildren
|
||||
}
|
||||
if childCount > 0 && cascade {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
update projax.items set deleted_at = now()
|
||||
where deleted_at is null
|
||||
and ($1::uuid = any(parent_ids)
|
||||
or exists (select 1 from unnest(paths) p where p like $2 || '.%'))`,
|
||||
id, primary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cascade soft-delete: %w", err)
|
||||
}
|
||||
}
|
||||
return s.SoftDelete(ctx, id)
|
||||
}
|
||||
|
||||
// GetByPathOrSlug resolves a single item by either a dot path (any entry in
|
||||
// paths) or a bare root slug. Slug-only inputs match items whose paths array
|
||||
// contains exactly the slug (i.e. root items) as well as a fallback unique
|
||||
// slug match — useful for MCP callers that don't know the path.
|
||||
func (s *Store) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) {
|
||||
key = sanitizeKey(key)
|
||||
if key == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `select `+itemsUnifiedCols+`
|
||||
from projax.items_unified u
|
||||
where ($1 = any(u.paths) or u.slug = $1)
|
||||
order by case when $1 = any(u.paths) then 0 else 1 end
|
||||
limit 1`, key)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// SearchFilters narrows ListByFilters. Each field is treated independently;
|
||||
// the predicates AND together. Empty fields are no-ops.
|
||||
type SearchFilters struct {
|
||||
ParentPath string // any item whose paths array contains a path beginning with this prefix
|
||||
Tags []string // ALL must be present on the item
|
||||
Management []string // ALL must be present on the item
|
||||
Kind []string // ANY must be present on the item
|
||||
Status string // exact match (active|done|archived)
|
||||
Q string // ILIKE prefix-match on title/slug/aliases/content_md
|
||||
HasRepo *bool // if non-nil, item must (or must not) have a gitea-repo link
|
||||
HasCalDAV *bool // if non-nil, item must (or must not) have a caldav-list link
|
||||
Limit int // 0 → no limit
|
||||
}
|
||||
|
||||
// ListByFilters returns items_unified rows matching all supplied predicates.
|
||||
// Used by the MCP list_items tool.
|
||||
func (s *Store) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) {
|
||||
conds := []string{"true"}
|
||||
args := []any{}
|
||||
addArg := func(v any) string {
|
||||
args = append(args, v)
|
||||
return fmt.Sprintf("$%d", len(args))
|
||||
}
|
||||
if f.ParentPath != "" {
|
||||
p := addArg(f.ParentPath)
|
||||
// Path equals or starts with `<parent>.`
|
||||
conds = append(conds, fmt.Sprintf("exists (select 1 from unnest(u.paths) pp where pp = %s or pp like %s || '.%%')", p, p))
|
||||
}
|
||||
if len(f.Tags) > 0 {
|
||||
conds = append(conds, fmt.Sprintf("u.tags @> %s::text[]", addArg(f.Tags)))
|
||||
}
|
||||
if len(f.Management) > 0 {
|
||||
conds = append(conds, fmt.Sprintf("u.management @> %s::text[]", addArg(f.Management)))
|
||||
}
|
||||
if len(f.Kind) > 0 {
|
||||
conds = append(conds, fmt.Sprintf("u.kind && %s::text[]", addArg(f.Kind)))
|
||||
}
|
||||
if f.Status != "" {
|
||||
conds = append(conds, fmt.Sprintf("u.status = %s", addArg(f.Status)))
|
||||
}
|
||||
if f.Q != "" {
|
||||
q := addArg("%" + f.Q + "%")
|
||||
conds = append(conds, fmt.Sprintf("(u.title ilike %s or u.slug ilike %s or u.content_md ilike %s or exists (select 1 from unnest(u.aliases) a where a ilike %s))", q, q, q, q))
|
||||
}
|
||||
if f.HasRepo != nil {
|
||||
op := ""
|
||||
if *f.HasRepo {
|
||||
op = "exists"
|
||||
} else {
|
||||
op = "not exists"
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'gitea-repo')", op))
|
||||
}
|
||||
if f.HasCalDAV != nil {
|
||||
op := ""
|
||||
if *f.HasCalDAV {
|
||||
op = "exists"
|
||||
} else {
|
||||
op = "not exists"
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'caldav-list')", op))
|
||||
}
|
||||
q := `select ` + itemsUnifiedCols + ` from projax.items_unified u where ` + joinAnd(conds) + ` order by u.paths[1] nulls last, u.slug`
|
||||
if f.Limit > 0 {
|
||||
q += fmt.Sprintf(" limit %d", f.Limit)
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// Search returns ranked items_unified rows matching the query. Match buckets:
|
||||
// (0) exact slug, (1) title ILIKE prefix, (2) title contains, (3) alias hit,
|
||||
// (4) content_md contains. Within each bucket rows are sorted by primary path.
|
||||
func (s *Store) Search(ctx context.Context, q string, limit int) ([]*Item, error) {
|
||||
q = sanitizeKey(q)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
// Ranking is computed in SQL with a virtual `match_rank`, then we re-select
|
||||
// just the canonical column set so scanItems handles the row.
|
||||
sql := `with ranked as (
|
||||
select u.*,
|
||||
case
|
||||
when u.slug = $1 then 0
|
||||
when u.title ilike $1 || '%' then 1
|
||||
when u.title ilike '%' || $1 || '%' then 2
|
||||
when exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%') then 3
|
||||
when u.content_md ilike '%' || $1 || '%' then 4
|
||||
else 5
|
||||
end as match_rank
|
||||
from projax.items_unified u
|
||||
where true
|
||||
and (
|
||||
u.slug = $1
|
||||
or u.title ilike '%' || $1 || '%'
|
||||
or u.content_md ilike '%' || $1 || '%'
|
||||
or exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%')
|
||||
)
|
||||
)
|
||||
select ` + itemsUnifiedCols + `
|
||||
from ranked
|
||||
order by match_rank, paths[1] nulls last, slug
|
||||
limit $2`
|
||||
rows, err := s.Pool.Query(ctx, sql, q, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// sanitizeKey trims and rejects NUL / control characters that the planner
|
||||
// would otherwise have to deal with.
|
||||
func sanitizeKey(s string) string {
|
||||
s = trimSpace(s)
|
||||
for _, r := range s {
|
||||
if r == 0 || (r < 0x20 && r != '\t') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
|
||||
s = s[1:]
|
||||
}
|
||||
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func joinAnd(parts []string) string {
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
out += " and "
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user