E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
340 lines
18 KiB
Go
340 lines
18 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/google/uuid"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
|
return []toolReg{
|
|
{tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
q := nStr(args["query"])
|
|
return annotateJSONResult(queryRows(ctx, pool, `
|
|
SELECT ke.title, e.slug,
|
|
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
|
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
|
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
|
FragmentDelimiter=" ... "') AS snippet,
|
|
ke.source, ke.tags
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
|
ORDER BY rank DESC
|
|
LIMIT 20`, q), "knowledge_results"), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
|
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
slug, _ := args["entity_slug"].(string)
|
|
return annotateJSONResult(queryRows(ctx, pool, `
|
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
JOIN relationships r ON r.source_id = ke.entity_id
|
|
JOIN entities target ON target.id = r.target_id
|
|
WHERE target.slug = $1
|
|
AND r.valid_to IS NULL
|
|
AND r.type IN ('documents', 'about')
|
|
UNION
|
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
JOIN relationships r ON r.source_id = ke.entity_id
|
|
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
|
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
|
WHERE r.valid_to IS NULL
|
|
AND r.type = 'procedure-for'
|
|
ORDER BY 1`, slug), "knowledge_results"), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
|
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
slug, _ := args["slug"].(string)
|
|
return queryRows(ctx, pool, `
|
|
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE e.slug = $1`, slug), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
|
InputSchema: objSchema(
|
|
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
|
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
|
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
|
|
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
|
|
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
|
|
),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
return upsertKnowledge(ctx, pool, args)
|
|
}},
|
|
{tool: &mcp.Tool{Name: "delete_knowledge", Description: "Soft-delete a knowledge entry (move to trash, restorable with restore_knowledge). The content and revision history survive.",
|
|
InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
slug, _ := args["knowledge_slug"].(string)
|
|
var entityID uuid.UUID
|
|
if u, err := uuid.Parse(slug); err == nil {
|
|
entityID = u
|
|
} else {
|
|
pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID)
|
|
}
|
|
if entityID == uuid.Nil {
|
|
return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil
|
|
}
|
|
// Snapshot before tombstoning.
|
|
pool.Exec(ctx, `
|
|
INSERT INTO knowledge_revisions (entity_id, title, content, source, tags, edited_by, version_at)
|
|
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
|
|
FROM knowledge_entities WHERE entity_id = $1`, entityID)
|
|
tag, err := pool.Exec(ctx,
|
|
`UPDATE knowledge_entities SET deleted_at = now(), edited_by = 'nomos'
|
|
WHERE entity_id = $1 AND deleted_at IS NULL`, entityID)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return textResult("knowledge entry already deleted"), nil
|
|
}
|
|
return textResult(fmt.Sprintf("Knowledge %s soft-deleted. Restore with restore_knowledge.", slug)), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "restore_knowledge", Description: "Restore a soft-deleted knowledge entry from trash. Undoes delete_knowledge.",
|
|
InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
slug, _ := args["knowledge_slug"].(string)
|
|
var entityID uuid.UUID
|
|
if u, err := uuid.Parse(slug); err == nil {
|
|
entityID = u
|
|
} else {
|
|
pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID)
|
|
}
|
|
if entityID == uuid.Nil {
|
|
return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil
|
|
}
|
|
tag, err := pool.Exec(ctx,
|
|
`UPDATE knowledge_entities SET deleted_at = NULL, edited_by = 'nomos'
|
|
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return textResult("knowledge entry is not deleted"), nil
|
|
}
|
|
return textResult(fmt.Sprintf("Knowledge %s restored from trash.", slug)), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "merge_knowledge", Description: "Fold one or more knowledge entries into a target. Source content is appended under a provenance heading, and the union of all tags is kept. Sources are soft-deleted afterwards.",
|
|
InputSchema: objSchema(
|
|
prop{"target_slug", "string", "Knowledge entry to merge INTO (slug or UUID)"},
|
|
prop{"source_slugs", "string", "Comma-separated slugs of entries to fold into the target"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
targetSlug, _ := args["target_slug"].(string)
|
|
sourceStr, _ := args["source_slugs"].(string)
|
|
|
|
var targetID uuid.UUID
|
|
if u, err := uuid.Parse(targetSlug); err == nil {
|
|
targetID = u
|
|
} else {
|
|
pool.QueryRow(ctx, `
|
|
SELECT ke.entity_id FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
|
targetSlug).Scan(&targetID)
|
|
}
|
|
if targetID == uuid.Nil {
|
|
return textResult(fmt.Sprintf("target knowledge entry not found: %s", targetSlug)), nil
|
|
}
|
|
|
|
sources := []string{}
|
|
for _, s := range strings.Split(sourceStr, ",") {
|
|
if s = strings.TrimSpace(s); s != "" && s != targetSlug {
|
|
sources = append(sources, s)
|
|
}
|
|
}
|
|
if len(sources) == 0 {
|
|
return textResult("no valid source entries to merge"), nil
|
|
}
|
|
|
|
var appended strings.Builder
|
|
merged := []string{}
|
|
for _, srcSlug := range sources {
|
|
var title, content, updated string
|
|
var tags []string
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
|
srcSlug).Scan(&title, &content, &tags, &updated)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
appended.WriteString("\n\n---\n\n## Merged: ")
|
|
appended.WriteString(title)
|
|
appended.WriteString("\n\n*Originally ")
|
|
appended.WriteString(srcSlug)
|
|
appended.WriteString(", last updated ")
|
|
appended.WriteString(updated)
|
|
appended.WriteString("*\n\n")
|
|
appended.WriteString(content)
|
|
for _, t := range tags {
|
|
fmt.Fprintf(&appended, "\ntag: %s", strings.ToLower(strings.TrimSpace(t)))
|
|
}
|
|
merged = append(merged, srcSlug)
|
|
}
|
|
|
|
if len(merged) == 0 {
|
|
return textResult("no source entries could be read"), nil
|
|
}
|
|
|
|
_, err := pool.Exec(ctx, `
|
|
UPDATE knowledge_entities SET content = content || $2, edited_by = 'nomos', updated_at = now()
|
|
WHERE entity_id = $1`, targetID, appended.String())
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error appending content: %v", err)), nil
|
|
}
|
|
|
|
for _, srcSlug := range merged {
|
|
pool.Exec(ctx, `
|
|
UPDATE knowledge_entities ke SET deleted_at = now(), edited_by = 'nomos'
|
|
FROM entities e
|
|
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
|
|
srcSlug)
|
|
}
|
|
|
|
return textResult(fmt.Sprintf("Merged %d entries into %s: %s", len(merged), targetSlug, strings.Join(merged, ", "))), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "rename_knowledge_tag", Description: "Bulk-rename one or more tags across all knowledge entries. Case-insensitive matching — 'oom' and 'OOM' are treated as the same tag. Deduplicates after rename.",
|
|
InputSchema: objSchema(
|
|
prop{"from", "string", "Comma-separated tag names to rename FROM"},
|
|
prop{"to", "string", "New tag name"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
fromStr, _ := args["from"].(string)
|
|
to, _ := args["to"].(string)
|
|
to = strings.ToLower(strings.TrimSpace(to))
|
|
|
|
from := []string{}
|
|
for _, f := range strings.Split(fromStr, ",") {
|
|
if f = strings.TrimSpace(f); f != "" {
|
|
from = append(from, strings.ToLower(f))
|
|
}
|
|
}
|
|
if to == "" || len(from) == 0 {
|
|
return textResult("from and to are required"), nil
|
|
}
|
|
|
|
tag, err := pool.Exec(ctx, `
|
|
UPDATE knowledge_entities ke
|
|
SET tags = sub.new_tags, updated_at = now()
|
|
FROM (
|
|
SELECT k.entity_id,
|
|
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
|
|
FROM unnest(k.tags) AS t) AS new_tags
|
|
FROM knowledge_entities k
|
|
WHERE k.deleted_at IS NULL
|
|
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
|
|
) AS sub
|
|
WHERE ke.entity_id = sub.entity_id`, from, to)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
|
}
|
|
return textResult(fmt.Sprintf("Tag %s → %s: %d entries updated.", strings.Join(from, ", "), to, tag.RowsAffected())), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "get_knowledge_revisions", Description: "Version history for a knowledge entry. Returns title, content, editor, tags, and timestamps for each revision.",
|
|
InputSchema: objSchema(
|
|
prop{"knowledge_slug", "string", "Knowledge entity slug (e.g. document:nomos/something)"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
slug, _ := args["knowledge_slug"].(string)
|
|
return queryRows(ctx, pool, `
|
|
SELECT kr.id, kr.title, kr.content, COALESCE(kr.edited_by, '') AS edited_by,
|
|
COALESCE(kr.tags::text, '{}') AS tags,
|
|
kr.version_at::text, kr.revised_at::text
|
|
FROM knowledge_revisions kr
|
|
JOIN entities e ON e.id = kr.entity_id
|
|
WHERE e.slug = $1
|
|
ORDER BY kr.version_at DESC LIMIT 50`, slug), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "get_knowledge_duplicates", Description: "Near-duplicate knowledge entries detected via trigram similarity. Returns clusters of similar documents with similarity scores. Use before creating new knowledge to avoid pileup.",
|
|
InputSchema: objSchema(
|
|
prop{"threshold", "number", "Similarity threshold 0-1 (default 0.6, lower = more matches)"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
threshold := getFloat(args, "threshold", 0.6)
|
|
return queryRows(ctx, pool, `
|
|
SELECT a.slug AS doc_a, b.slug AS doc_b, similarity(ka.title, kb.title) AS sim
|
|
FROM knowledge_entities ka
|
|
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
|
|
JOIN entities a ON a.id = ka.entity_id
|
|
JOIN entities b ON b.id = kb.entity_id
|
|
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
|
|
AND similarity(ka.title, kb.title) > $1
|
|
ORDER BY sim DESC LIMIT 100`, threshold), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "get_knowledge_orphans", Description: "Knowledge entries with no entity links (unlinked), no tags (untagged), or stale (not updated in N days). Helps identify abandoned or disconnected knowledge to clean up.",
|
|
InputSchema: objSchema(
|
|
prop{"stale_days", "integer", "Days without update to consider stale (default 90)"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
staleDays := int(getFloat(args, "stale_days", 90))
|
|
return queryRows(ctx, pool, fmt.Sprintf(`
|
|
SELECT e.slug, ke.title, e.type AS kind, COALESCE(ke.edited_by, '') AS edited_by,
|
|
ke.updated_at::text,
|
|
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
|
|
NOT EXISTS (
|
|
SELECT 1 FROM relationships r
|
|
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
|
|
AND r.type IN ('documents', 'about')
|
|
) AS unlinked,
|
|
(ke.updated_at < now() - interval '%d days') AS stale
|
|
FROM knowledge_entities ke
|
|
JOIN entities e ON e.id = ke.entity_id
|
|
WHERE ke.deleted_at IS NULL
|
|
ORDER BY ke.updated_at ASC`, staleDays)), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "list_knowledge_tags", Description: "All tags used across the knowledge base with usage counts. Returns normalized tag, count, and any casing variants (e.g. 'oom' and 'OOM' surface as variants so you can spot drift).",
|
|
InputSchema: objSchema(),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return queryRows(ctx, pool, `
|
|
SELECT lower(tag) AS tag, count(*) AS uses,
|
|
array_agg(DISTINCT tag ORDER BY tag) AS variants
|
|
FROM knowledge_entities ke, unnest(ke.tags) AS tag
|
|
WHERE ke.deleted_at IS NULL
|
|
GROUP BY lower(tag) ORDER BY uses DESC, lower(tag)`), nil
|
|
}},
|
|
{tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
|
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
pubkey, _ := args["caller_pubkey"].(string)
|
|
// Match entities where age_pubkey attribute contains the caller's key.
|
|
query := `
|
|
SELECT e.slug, e.type, e.name,
|
|
e.attributes->>'age_pubkey' AS age_pubkey
|
|
FROM entities e
|
|
WHERE e.attributes->>'age_pubkey' IS NOT NULL`
|
|
var dbArgs []any
|
|
if pubkey != "" {
|
|
query += ` AND e.attributes->>'age_pubkey' = $1`
|
|
dbArgs = append(dbArgs, pubkey)
|
|
}
|
|
query += ` ORDER BY e.slug LIMIT 100`
|
|
return queryRows(ctx, pool, query, dbArgs...), nil
|
|
}},
|
|
}
|
|
}
|