diff --git a/changelog.d/added-10621.md b/changelog.d/added-10621.md new file mode 100644 index 0000000000..efae0b436d --- /dev/null +++ b/changelog.d/added-10621.md @@ -0,0 +1 @@ +- knowledge: owner-switched, read-only public MCP endpoint for external agents (#10621) diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index 1b6353435a..aaaf10e747 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -169,8 +169,8 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `PUT` | `/api/config/governor/watchdog` | Owner only | Governor Watchdog | `pkg/dashboard/api.go:201` | | `GET` | `/api/config/escalation` | Owner only | Escalation Config Get | `pkg/dashboard/api.go:204` | | `PUT` | `/api/config/escalation` | Owner only | Escalation Config Set | `pkg/dashboard/api.go:205` | -| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:434` | -| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:435` | +| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:437` | +| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:438` | | `GET` | `/api/config/review` | Dashboard auth/session | Review Config Get | `pkg/dashboard/api.go:208` | | `PUT` | `/api/config/review` | Owner only | Review Config Set | `pkg/dashboard/api.go:211` | | `PUT` | `/api/config/governor/features` | Owner only | Governor Features | `pkg/dashboard/api.go:217` | @@ -214,7 +214,7 @@ Auth levels are derived from dashboard middleware (`isPublicPath`, dashboard tok | `GET` | `/api/model-advisor` | Dashboard auth/session | Model Advisor | `pkg/dashboard/api.go:153` | | `GET` | `/api/governor/pr-models` | Dashboard auth/session | Agent-authored PR distribution by normalized attribution model/backend for `window=7d`, `30d`, or `all`, including per-model rework stats and the top 10 most-reworked PRs | `pkg/dashboard/api.go:154` | | `GET` | `/api/reviewer/accuracy` | Dashboard auth/session | Reviewer verdict calibration for the configured window: false-approve/false-block rates and confidence buckets per perspective and reviewer model | `pkg/dashboard/api.go:210` | -| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:431` | +| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:434` | | `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:283` | | `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:284` | | `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:285` | @@ -284,42 +284,43 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` |---|---|---|---|---| | `GET` | `/api/knowledge` | Dashboard auth/session | Knowledge List | `pkg/dashboard/api.go:319` | | `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:320` | +| `POST` | `/mcp/knowledge` | Public (read-only) when `HIVE_PUBLIC_KNOWLEDGE` is on; 404 otherwise | Public Knowledge MCP endpoint — `initialize`, `tools/list`, `tools/call` for `knowledge_search`/`knowledge_get`/`knowledge_export` ([docs](public-knowledge-mcp.md)) | `pkg/dashboard/api.go:324` | | `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:321` | -| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:322` | -| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:323` | -| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:324` | -| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:325` | -| `POST` | `/api/knowledge/create` | Dashboard auth/session | Knowledge Create | `pkg/dashboard/api.go:326` | -| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:327` | -| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:333` | -| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:334` | -| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:335` | -| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:336` | -| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:337` | -| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:338` | -| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:339` | -| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:340` | -| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:341` | -| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:342` | -| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:343` | -| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:344` | -| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:345` | -| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:346` | -| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:347` | -| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:348` | -| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:349` | -| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:350` | -| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:351` | -| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:352` | -| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:353` | -| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:354` | -| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:355` | -| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:356` | -| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:357` | -| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:358` | -| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:359` | -| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:331` | -| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:332` | +| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:325` | +| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:326` | +| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:327` | +| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:328` | +| `POST` | `/api/knowledge/create` | Dashboard auth/session | Knowledge Create | `pkg/dashboard/api.go:329` | +| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:330` | +| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:336` | +| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:337` | +| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:338` | +| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:339` | +| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:340` | +| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:341` | +| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:342` | +| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:343` | +| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:344` | +| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:345` | +| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:346` | +| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:347` | +| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:348` | +| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:349` | +| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:350` | +| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:351` | +| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:352` | +| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:353` | +| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:354` | +| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:355` | +| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:356` | +| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:357` | +| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:358` | +| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:359` | +| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:360` | +| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:361` | +| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:362` | +| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:334` | +| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:335` | ## Contribute @@ -427,63 +428,63 @@ always resolved server-side from the validated token. | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status | `pkg/dashboard/api.go:393` | -| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:394` | -| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:395` | -| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:396` | -| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:397` | -| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:398` | -| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:399` | -| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:400` | -| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:401` | -| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:402` | -| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:403` | -| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:404` | -| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:405` | -| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals | `pkg/dashboard/api.go:406` | -| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos | `pkg/dashboard/api.go:407` | -| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:408` | -| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:409` | -| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:410` | -| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:411` | -| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:412` | -| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:413` | +| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status | `pkg/dashboard/api.go:396` | +| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:397` | +| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:398` | +| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:399` | +| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:400` | +| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:401` | +| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:402` | +| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:403` | +| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:404` | +| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:405` | +| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:406` | +| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:407` | +| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:408` | +| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals | `pkg/dashboard/api.go:409` | +| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos | `pkg/dashboard/api.go:410` | +| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:411` | +| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:412` | +| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:413` | +| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:414` | +| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:415` | +| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:416` | ## Inception | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:364` | -| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:365` | -| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:366` | -| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:367` | -| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:368` | -| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:369` | -| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:370` | -| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:371` | -| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:372` | -| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:373` | -| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:374` | -| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:375` | -| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:376` | -| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:377` | +| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:367` | +| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:368` | +| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:369` | +| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:370` | +| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:371` | +| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:372` | +| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:373` | +| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:374` | +| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:375` | +| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:376` | +| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:377` | +| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:378` | +| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:379` | +| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:380` | ## Beads | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:415` | -| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:416` | -| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:417` | -| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:418` | -| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:419` | +| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:418` | +| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:419` | +| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:420` | +| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:421` | +| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:422` | ## Dashboard miscellaneous | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/audit` | Read-write role | Audit Log — `{"entries": [...]}` envelope, newest first, capped at 200; response shape and the serve-time `user_name` field in [audit-log.md](audit-log.md#get-apiaudit) | `pkg/dashboard/api.go:51` | -| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) — total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe → Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:426` | +| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) — total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe → Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:429` | | `POST` | `/api/presence` | Dashboard auth/session | Presence | `pkg/dashboard/api.go:54` | | `GET` | `/api/prompt-history` | Dashboard auth/session | Prompt History | `pkg/dashboard/api.go:55` | | `POST` | `/api/self-upgrade` | Owner only | Self Upgrade | `pkg/dashboard/api.go:56` | @@ -497,11 +498,11 @@ always resolved server-side from the validated token. | `GET` | `/api/convergence/soak` | Owner only | Convergence Soak Status | `pkg/dashboard/api.go:231` | -| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:382` | -| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:385` | -| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:386` | -| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:388` | -| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:389` | +| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:385` | +| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:388` | +| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:389` | +| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:391` | +| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:392` | | `POST` | `/api/linear/agent/install` | Owner only | Linear Agent Install (OAuth start) | `pkg/dashboard/api_linear_agent.go:215` | | `GET` | `/api/linear/agent/status` | Owner only | Linear Agent Status | `pkg/dashboard/api_linear_agent.go:216` | | `POST` | `/api/linear/agent/disconnect` | Owner only | Linear Agent Disconnect | `pkg/dashboard/api_linear_agent.go:217` | @@ -516,10 +517,10 @@ always resolved server-side from the validated token. | `GET` | `/api/summaries` | Dashboard auth/session | Summaries | `pkg/dashboard/api.go:168` | | `POST` | `/api/prs/{owner}/{repo}/{number}/queue-automerge` | Merger/owner role | Queue PRAuto Merge | `pkg/dashboard/api.go:169` | | `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:317` | -| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:361` | -| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:362` | -| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:391` | -| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:421` | +| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:364` | +| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:365` | +| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:394` | +| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:424` | | `GET` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:236` | | `POST` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:237` | | `GET` | `/api/docs` | Dashboard auth/session | APIDocs | `pkg/dashboard/api_contribute.go:238` | diff --git a/src/docs/documentation-map.md b/src/docs/documentation-map.md index c76997cd99..e6043dc149 100644 --- a/src/docs/documentation-map.md +++ b/src/docs/documentation-map.md @@ -118,6 +118,7 @@ Documentation for the current Hive line (branch `v5`; the code and docs live und - [Branding a hive](branding.md) — persistent per-deployment name, mark, and colours via `/branding/branding.json` and `custom.css` (`HIVE_BRANDING_JSON`/`HIVE_BRANDING_CSS`); distinct from the per-request `?style=` stylesheets above. - [Portable AgentDefinition format](../AGENT-DEFINITION.md) — standalone YAML schema for importing/exporting agent definitions. - [Knowledge curator](knowledge-curator.md) — automatic fact extraction and promotion knobs, plus the other `knowledge:` sub-sections: `git_sources` (indexing a remote repo, layer semantics, private-repo auth (unsupported), diagnosing a failed source), `vaults` (local Obsidian vaults, git-sync), `documents` (PDF/URL import), and `bead_synthesizer` (on-by-default bead→wiki synthesis and retention policy, and how to turn it off). See also [knowledge confidence scoring](knowledge-confidence.md) for dashboard confidence badges and score explanations. +- [Public knowledge MCP endpoint](public-knowledge-mcp.md) — the owner-switched, read-only `POST /mcp/knowledge` surface that lets any external agent (Goose, Claude, Copilot) read a hive's operational knowledge without a session; what is and is not exposed, and client configuration. - [Skill registry](skills.md) — the `/data/skills/` file format and front-matter fields. **Delivered to agents today**: skills are opt-in per agent via `skills: [name, ...]`, resolved registry-first with an `AGENTS.md` repo-local fallback, and the rendered block is prepended to the kick's `${KNOWLEDGE}`. Both sources reload on every kick. An 8 KiB whole-skill cap applies; a skill that would exceed it is dropped whole, never truncated. - [AGENTS.md repo instructions](agents-md.md) — the per-repo `AGENTS.md` file format Hive's parser (`pkg/agentsmd`) understands, including front-matter `skills:` and inline `## Skill:` sections. **Wired into kicks, but needs a checkout**: Hive agents keep no clones, so set `project.checkouts_dir` to a directory holding one checkout per repo. Without it there is no root to read and injection stays a no-op, which is the default. - [Agent peer-awareness logging (pluk)](agent-logging.md) — pluk log format, `hive-panes`, availability, and retention. diff --git a/src/docs/env-vars.md b/src/docs/env-vars.md index 76f816f3d2..640068edfb 100644 --- a/src/docs/env-vars.md +++ b/src/docs/env-vars.md @@ -48,6 +48,8 @@ This reference is compiled by hand from the Go source under `src/`, the deployme | `HIVE_METRICS_ENABLED` | No | disabled | Registers Prometheus `/metrics` when set to `1`, `true`, `yes`, or `on`. Requires `HIVE_METRICS_TOKEN` — enabled-but-tokenless returns 403 ([#3804](https://github.com/hivecommons/hive/pull/3804)). | | `HIVE_METRICS_TOKEN` | Yes when metrics enabled | none | Bearer token read by `pkg/dashboard/metrics_prometheus.go` for `/metrics` (`Authorization: Bearer ...`; Prometheus `bearer_token`). `/metrics` bypasses dashboard session auth, so this token is its only guard; enabled-but-tokenless fails closed and the cost/agent series are never served without it. | | `HIVE_METRICS_FILE` | No | `/var/run/hive-metrics/contribute.json` | Contributor metrics JSON file override. | +| `HIVE_PUBLIC_KNOWLEDGE` | No | disabled | Owner switch for the anonymous, **read-only** MCP knowledge endpoint `POST /mcp/knowledge` (`1`, `true`, `yes`, or `on` — same spelling as `HIVE_METRICS_ENABLED`). Off → 404 even for authenticated callers. Read on each request so it can be closed without a pod roll. Only operational fact types (pattern, gotcha, regression, test_scaffold, integration, coverage_rule, general) are served; ideation/governance types, sources, and usage data never leave the hive. See [public-knowledge-mcp.md](public-knowledge-mcp.md) ([#10615](https://github.com/hivecommons/hive/issues/10615)). | +| `HIVE_PUBLIC_KNOWLEDGE_TAGS` | No | none (all public-type facts) | Comma-separated tag allow-list that further narrows what `/mcp/knowledge` serves to facts carrying at least one listed tag (case-insensitive). | | `HIVE_COPILOT_INTEGRATION_ID` | No | compiled Copilot integration id | Overrides the integration id used by Copilot model discovery. | | `HIVE_CONTRIBUTORS_DIR` | No | hub default | Contributor registry directory override. | | `HIVE_CONTRIBUTE_SKIP_LABELS` | No | `blocked,tracking,epic,discussion,question,needs-decision,needs-triage` | Comma-separated, case-insensitive label patterns for issues that are not contributor work and must never be offered by the relay. Patterns use `path.Match`-style `*` globs; `blocked` is always unioned into the effective set even if omitted. Same setting as `hub.contribute_skip_labels`. | diff --git a/src/docs/public-knowledge-mcp.md b/src/docs/public-knowledge-mcp.md new file mode 100644 index 0000000000..3e1ecf89d9 --- /dev/null +++ b/src/docs/public-knowledge-mcp.md @@ -0,0 +1,112 @@ +# Public knowledge MCP endpoint + +A hive accumulates operational knowledge — gotchas, patterns, regressions, +integration notes — as its agents work. Normally that knowledge is reachable +only with a hub session, the dashboard token, or a contributor registration +token (`/api/knowledge/*`, `hivectl knowledge export`). + +The **public knowledge** switch lets a hive owner open a second, strictly +**read-only** surface so *any* agent — Goose, Claude Desktop/Code, Copilot +CLI, a shell script — can consult the hive without credentials. The +motivating case is a distro's troubleshooting assistant reasoning from the +project's own accumulated fixes instead of only what its model was trained on +([#10615](https://github.com/hivecommons/hive/issues/10615)). + +## Owner switch + +| Variable | Effect | +|---|---| +| `HIVE_PUBLIC_KNOWLEDGE=1` (`true`/`yes`/`on`) | Enables `POST /mcp/knowledge`. Unset or anything else → the path returns **404**, even to the owner. | +| `HIVE_PUBLIC_KNOWLEDGE_TAGS=linux,troubleshooting` | Optional. Only facts carrying at least one listed tag are served. | + +Both are read on every request, so the surface can be closed instantly +without restarting the spoke. + +## What is exposed + +Only operational fact types leave the hive: + +`pattern`, `gotcha`, `regression`, `test_scaffold`, `integration`, +`coverage_rule`, `general` + +Ideation and governance types (`idea`, `vision`, `constitution`, +`requirement`, `constraint`, `stakeholder`, `decision`) are never served, +and a request for one by slug answers "not found" rather than "forbidden". + +Each public fact carries `slug`, `title`, `type`, `body`, `tags`, `related` +and (only when scored) `confidence`. Sources (PR, comment, author), usage +counters, confidence reasoning, and lifecycle phase are stripped. + +## What is *not* possible + +There is no write method on this surface. The endpoint serves the MCP +handshake (`initialize`, `ping`, `notifications/*`), `tools/list`, and +`tools/call` for three tools. Nothing in the handler reaches +`CreateFact`, `UpdateFact`, `DeleteFact`, import, promotion, or vault +mutation. Anonymous callers cannot alter a knowledge base, and every tool is +annotated `readOnlyHint: true` so MCP clients run them without confirmation. + +Other hard limits: POST only (no SSE stream), 64 KiB request body, one +JSON-RPC request per POST (batch arrays are answered with `-32600`; the +endpoint speaks MCP `2025-06-18`, which dropped batching), and +`knowledge_search` returns at most 50 results per call. + +A fact's `related` list is filtered to public slugs, so a public fact cannot +disclose even the name of a private one. `knowledge_get` and +`knowledge_export` return complete bodies; `knowledge_search` results do too, +re-read per hit. + +## Tools + +| Tool | Arguments | Returns | +|---|---|---| +| `knowledge_search` | `query` (required), `type`, `limit` (default 10, max 50) | JSON `{query, count, results[]}` | +| `knowledge_get` | `slug` (required) | One public fact as JSON, or an `isError` "fact not found" | +| `knowledge_export` | none | The whole public base as one Markdown document, grouped by type; `_meta.etag` and `_meta.facts` | + +## Client configuration + +The endpoint speaks MCP Streamable HTTP with plain JSON responses. + +**Goose** (`~/.config/goose/config.yaml`): + +```yaml +extensions: + bluefin-hive: + type: streamable_http + uri: https://hive.example.org/mcp/knowledge + enabled: true +``` + +**Claude Code**: + +```sh +claude mcp add --transport http bluefin-hive https://hive.example.org/mcp/knowledge +``` + +**Copilot CLI** (`~/.copilot/mcp-config.json`): + +```json +{ "mcpServers": { "bluefin-hive": { "type": "http", "url": "https://hive.example.org/mcp/knowledge" } } } +``` + +**curl**: + +```sh +curl -s https://hive.example.org/mcp/knowledge \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_search","arguments":{"query":"bluetooth"}}}' +``` + +## Hub-fronted spokes + +On hosted hives the hub's nginx `auth_request` gate waves `/mcp/knowledge` +through (same mechanism as `/api/knowledge/export`, #8294) so an agent with +no browser session is not bounced to the login page. This opens nothing by +itself: the spoke re-checks `HIVE_PUBLIC_KNOWLEDGE` and 404s when it is off. + +## Related + +- [Environment variable reference](env-vars.md) +- [Knowledge curator](knowledge-curator.md) — how facts are produced and promoted +- [Knowledge system design](design/knowledge-system.md) diff --git a/src/pkg/dashboard/api.go b/src/pkg/dashboard/api.go index ff4f06f22d..8779ce3be5 100644 --- a/src/pkg/dashboard/api.go +++ b/src/pkg/dashboard/api.go @@ -319,6 +319,9 @@ func (s *Server) RegisterAPI(deps *Dependencies) { s.mux.HandleFunc("GET /api/knowledge", s.handleKnowledgeList) s.mux.HandleFunc("GET /api/knowledge/export", s.handleKnowledgeExport) s.mux.HandleFunc("GET /api/knowledge/search", s.handleKnowledgeSearch) + // Anonymous, owner-switched, read-only MCP surface (#10615). POST only — + // the mux answers other verbs with 405, and there is no SSE stream. + s.mux.HandleFunc("POST /mcp/knowledge", s.handlePublicKnowledgeMCP) // path == publicKnowledgeMCPPath s.mux.HandleFunc("GET /api/knowledge/health", s.handleKnowledgeHealth) s.mux.HandleFunc("GET /api/knowledge/stats", s.handleKnowledgeStats) s.mux.HandleFunc("GET /api/knowledge/graph", s.handleKnowledgeGraph) diff --git a/src/pkg/dashboard/mcp_knowledge.go b/src/pkg/dashboard/mcp_knowledge.go new file mode 100644 index 0000000000..e64801e98f --- /dev/null +++ b/src/pkg/dashboard/mcp_knowledge.go @@ -0,0 +1,571 @@ +package dashboard + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + + "github.com/hivecommons/hive/pkg/knowledge" +) + +// Public knowledge MCP endpoint (#10615). +// +// A hive owner can flip HIVE_PUBLIC_KNOWLEDGE on to let ANY external agent +// (Goose, Claude, Copilot, a curl loop) read this hive's operational knowledge +// over a Model Context Protocol endpoint with no hub session, dashboard token, +// or contributor registration. The surface is strictly read-only: the only +// JSON-RPC methods served are the MCP handshake plus tools/list and +// tools/call, and the only tools are search/get/export. There is no code path +// from this handler to CreateFact/UpdateFact/DeleteFact or any vault mutation, +// so an anonymous caller can never overwrite a knowledge base. +// +// Default is OFF and the endpoint 404s. isPublicPath and the hub's +// publicExactPaths admit /mcp/knowledge past authentication only so this +// handler can run its own fail-closed check; they do not weaken anything else. + +const ( + // publicKnowledgeMCPPath is the spoke endpoint external agents connect to. + publicKnowledgeMCPPath = "/mcp/knowledge" + + // publicKnowledgeEnabledEnv is the owner switch. Same spelling as + // HIVE_METRICS_ENABLED: 1|true|yes|on. Read on each request so an owner + // can close the surface without a pod roll. + publicKnowledgeEnabledEnv = "HIVE_PUBLIC_KNOWLEDGE" + + // publicKnowledgeTagsEnv optionally narrows the public projection to facts + // carrying at least one of the listed (comma-separated) tags. Empty means + // every fact of a public type is exposed. + publicKnowledgeTagsEnv = "HIVE_PUBLIC_KNOWLEDGE_TAGS" + + // mcpProtocolVersion is the MCP spec revision this endpoint speaks. The + // 2025-06-18 revision dropped JSON-RPC batching, which this endpoint does + // not implement (a batch array is answered with -32600). + mcpProtocolVersion = "2025-06-18" + + // publicKnowledgeDefaultLimit / MaxLimit bound knowledge_search results so + // an anonymous caller cannot pull the whole base one query at a time + // faster than knowledge_export already allows. + publicKnowledgeDefaultLimit = 10 + publicKnowledgeMaxLimit = 50 + + // publicKnowledgeOverfetch is how many ranked hits are pulled from the + // stores before the public filter runs. Stores rank across ALL types, so + // a hive heavy in private decision/idea facts could otherwise fill the + // window with facts the filter then drops and starve the result set. + publicKnowledgeOverfetch = publicKnowledgeMaxLimit * 10 + + // publicKnowledgeMaxBodyBytes caps a JSON-RPC request body. + publicKnowledgeMaxBodyBytes = 64 << 10 + + // JSON-RPC 2.0 error codes. + jsonRPCParseError = -32700 + jsonRPCInvalidRequest = -32600 + jsonRPCMethodNotFound = -32601 + jsonRPCInvalidParams = -32602 +) + +// publicKnowledgeTypes is the allow-list of fact types that leave the hive +// through the public endpoint. Operational knowledge only — ideation and +// governance types (idea, vision, constitution, requirement, constraint, +// stakeholder, decision) describe the project's internal direction and stay +// behind authentication. +var publicKnowledgeTypes = map[string]struct{}{ + "pattern": {}, + "gotcha": {}, + "regression": {}, + "test_scaffold": {}, + "integration": {}, + "coverage_rule": {}, + "general": {}, + "": {}, // untyped facts render under "general" +} + +// publicKnowledgeTypeOrder fixes section order in knowledge_export output. +var publicKnowledgeTypeOrder = []string{ + "pattern", "gotcha", "regression", "coverage_rule", "test_scaffold", "integration", "general", +} + +var publicKnowledgeTypeLabels = map[string]string{ + "pattern": "Patterns", + "gotcha": "Gotchas", + "regression": "Regressions", + "test_scaffold": "Test Scaffolds", + "integration": "Integration", + "coverage_rule": "Coverage Rules", + "general": "General", +} + +// publicKnowledgeEnabled reports whether the owner has opened the read-only +// knowledge endpoint. +func publicKnowledgeEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(publicKnowledgeEnabledEnv))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// publicKnowledgeTags returns the lower-cased tag allow-list, or nil for "all". +func publicKnowledgeTags() []string { + raw := strings.TrimSpace(os.Getenv(publicKnowledgeTagsEnv)) + if raw == "" { + return nil + } + var tags []string + for _, t := range strings.Split(raw, ",") { + if t = strings.ToLower(strings.TrimSpace(t)); t != "" { + tags = append(tags, t) + } + } + return tags +} + +// publicFact is the outward projection of a knowledge.Fact. Sources (PR, +// comment, author), usage counters, confidence reasoning, and lifecycle +// phase are deliberately absent. +type publicFact struct { + Slug string `json:"slug"` + Title string `json:"title"` + Type string `json:"type"` + Body string `json:"body"` + Confidence float64 `json:"confidence,omitempty"` + Tags []string `json:"tags,omitempty"` + Related []string `json:"related,omitempty"` +} + +// toPublicFact projects f. Related is filtered against publicSlugs so a public +// fact cannot disclose the (often descriptive) slug of a private one. +func toPublicFact(f knowledge.Fact, publicSlugs map[string]struct{}) publicFact { + t := string(f.Type) + if t == "" { + t = "general" + } + pf := publicFact{Slug: f.Slug, Title: f.Title, Type: t, Body: f.Body, Tags: f.Tags} + for _, r := range f.Related { + if _, ok := publicSlugs[r]; ok { + pf.Related = append(pf.Related, r) + } + } + if f.ConfidenceScored { + pf.Confidence = f.Confidence + } + return pf +} + +// publicListing returns every public fact (snippet bodies, as the stores list +// them) plus the set of their slugs. It is the authority for "is this slug +// public" used by every tool. +func (s *Server) publicListing(tags []string) ([]knowledge.Fact, map[string]struct{}) { + all := publicKnowledgeFilter(s.deps.Knowledge.SearchAllWithVaults(s.deps.Ctx, "", "", 0), tags) + slugs := make(map[string]struct{}, len(all)) + for _, f := range all { + slugs[f.Slug] = struct{}{} + } + return all, slugs +} + +// withFullBody re-reads f by slug so callers get the complete body rather +// than the ~200-rune snippet store listings carry. Falls back to f unchanged +// when the read fails or the stored page is no longer public. +func (s *Server) withFullBody(f knowledge.Fact) knowledge.Fact { + full, err := s.deps.Knowledge.ReadFact(s.deps.Ctx, f.Slug) + if err != nil || full == nil || full.Slug != f.Slug { + return f + } + if _, ok := publicKnowledgeTypes[string(full.Type)]; !ok { + return f + } + out := *full + // Preserve listing-derived fields the page read does not carry. + if len(out.Related) == 0 { + out.Related = f.Related + } + if len(out.Tags) == 0 { + out.Tags = f.Tags + } + return out +} + +// publicKnowledgeFilter keeps only facts of a public type that match the tag +// allow-list (if any). It is the single choke point every tool goes through. +func publicKnowledgeFilter(facts []knowledge.Fact, tags []string) []knowledge.Fact { + out := make([]knowledge.Fact, 0, len(facts)) + for _, f := range facts { + if _, ok := publicKnowledgeTypes[string(f.Type)]; !ok { + continue + } + if len(tags) > 0 && !factHasAnyTag(f, tags) { + continue + } + out = append(out, f) + } + return out +} + +func factHasAnyTag(f knowledge.Fact, tags []string) bool { + for _, have := range f.Tags { + have = strings.ToLower(have) + for _, want := range tags { + if have == want { + return true + } + } + } + return false +} + +// renderPublicKnowledgeMarkdown is the knowledge_export body. It mirrors the +// authenticated /api/knowledge/export layout but only over the public +// projection, and sorts deterministically so unchanged knowledge yields an +// identical document. +func renderPublicKnowledgeMarkdown(facts []knowledge.Fact) string { + grouped := make(map[string][]knowledge.Fact) + for _, f := range facts { + t := string(f.Type) + if t == "" { + t = "general" + } + grouped[t] = append(grouped[t], f) + } + + var sb strings.Builder + sb.WriteString("# Hive Public Knowledge\n\n") + sb.WriteString("This document is auto-generated from a hive knowledge base and served read-only.\n") + sb.WriteString("It refreshes as the hive learns — do not edit manually.\n\n") + + for _, t := range publicKnowledgeTypeOrder { + ff := grouped[t] + if len(ff) == 0 { + continue + } + sortFactsStable(ff) + label := publicKnowledgeTypeLabels[t] + if label == "" { + label = titleCaseWords(t) + } + sb.WriteString("## " + label + "\n\n") + for _, f := range ff { + sb.WriteString("### " + f.Title + "\n\n") + if f.Body != "" { + sb.WriteString(f.Body + "\n\n") + } + if len(f.Tags) > 0 { + sb.WriteString("Tags: " + strings.Join(f.Tags, ", ") + "\n\n") + } + } + } + return sb.String() +} + +// --- JSON-RPC / MCP wire types ------------------------------------------- + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result interface{} `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type mcpToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema interface{} `json:"inputSchema"` + Annotations interface{} `json:"annotations,omitempty"` +} + +type mcpContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type mcpToolResult struct { + Content []mcpContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +// mcpReadOnlyAnnotations tells MCP clients every tool here is safe to call +// without confirmation: it neither mutates nor reaches outside the hive. +var mcpReadOnlyAnnotations = map[string]interface{}{ + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, +} + +func publicKnowledgeToolDefs() []mcpToolDef { + return []mcpToolDef{ + { + Name: "knowledge_search", + Description: "Search this hive's public operational knowledge (patterns, gotchas, regressions, integrations) by free-text query. Returns matching facts as JSON.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{"type": "string", "description": "Free-text search query."}, + "type": map[string]interface{}{"type": "string", "description": "Optional fact type filter: pattern, gotcha, regression, test_scaffold, integration, coverage_rule, general."}, + "limit": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("Max results (default %d, max %d).", publicKnowledgeDefaultLimit, publicKnowledgeMaxLimit)}, + }, + "required": []string{"query"}, + }, + Annotations: mcpReadOnlyAnnotations, + }, + { + Name: "knowledge_get", + Description: "Fetch a single public knowledge fact by slug (as returned by knowledge_search).", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "slug": map[string]interface{}{"type": "string", "description": "Fact slug."}, + }, + "required": []string{"slug"}, + }, + Annotations: mcpReadOnlyAnnotations, + }, + { + Name: "knowledge_export", + Description: "Export the entire public knowledge base as a single Markdown document, grouped by fact type.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + Annotations: mcpReadOnlyAnnotations, + }, + } +} + +// handlePublicKnowledgeMCP serves POST /mcp/knowledge. It is the ONLY +// anonymous entry point into knowledge and fails closed when the owner switch +// is off, regardless of what the auth middleware admitted. +func (s *Server) handlePublicKnowledgeMCP(w http.ResponseWriter, r *http.Request) { + if !publicKnowledgeEnabled() { + http.NotFound(w, r) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, publicKnowledgeMaxBodyBytes+1)) + if err != nil || len(body) > publicKnowledgeMaxBodyBytes { + writeJSONRPC(w, http.StatusRequestEntityTooLarge, jsonRPCResponse{JSONRPC: "2.0", ID: nullID, Error: &jsonRPCError{Code: jsonRPCInvalidRequest, Message: "request body too large"}}) + return + } + + if trimmed := bytes.TrimSpace(body); len(trimmed) > 0 && trimmed[0] == '[' { + writeJSONRPC(w, http.StatusBadRequest, jsonRPCResponse{JSONRPC: "2.0", ID: nullID, Error: &jsonRPCError{Code: jsonRPCInvalidRequest, Message: "JSON-RPC batch requests are not supported; send one request per POST"}}) + return + } + + var req jsonRPCRequest + if err := json.Unmarshal(body, &req); err != nil { + writeJSONRPC(w, http.StatusBadRequest, jsonRPCResponse{JSONRPC: "2.0", ID: nullID, Error: &jsonRPCError{Code: jsonRPCParseError, Message: "invalid JSON"}}) + return + } + if req.JSONRPC != "2.0" || req.Method == "" { + writeJSONRPC(w, http.StatusBadRequest, jsonRPCResponse{JSONRPC: "2.0", ID: idOrNull(req.ID), Error: &jsonRPCError{Code: jsonRPCInvalidRequest, Message: "expected JSON-RPC 2.0 request with a method"}}) + return + } + + // Notifications carry no id and get no body (MCP Streamable HTTP: 202). + if strings.HasPrefix(req.Method, "notifications/") { + w.WriteHeader(http.StatusAccepted) + return + } + + resp := jsonRPCResponse{JSONRPC: "2.0", ID: idOrNull(req.ID)} + switch req.Method { + case "initialize": + resp.Result = map[string]interface{}{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]interface{}{"tools": map[string]interface{}{"listChanged": false}}, + "serverInfo": map[string]interface{}{"name": "hive-public-knowledge", "version": "1"}, + "instructions": "Read-only access to this hive's operational knowledge. Use knowledge_search for targeted lookups and knowledge_export for the full Markdown document.", + } + case "ping": + resp.Result = map[string]interface{}{} + case "tools/list": + resp.Result = map[string]interface{}{"tools": publicKnowledgeToolDefs()} + case "tools/call": + result, rpcErr := s.callPublicKnowledgeTool(r, req.Params) + if rpcErr != nil { + resp.Error = rpcErr + } else { + resp.Result = result + } + default: + resp.Error = &jsonRPCError{Code: jsonRPCMethodNotFound, Message: "method not found: " + req.Method} + } + writeJSONRPC(w, http.StatusOK, resp) +} + +var nullID = json.RawMessage("null") + +func idOrNull(id json.RawMessage) json.RawMessage { + if len(id) == 0 { + return nullID + } + return id +} + +func writeJSONRPC(w http.ResponseWriter, status int, resp jsonRPCResponse) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(resp) +} + +type mcpToolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +// callPublicKnowledgeTool dispatches tools/call. Unknown tool names are a +// JSON-RPC invalid-params error; tool-level problems (fact not found) come +// back as isError results per the MCP spec so the model can recover. +func (s *Server) callPublicKnowledgeTool(r *http.Request, raw json.RawMessage) (interface{}, *jsonRPCError) { + var params mcpToolCallParams + if len(raw) > 0 { + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "invalid tools/call params"} + } + } + if !s.ensureKnowledge() { + return toolText("Knowledge base not available."), nil + } + tags := publicKnowledgeTags() + + switch params.Name { + case "knowledge_search": + var args struct { + Query string `json:"query"` + Type string `json:"type"` + Limit int `json:"limit"` + } + if len(params.Arguments) > 0 { + if err := json.Unmarshal(params.Arguments, &args); err != nil { + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "invalid knowledge_search arguments"} + } + } + args.Query = strings.TrimSpace(args.Query) + if args.Query == "" { + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "query is required"} + } + if _, ok := publicKnowledgeTypes[args.Type]; !ok { + return toolError(fmt.Sprintf("type %q is not a public fact type", args.Type)), nil + } + limit := args.Limit + if limit <= 0 { + limit = publicKnowledgeDefaultLimit + } + if limit > publicKnowledgeMaxLimit { + limit = publicKnowledgeMaxLimit + } + _, publicSlugs := s.publicListing(tags) + facts := publicKnowledgeFilter(s.deps.Knowledge.SearchAllWithVaults(s.deps.Ctx, args.Query, args.Type, publicKnowledgeOverfetch), tags) + out := make([]publicFact, 0, limit) + for _, f := range facts { + if len(out) >= limit { + break + } + // Vault/git-source stores ignore the type argument; enforce it here + // so the advertised filter holds for every source. + if args.Type != "" && publicTypeName(f.Type) != args.Type { + continue + } + if _, ok := publicSlugs[f.Slug]; !ok { + continue + } + out = append(out, toPublicFact(s.withFullBody(f), publicSlugs)) + } + return toolJSON(map[string]interface{}{"query": args.Query, "count": len(out), "results": out}) + + case "knowledge_get": + var args struct { + Slug string `json:"slug"` + } + if len(params.Arguments) > 0 { + if err := json.Unmarshal(params.Arguments, &args); err != nil { + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "invalid knowledge_get arguments"} + } + } + args.Slug = strings.TrimSpace(args.Slug) + if args.Slug == "" { + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "slug is required"} + } + // Resolve through the same filtered listing search uses so a private + // fact can never be fetched by guessing its slug. + all, publicSlugs := s.publicListing(tags) + for _, f := range all { + if f.Slug == args.Slug { + return toolJSON(toPublicFact(s.withFullBody(f), publicSlugs)) + } + } + return toolError("fact not found: " + args.Slug), nil + + case "knowledge_export": + all, _ := s.publicListing(tags) + for i := range all { + all[i] = s.withFullBody(all[i]) + } + md := renderPublicKnowledgeMarkdown(all) + sum := sha256.Sum256([]byte(md)) + return map[string]interface{}{ + "content": []mcpContent{{Type: "text", Text: md}}, + "_meta": map[string]interface{}{"etag": fmt.Sprintf(`"%x"`, sum), "facts": len(all)}, + }, nil + + case "": + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "tool name is required"} + default: + return nil, &jsonRPCError{Code: jsonRPCInvalidParams, Message: "unknown tool: " + params.Name} + } +} + +// publicTypeName normalises the empty type to "general" for comparisons. +func publicTypeName(t knowledge.FactType) string { + if t == "" { + return "general" + } + return string(t) +} + +func toolText(text string) mcpToolResult { + return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}} +} + +func toolError(text string) mcpToolResult { + return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}, IsError: true} +} + +func toolJSON(v interface{}) (interface{}, *jsonRPCError) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return toolError("failed to encode result"), nil + } + return toolText(string(b)), nil +} + +// publicKnowledgeTypeList is exported for docs/tests: the sorted public types. +func publicKnowledgeTypeList() []string { + out := make([]string, 0, len(publicKnowledgeTypes)) + for t := range publicKnowledgeTypes { + if t != "" { + out = append(out, t) + } + } + sort.Strings(out) + return out +} diff --git a/src/pkg/dashboard/mcp_knowledge_test.go b/src/pkg/dashboard/mcp_knowledge_test.go new file mode 100644 index 0000000000..6720c80716 --- /dev/null +++ b/src/pkg/dashboard/mcp_knowledge_test.go @@ -0,0 +1,409 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/knowledge" +) + +// publicKnowledgeServer builds a token-secured spoke with a vault holding one +// fact of every interesting kind: a public gotcha, a tagged public pattern, +// and a private governance fact that must never leave through /mcp/knowledge. +func publicKnowledgeServer(t *testing.T) *Server { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) + s := NewServerWithAuth(0, "dashboard-secret", logger) + s.RegisterAPI(testDeps(t)) + + vault := filepath.Join(t.TempDir(), "vault") + if err := os.MkdirAll(vault, 0o755); err != nil { + t.Fatal(err) + } + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(vault, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("gotcha-nil-map.md", "---\ntitle: Nil Map Gotcha\ntype: gotcha\n---\nAlways initialize maps before use.\n") + write("pattern-caching.md", "---\ntitle: Caching Pattern\ntype: pattern\ntags: [linux, public]\n---\nUse Redis for caching hot paths.\n") + write("vision-world-domination.md", "---\ntitle: Secret Roadmap\ntype: vision\n---\nWe will acquire a competitor in Q3.\n") + // Long body (>200 runes) with a Related link to a private fact: exercises + // full-body re-reads and Related filtering. + write("pattern-long-body.md", "---\ntitle: Long Caching Notes\ntype: pattern\nrelated: [pattern-caching, vision-world-domination]\n---\n"+strings.Repeat("Redis caching detail sentence. ", 20)+"END-MARKER\n") + + api := knowledge.NewKnowledgeAPI(nil, knowledge.KnowledgeConfig{Enabled: true, Engine: "file"}, logger) + if err := api.ConnectVault(vault, "vault"); err != nil { + t.Fatalf("ConnectVault: %v", err) + } + s.deps.Knowledge = api + return s +} + +func mcpCall(t *testing.T, s *Server, payload string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, publicKnowledgeMCPPath, bytes.NewBufferString(payload)) + req.Header.Set("Content-Type", "application/json") + // Deliberately anonymous: no Authorization, no X-Hive-* headers. Goes + // through the full handler chain so the authenticate middleware is tested. + s.Handler().ServeHTTP(rec, req) + return rec +} + +func decodeRPC(t *testing.T, rec *httptest.ResponseRecorder) jsonRPCResponse { + t.Helper() + var resp jsonRPCResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return resp +} + +// toolText extracts the single text content of a tools/call result. +func rpcToolText(t *testing.T, resp jsonRPCResponse) (string, bool) { + t.Helper() + if resp.Error != nil { + t.Fatalf("unexpected rpc error: %+v", resp.Error) + } + raw, _ := json.Marshal(resp.Result) + var res mcpToolResult + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatalf("decode tool result %s: %v", raw, err) + } + if len(res.Content) != 1 { + t.Fatalf("content = %+v, want 1 item", res.Content) + } + return res.Content[0].Text, res.IsError +} + +func TestPublicKnowledgeMCPDisabledByDefault(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "") + s := publicKnowledgeServer(t) + + rec := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) + if rec.Code == http.StatusOK { + t.Fatalf("switch off: status = %d, want non-200; body=%q", rec.Code, rec.Body.String()) + } + if isPublicPath(publicKnowledgeMCPPath) { + t.Fatal("switch off: /mcp/knowledge must not be a public path") + } +} + +func TestPublicKnowledgeMCPDisabledEvenWhenAuthenticated(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "0") + s := publicKnowledgeServer(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, publicKnowledgeMCPPath, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + req.Header.Set("Authorization", "Bearer dashboard-secret") + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("switch off + owner token: status = %d, want 404 (the surface does not exist until the owner opens it)", rec.Code) + } +} + +func TestPublicKnowledgeMCPEnabledSpellings(t *testing.T) { + for _, v := range []string{"1", "true", "YES", " on "} { + t.Setenv(publicKnowledgeEnabledEnv, v) + if !publicKnowledgeEnabled() { + t.Errorf("%q should enable", v) + } + } + for _, v := range []string{"", "0", "false", "off", "maybe"} { + t.Setenv(publicKnowledgeEnabledEnv, v) + if publicKnowledgeEnabled() { + t.Errorf("%q should not enable", v) + } + } +} + +func TestPublicKnowledgeMCPHandshakeAndToolsList(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "1") + s := publicKnowledgeServer(t) + + if !isPublicPath(publicKnowledgeMCPPath) { + t.Fatal("switch on: /mcp/knowledge must be public") + } + + rec := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"goose","version":"1"}}}`) + if rec.Code != http.StatusOK { + t.Fatalf("initialize anonymous: status = %d body=%q", rec.Code, rec.Body.String()) + } + resp := decodeRPC(t, rec) + if resp.Error != nil { + t.Fatalf("initialize error: %+v", resp.Error) + } + init := resp.Result.(map[string]interface{}) + if init["protocolVersion"] != mcpProtocolVersion { + t.Errorf("protocolVersion = %v", init["protocolVersion"]) + } + + rec = mcpCall(t, s, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + if rec.Code != http.StatusAccepted || rec.Body.Len() != 0 { + t.Fatalf("notification: status = %d body=%q, want 202 and empty", rec.Code, rec.Body.String()) + } + + rec = mcpCall(t, s, `{"jsonrpc":"2.0","id":"abc","method":"ping"}`) + if resp = decodeRPC(t, rec); resp.Error != nil || string(resp.ID) != `"abc"` { + t.Fatalf("ping: %+v id=%s", resp.Error, resp.ID) + } + + rec = mcpCall(t, s, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + resp = decodeRPC(t, rec) + raw, _ := json.Marshal(resp.Result) + var list struct { + Tools []mcpToolDef `json:"tools"` + } + if err := json.Unmarshal(raw, &list); err != nil { + t.Fatal(err) + } + var names []string + for _, td := range list.Tools { + names = append(names, td.Name) + ann, _ := td.Annotations.(map[string]interface{}) + if ann["readOnlyHint"] != true || ann["destructiveHint"] != false { + t.Errorf("tool %s must be annotated read-only, got %v", td.Name, td.Annotations) + } + for _, verb := range []string{"create", "update", "delete", "write", "import", "promote"} { + if strings.Contains(td.Name, verb) { + t.Errorf("tool %s looks like a write tool; public surface must be read-only", td.Name) + } + } + } + if got := strings.Join(names, ","); got != "knowledge_search,knowledge_get,knowledge_export" { + t.Fatalf("tools = %s", got) + } +} + +func TestPublicKnowledgeMCPSearchGetExportHidePrivateTypes(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "true") + t.Setenv(publicKnowledgeTagsEnv, "") + s := publicKnowledgeServer(t) + + // Export: public types present, vision absent, sources never rendered. + resp := decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export","arguments":{}}}`)) + md, isErr := rpcToolText(t, resp) + if isErr { + t.Fatalf("export isError: %s", md) + } + for _, want := range []string{"# Hive Public Knowledge", "## Gotchas", "### Nil Map Gotcha", "## Patterns", "### Caching Pattern", "Tags: linux, public"} { + if !strings.Contains(md, want) { + t.Errorf("export missing %q:\n%s", want, md) + } + } + for _, leak := range []string{"Secret Roadmap", "acquire a competitor", "## Vision"} { + if strings.Contains(md, leak) { + t.Errorf("export leaked private content %q:\n%s", leak, md) + } + } + + // Get: public slug resolves, private slug is "not found" (not 'forbidden', + // which would confirm existence). + resp = decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"knowledge_get","arguments":{"slug":"gotcha-nil-map"}}}`)) + text, isErr := rpcToolText(t, resp) + if isErr || !strings.Contains(text, "Always initialize maps") { + t.Fatalf("get public: isErr=%v text=%s", isErr, text) + } + var pf publicFact + if err := json.Unmarshal([]byte(text), &pf); err != nil { + t.Fatalf("get result not a publicFact: %v", err) + } + var generic map[string]interface{} + _ = json.Unmarshal([]byte(text), &generic) + for _, hidden := range []string{"sources", "usage_count", "confidence_reason", "last_used", "phase"} { + if _, ok := generic[hidden]; ok { + t.Errorf("public fact exposes %q", hidden) + } + } + + resp = decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"knowledge_get","arguments":{"slug":"vision-world-domination"}}}`)) + text, isErr = rpcToolText(t, resp) + if !isErr || !strings.Contains(text, "fact not found") { + t.Fatalf("get private: isErr=%v text=%s, want not-found", isErr, text) + } + + // Search: a query that only matches the private fact returns nothing. + resp = decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"knowledge_search","arguments":{"query":"competitor"}}}`)) + text, _ = rpcToolText(t, resp) + if strings.Contains(text, "Secret Roadmap") { + t.Fatalf("search leaked private fact: %s", text) + } + resp = decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"knowledge_search","arguments":{"query":"maps","limit":1000}}}`)) + text, _ = rpcToolText(t, resp) + if !strings.Contains(text, "Nil Map Gotcha") { + t.Fatalf("search did not find public fact: %s", text) + } +} + +func TestPublicKnowledgeMCPTagAllowList(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "1") + t.Setenv(publicKnowledgeTagsEnv, "Public, troubleshooting") + s := publicKnowledgeServer(t) + + resp := decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export"}}`)) + md, _ := rpcToolText(t, resp) + if !strings.Contains(md, "Caching Pattern") { + t.Errorf("tagged fact missing:\n%s", md) + } + if strings.Contains(md, "Nil Map Gotcha") { + t.Errorf("untagged fact exposed despite tag allow-list:\n%s", md) + } +} + +func TestPublicKnowledgeMCPRejectsWritesAndBadInput(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "1") + s := publicKnowledgeServer(t) + + cases := map[string]int{ + `{"jsonrpc":"2.0","id":1,"method":"resources/write"}`: jsonRPCMethodNotFound, + `{"jsonrpc":"2.0","id":1,"method":"knowledge/create"}`: jsonRPCMethodNotFound, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_create","arguments":{}}}`: jsonRPCInvalidParams, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_delete","arguments":{}}}`: jsonRPCInvalidParams, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_search","arguments":{}}}`: jsonRPCInvalidParams, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_get","arguments":{}}}`: jsonRPCInvalidParams, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_search","arguments":"nope"}}`: jsonRPCInvalidParams, + `{"jsonrpc":"1.0","id":1,"method":"tools/list"}`: jsonRPCInvalidRequest, + `not json`: jsonRPCParseError, + } + for payload, wantCode := range cases { + resp := decodeRPC(t, mcpCall(t, s, payload)) + if resp.Error == nil || resp.Error.Code != wantCode { + t.Errorf("%s: error = %+v, want code %d", payload, resp.Error, wantCode) + } + } + + // Non-POST verbs are refused outright; there is no SSE stream to open. + for _, m := range []string{http.MethodGet, http.MethodDelete, http.MethodPut} { + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(m, publicKnowledgeMCPPath, nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: status = %d, want 405", m, rec.Code) + } + } + + // Oversized bodies are rejected before parsing. + big := `{"jsonrpc":"2.0","id":1,"method":"ping","params":{"pad":"` + strings.Repeat("x", publicKnowledgeMaxBodyBytes) + `"}}` + if rec := mcpCall(t, s, big); rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("oversized: status = %d, want 413", rec.Code) + } + + // Search with a private type filter is refused as a tool error. + resp := decodeRPC(t, mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_search","arguments":{"query":"x","type":"vision"}}}`)) + if text, isErr := rpcToolText(t, resp); !isErr || !strings.Contains(text, "not a public fact type") { + t.Errorf("private type filter: isErr=%v text=%s", isErr, text) + } +} + +func TestPublicKnowledgeMCPNoKnowledgeBase(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "1") + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s := NewServerWithAuth(0, "", logger) + // deps nil → ensureKnowledge false → graceful text, never a panic. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, publicKnowledgeMCPPath, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export"}}`)) + s.handlePublicKnowledgeMCP(rec, req) + resp := decodeRPC(t, rec) + if text, _ := rpcToolText(t, resp); !strings.Contains(text, "not available") { + t.Fatalf("text = %s", text) + } +} + +func TestPublicKnowledgeTypeListExcludesGovernance(t *testing.T) { + got := strings.Join(publicKnowledgeTypeList(), ",") + for _, private := range []string{"idea", "vision", "constitution", "requirement", "constraint", "stakeholder", "decision"} { + if strings.Contains(got, private) { + t.Errorf("public type list includes private type %q: %s", private, got) + } + } + if !strings.Contains(got, "gotcha") || !strings.Contains(got, "pattern") { + t.Errorf("public type list = %s", got) + } +} + +// The route is registered with a literal so docs/api-reference.md citations +// can verify it; keep the literal and the constant in lock-step. +func TestPublicKnowledgeMCPPathConstantMatchesRoute(t *testing.T) { + if publicKnowledgeMCPPath != "/mcp/knowledge" { + t.Fatalf("publicKnowledgeMCPPath = %q; update the HandleFunc literal in api.go and docs/api-reference.md", publicKnowledgeMCPPath) + } +} + +func TestPublicKnowledgeMCPRejectsBatchRequests(t *testing.T) { + t.Setenv("HIVE_PUBLIC_KNOWLEDGE", "1") + s := publicKnowledgeServer(t) + rec := mcpCall(t, s, `[{"jsonrpc":"2.0","id":1,"method":"ping"}]`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + resp := decodeRPC(t, rec) + if resp.Error == nil || resp.Error.Code != jsonRPCInvalidRequest { + t.Fatalf("error = %+v, want code %d", resp.Error, jsonRPCInvalidRequest) + } +} + +func TestPublicKnowledgeMCPGetReturnsFullBodyAndFiltersRelated(t *testing.T) { + t.Setenv("HIVE_PUBLIC_KNOWLEDGE", "1") + s := publicKnowledgeServer(t) + rec := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_get","arguments":{"slug":"pattern-long-body"}}}`) + text, isErr := rpcToolText(t, decodeRPC(t, rec)) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + var pf publicFact + if err := json.Unmarshal([]byte(text), &pf); err != nil { + t.Fatalf("decode fact: %v", err) + } + if !strings.Contains(pf.Body, "END-MARKER") { + t.Fatalf("body truncated, want full text ending in END-MARKER: %q", pf.Body) + } + if len(pf.Related) != 1 || pf.Related[0] != "pattern-caching" { + t.Fatalf("related = %v, want only the public slug", pf.Related) + } + + rec = mcpCall(t, s, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"knowledge_export","arguments":{}}}`) + text, _ = rpcToolText(t, decodeRPC(t, rec)) + if !strings.Contains(text, "END-MARKER") { + t.Fatal("export should carry full bodies") + } + if strings.Contains(text, "vision-world-domination") || strings.Contains(text, "acquire a competitor") { + t.Fatal("export leaked private fact") + } +} + +func TestPublicKnowledgeMCPSearchTypeFilterAppliesToVaultResults(t *testing.T) { + t.Setenv("HIVE_PUBLIC_KNOWLEDGE", "1") + s := publicKnowledgeServer(t) + // "maps" only matches the gotcha; asking for type=pattern must yield nothing + // even though the vault store ignores the type argument. + rec := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_search","arguments":{"query":"initialize maps","type":"pattern"}}}`) + text, isErr := rpcToolText(t, decodeRPC(t, rec)) + if isErr { + t.Fatalf("unexpected tool error: %s", text) + } + var out struct { + Count int `json:"count"` + Results []publicFact `json:"results"` + } + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatal(err) + } + for _, r := range out.Results { + if r.Type != "pattern" { + t.Fatalf("type filter leaked %q: %+v", r.Type, r) + } + } + rec = mcpCall(t, s, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"knowledge_search","arguments":{"query":"initialize maps","type":"gotcha"}}}`) + text, _ = rpcToolText(t, decodeRPC(t, rec)) + if !strings.Contains(text, "gotcha-nil-map") { + t.Fatalf("gotcha search missing result: %s", text) + } +} diff --git a/src/pkg/dashboard/server.go b/src/pkg/dashboard/server.go index 46670fdc88..ae2252d26b 100644 --- a/src/pkg/dashboard/server.go +++ b/src/pkg/dashboard/server.go @@ -1836,6 +1836,11 @@ func isPublicPath(path string) bool { return true case path == "/auth/return": return true + case path == publicKnowledgeMCPPath && publicKnowledgeEnabled(): + // Owner-switched, read-only MCP knowledge endpoint (#10615). Public only + // while HIVE_PUBLIC_KNOWLEDGE is on; handlePublicKnowledgeMCP re-checks + // the switch and 404s otherwise, and serves no write method at all. + return true case path == "/metrics" && metricsEnabled(): // Prometheus scrape target — bypasses dashboard auth only when // explicitly enabled via HIVE_METRICS_ENABLED (Prometheus cannot diff --git a/src/pkg/hub/saas_auth.go b/src/pkg/hub/saas_auth.go index 5e5834ad72..215a358030 100644 --- a/src/pkg/hub/saas_auth.go +++ b/src/pkg/hub/saas_auth.go @@ -783,6 +783,7 @@ func (s *HubServer) handleUserToken(w http.ResponseWriter, r *http.Request) { var publicExactPaths = map[string]struct{}{ knowledgeExportPath: {}, + publicKnowledgeMCPPath: {}, "/api/gh-user-auth/status": {}, "/api/style": {}, "/api/theme.css": {}, @@ -853,6 +854,15 @@ func isSaaSPublicPath(originalURI string) bool { // #4050 (/api/v1) and #7453 (/api/contribute/me). const knowledgeExportPath = "/api/knowledge/export" +// publicKnowledgeMCPPath is the spoke's owner-switched, read-only MCP +// knowledge endpoint (#10615). External agents (Goose, Claude, Copilot) reach +// it with no browser session, so the hub's nginx auth_request gate must wave +// it through for the same reason as knowledgeExportPath. This does NOT open +// anything by itself: the spoke's handlePublicKnowledgeMCP 404s unless the +// owner set HIVE_PUBLIC_KNOWLEDGE, and the endpoint serves only +// search/get/export — there is no write method on that surface. +const publicKnowledgeMCPPath = "/mcp/knowledge" + // ssoHandoffPath is the spoke's SSO handoff endpoint. It MUST bypass the hub's // nginx auth_request gate. // diff --git a/src/pkg/hub/saas_authcheck_public_knowledge_mcp_test.go b/src/pkg/hub/saas_authcheck_public_knowledge_mcp_test.go new file mode 100644 index 0000000000..2c699410da --- /dev/null +++ b/src/pkg/hub/saas_authcheck_public_knowledge_mcp_test.go @@ -0,0 +1,36 @@ +package hub + +import ( + "net/http" + "testing" +) + +// The hub cannot see a spoke's HIVE_PUBLIC_KNOWLEDGE, so it waves +// /mcp/knowledge through the nginx auth_request gate and lets the spoke +// fail closed (#10615) — the same arrangement as knowledgeExportPath (#8294). +func TestSaaSAuthCheckPublicKnowledgeMCPPathBypassesGate(t *testing.T) { + cleanup := helperSetupTempDirs(t) + defer cleanup() + s := newHandlerHub() + demotedOwnerFixture(t, s, "spoke-owner", "owned-hive") + + if !isSaaSPublicPath(publicKnowledgeMCPPath) || !isSaaSPublicPath(publicKnowledgeMCPPath+"?x=1") { + t.Fatalf("%s must be a SaaS public path", publicKnowledgeMCPPath) + } + // Only the exact path — nothing else under /mcp/ is public. + for _, p := range []string{"/mcp", "/mcp/", "/mcp/knowledge/admin", "/mcp/other"} { + if isSaaSPublicPath(p) { + t.Errorf("%s must NOT be public", p) + } + } + + rec := publicPathAuthCheck(t, s, "owned-hive", publicKnowledgeMCPPath, "") + if rec.Code != http.StatusOK { + t.Fatalf("%s anonymous: status = %d, want 200 (a 401 becomes an auth-signin redirect and an external agent never reaches the spoke's own switch)", publicKnowledgeMCPPath, rec.Code) + } + for _, h := range []string{"X-Hive-User", "X-Hive-Role", proxyAuthHeader} { + if got := rec.Header().Get(h); got != "" { + t.Errorf("%s anonymous: %s = %q, want unset", publicKnowledgeMCPPath, h, got) + } + } +}