diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2dc51ac..4812611 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -91,14 +91,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) limit := int(getFloat(args, "limit", 50)) - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at FROM entities e WHERE ($1::text IS NULL OR e.type = $1) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') ORDER BY e.slug LIMIT $4`, - nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil + nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil }) register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", @@ -154,7 +154,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) q := nStr(args["query"]) - return queryRows(ctx, pool, ` + 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), @@ -165,7 +165,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { JOIN entities e ON e.id = ke.entity_id WHERE ke.search @@ plainto_tsquery('english', $1) ORDER BY rank DESC - LIMIT 20`, q), nil + LIMIT 20`, q), "knowledge_results"), nil }) register(&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.", @@ -173,7 +173,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_slug"].(string) - return queryRows(ctx, pool, ` + 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 @@ -193,7 +193,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { 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), nil + ORDER BY 1`, slug), "knowledge_results"), nil }) register(&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.", @@ -289,7 +289,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) hours := int(getFloat(args, "hours", 24)) - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT time_bucket('1 hour', ts) AS bucket, entity_id::text, metric, ROUND(avg(value)::numeric, 2) AS avg, @@ -298,7 +298,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { FROM metric_samples WHERE ts > now() - make_interval(hours => $1) GROUP BY bucket, entity_id, metric - ORDER BY bucket DESC LIMIT 100`, hours), nil + ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil }) // ─── Phase 4: new tools ────────────────────────────────────────── @@ -649,14 +649,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) limit := int(getFloat(args, "limit", 50)) - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, entity_id::text, left(input_summary, 200) AS input_summary, left(output_summary, 200) AS output_summary, duration_ms, token_count, success, correlation_id FROM agent_activity WHERE agent_id = $1 - ORDER BY ts DESC LIMIT $2`, agentID, limit), nil + ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil }) // ─── Phase 5: operational MCP tools ────────────────────────────── @@ -664,14 +664,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state", InputSchema: objSchema(), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, e.attributes->>'lan_ip' AS lan_ip, st.health, st.last_check_at FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.type = 'lxc' - ORDER BY (e.attributes->>'pve_id')::int`), nil + ORDER BY (e.attributes->>'pve_id')::int`), "lxc_list"), nil }) register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", @@ -811,7 +811,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { return textResult("error: hostname required"), nil } slug := "ws:" + hostname - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.name, e.state, COALESCE(st.health, 'unknown') AS health, COALESCE(st.last_check_at::text, '') AS last_check, @@ -821,7 +821,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.slug = $1 - ORDER BY e.slug`, slug), nil + ORDER BY e.slug`, slug), "entity_card"), nil }) register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", @@ -832,7 +832,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { if slug == "" { return textResult("error: service_slug required"), nil } - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.name, e.state, COALESCE(st.health, 'unknown') AS health, COALESCE(st.last_check_at::text, '') AS last_check, @@ -840,7 +840,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { COALESCE(e.attributes::text, '{}') AS attrs FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id - WHERE e.slug = $1`, slug), nil + WHERE e.slug = $1`, slug), "entity_card"), nil }) register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", @@ -878,7 +878,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { args := argsMap(req) slug, _ := args["entity_slug"].(string) limit := int(getFloat(args, "limit", 20)) - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, al.action, al.method, al.path, al.detail::text AS details @@ -886,13 +886,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { JOIN entities e ON e.id = al.entity_id WHERE e.slug = $1 ORDER BY al.ts DESC - LIMIT $2`, slug, limit), nil + LIMIT $2`, slug, limit), "change_log"), nil }) register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", InputSchema: objSchema(), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return queryRows(ctx, pool, ` + return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.state, COALESCE(st.health, 'unknown') AS health, COALESCE(st.last_check_at::text, '') AS last_check @@ -902,7 +902,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { OR st.health IS NOT NULL ORDER BY st.health, e.slug LIMIT 200 - `), nil + `), "fleet_snapshot"), nil }) register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", @@ -1128,6 +1128,26 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m return textResult(string(data)) } +func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult { + if len(result.Content) == 0 { + return result + } + tc, ok := result.Content[0].(*mcp.TextContent) + if !ok || tc.Text == "" { + return result + } + var items []map[string]any + if err := json.Unmarshal([]byte(tc.Text), &items); err != nil { + return result + } + wrapper := map[string]any{ + "__renderer": rendererID, + "data": items, + } + data, _ := json.MarshalIndent(wrapper, "", " ") + return textResult(string(data)) +} + // ─── SSH helpers ───────────────────────────────────────────────────────── var ( diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 268df41..fb08ee8 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -1,11 +1,79 @@ package mcp import ( + "encoding/json" + "strings" "testing" "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" ) +func TestAnnotateJSONResult(t *testing.T) { + // valid JSON array → wrapped with __renderer + data + result := textResult(`[{"slug": "host:hubris", "type": "host"}]`) + annotated := annotateJSONResult(result, "entity_card") + + if len(annotated.Content) != 1 { + t.Fatalf("expected 1 content item, got %d", len(annotated.Content)) + } + tc, ok := annotated.Content[0].(*mcp.TextContent) + if !ok { + t.Fatal("content is not TextContent") + } + + var wrapper map[string]interface{} + if err := json.Unmarshal([]byte(tc.Text), &wrapper); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + if wrapper["__renderer"] != "entity_card" { + t.Errorf("__renderer = %q, want entity_card", wrapper["__renderer"]) + } + data, ok := wrapper["data"].([]interface{}) + if !ok || len(data) != 1 { + t.Fatal("data is not the original array") + } +} + +func TestAnnotateJSONResultNoop(t *testing.T) { + // empty content → no-op + result := &mcp.CallToolResult{Content: []mcp.Content{}} + annotated := annotateJSONResult(result, "entity_card") + if len(annotated.Content) != 0 { + t.Fatal("empty content should be unchanged") + } + + // non-JSON text → no-op (not wrapped) + result = textResult("just plain text") + annotated = annotateJSONResult(result, "entity_card") + tc, _ := annotated.Content[0].(*mcp.TextContent) + if strings.Contains(tc.Text, "__renderer") { + t.Fatal("non-JSON content should not be annotated") + } + + // textResult with empty string → no-op + result = textResult("") + annotated = annotateJSONResult(result, "entity_card") + tc, _ = annotated.Content[0].(*mcp.TextContent) + if tc.Text != "" { + t.Fatal("empty text content should be unchanged") + } +} + +func TestAnnotateJSONResultPreservesMultipleRows(t *testing.T) { + result := textResult(`[{"slug": "a"}, {"slug": "b"}, {"slug": "c"}]`) + annotated := annotateJSONResult(result, "lxc_list") + + tc, _ := annotated.Content[0].(*mcp.TextContent) + var wrapper map[string]interface{} + json.Unmarshal([]byte(tc.Text), &wrapper) + + data := wrapper["data"].([]interface{}) + if len(data) != 3 { + t.Fatalf("expected 3 rows in data, got %d", len(data)) + } +} + // TestNewServerRegistersTools verifies every tool registers with a valid // input schema. The MCP SDK panics at AddTool if a tool omits its object // input schema, so merely constructing the server exercises that contract — diff --git a/plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md b/plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md new file mode 100644 index 0000000..728ddc2 --- /dev/null +++ b/plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md @@ -0,0 +1,66 @@ +# 2026-07-13 — MCP tool apps: custom in-chat renderers + +**Status:** Done — implemented 2026-07-13. + +## What was built + +12 of 33 MCP tools now render as rich inline cards in the chat instead of raw +JSON inside a collapsed component. The remaining 21 tools stay collapsed. + +### Architecture + +- **Server** (`internal/mcp/server.go`): `annotateJSONResult()` function wraps + `queryRows` output with `{"__renderer": "...", "data": [...]}` for 12 tools. +- **Registry** (`web/src/lib/tool-renderers.ts`): match/dispatch system that + maps tool names + `__renderer` hints to Svelte components. +- **Renderer components** (`web/src/lib/renderers/`): 9 purpose-built cards, + each handling loading/spinner, error, and success states with proper ARIA + labels. +- **Chat dispatch** (`web/src/pages/Chat.svelte`): matched tools render inline + before the markdown text, with a 5-card limit to prevent chat spam. Overflow + goes to the collapsed `ToolCallGroup` alongside unmatched tools. +- **ToolCallGroup** (`web/src/lib/components/ToolCallGroup.svelte`): accepts + `unmatched` prop, shows "N tools · M cards shown" when some render inline, + hides entirely when all matched. + +### Renderers + +| Component | Tools matched | Visual | +|-----------|--------------|--------| +| `EntityCard` | `get_entity`, `whoami`, `explain` | Slug, type badge, health dot, key attrs | +| `HealthSummary` | `get_health_summary` | Stacked health bar (healthy/degraded/down) | +| `LXCList` | `list_lxcs` | Compact table: name, ID, IP, health | +| `EntityTable` | `list_entities` | Auto-column table from query results | +| `KnowledgeResults` | `search_knowledge`, `get_entity_knowledge` | Title, snippet, source, slug | +| `BlastRadius` | `get_blast_radius` | Entities grouped by hop distance | +| `ChangeLog` | `get_change_history`, `get_agent_activity` | Timeline with status dots | +| `FleetSnapshot` | `get_state_snapshot` | Health + type counts in a grid | +| `MetricChart` | `query_metrics` | Bucketed time/avg/min/max table | + +### Tests + +`internal/mcp/server_test.go`: 3 new tests for `annotateJSONResult` — wraps +valid JSON arrays, no-op on empty/non-JSON/empty-text content, preserves +multi-row arrays. + +### Files changed + +**New (18):** +- `web/src/lib/tool-renderers.ts` +- `web/src/lib/renderers/index.ts` +- `web/src/lib/renderers/EntityCard.svelte` + `entity-card.ts` +- `web/src/lib/renderers/HealthSummary.svelte` + `health-summary.ts` +- `web/src/lib/renderers/LXCList.svelte` + `lxc-list.ts` +- `web/src/lib/renderers/EntityTable.svelte` + `entity-table.ts` +- `web/src/lib/renderers/KnowledgeResults.svelte` + `knowledge-results.ts` +- `web/src/lib/renderers/BlastRadius.svelte` + `blast-radius.ts` +- `web/src/lib/renderers/ChangeLog.svelte` + `change-log.ts` +- `web/src/lib/renderers/FleetSnapshot.svelte` + `fleet-snapshot.ts` +- `web/src/lib/renderers/MetricChart.svelte` + `metric-chart.ts` + +**Modified (5):** +- `internal/mcp/server.go` — `annotateJSONResult()` + 12 tool annotations +- `internal/mcp/server_test.go` — 3 tests for `annotateJSONResult` +- `web/src/lib/components/ToolCallGroup.svelte` — `unmatched`/`bodyTools` +- `web/src/pages/Chat.svelte` — inline dispatch + 5-card limit +- `web/src/main.ts` — deferred renderer import diff --git a/plans/index.md b/plans/index.md index 8cd4579..413c4ed 100644 --- a/plans/index.md +++ b/plans/index.md @@ -14,7 +14,6 @@ went sideways, open an investigation. | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | -| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | ## Done @@ -45,6 +44,7 @@ See [`done/`](done/) for executed plans: | 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) | | 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) | | 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) | +| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) | ## Conventions diff --git a/web/src/lib/components/ToolCallGroup.svelte b/web/src/lib/components/ToolCallGroup.svelte index c4eea6f..eed32bf 100644 --- a/web/src/lib/components/ToolCallGroup.svelte +++ b/web/src/lib/components/ToolCallGroup.svelte @@ -7,11 +7,14 @@ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle' - let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props() + let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props() let open = $state(false) let wasActive = $state(active) + const bodyTools = $derived(unmatched ?? tools) + const inlineCount = $derived(tools.length - bodyTools.length) + $effect(() => { if (active && !wasActive) { open = true @@ -22,18 +25,18 @@ wasActive = active }) - const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length) - const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error)) - const names = $derived(tools.map((t) => t.name).join(', ')) + const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length) + const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error)) + const names = $derived(bodyTools.map((t) => t.name).join(', ')) const runningTool = $derived( - active ? tools.find((t) => t.type === 'tool_use') : undefined + active ? bodyTools.find((t) => t.type === 'tool_use') : undefined ) const ariaLabel = $derived( - doneCount === tools.length - ? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed` - : `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done` + doneCount === bodyTools.length + ? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed` + : `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done` ) function toolSummary(args: unknown): string { @@ -45,19 +48,19 @@ } -{#if tools.length} +{#if bodyTools.length} - {#if active && doneCount < tools.length} - + {#if active && doneCount < bodyTools.length} + -
- {#each tools as tool (tool.id)} +
+ {#each bodyTools as tool (tool.id)}
{#if tool.type === 'tool_result' && tool.error} - +