hyperguild#81. brain_pending lists brain/raw/ notes awaiting review; brain_promote moves one into the wiki. There was no way to say "reviewed, not worth promoting" — the only options were promote it or let it sit forever. Live consequence, confirmed this session: the pending queue has grown to 11 items, the oldest 152 days old, several of which are generic web scrapes nobody is ever going to promote but nobody could clear either except an un-audited rm bypassing the tool surface entirely. RejectNote (internal/api/promote.go) mirrors PromoteNote's exact safety shape: same basename-only filename validation, same write-then-delete ordering (the source survives any write failure), same collision check (never silently overwrite an existing rejected note under the same name). Stamps rejected_at (+ reject_reason if given) into frontmatter, moves brain/raw/<filename> -> brain/raw/rejected/<filename>. No index rebuild or auto-tunnel -- a rejected note never entered the wiki, so there's nothing to index. raw/rejected/ is a subdirectory ListPending's non-recursive os.ReadDir never walks into, so a rejected note is never re-surfaced as pending -- the human decision, once made, stays made. brain_reject wired into the MCP tool list and dispatch, mirroring brain_promote's schema shape (filename required, reason optional). Human-invoked only, same as promote -- never called automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
152 lines
5.0 KiB
Go
152 lines
5.0 KiB
Go
package mcp_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func body(t *testing.T, v any) *bytes.Buffer {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
require.NoError(t, err)
|
|
return bytes.NewBuffer(b)
|
|
}
|
|
|
|
func TestServerInitialize(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": map[string]any{},
|
|
}))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
result := resp["result"].(map[string]any)
|
|
assert.Equal(t, "2024-11-05", result["protocolVersion"])
|
|
}
|
|
|
|
func TestServerToolsList(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
|
}))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
tools := resp["result"].(map[string]any)["tools"].([]any)
|
|
names := make([]string, 0, len(tools))
|
|
for _, t := range tools {
|
|
names = append(names, t.(map[string]any)["name"].(string))
|
|
}
|
|
assert.ElementsMatch(t, []string{
|
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
|
"brain_pending", "brain_promote", "brain_reject",
|
|
"brain_read", "brain_edit",
|
|
"brain_tunnel",
|
|
"brain_ingest_raw", "brain_ingest",
|
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
|
"session_log",
|
|
}, names)
|
|
|
|
// brain_index is auto-called after every brain_write, so surfacing it as
|
|
// a caller verb is cognitive load with no decision behind it (ADR-0012
|
|
// rule 4, #78). The capability stays reachable — see the admin test below.
|
|
assert.NotContains(t, names, "brain_index")
|
|
|
|
// brain_edit_root is de-surfaced for a sharper reason than brain_index:
|
|
// brain content is ingested from the outside world and reaches a model's
|
|
// context, so a listed governance-file editor would be a durable
|
|
// prompt-injection path — one poisoned page rewriting the CLAUDE.md a
|
|
// .dispatch-allow agent later loads as instructions.
|
|
assert.NotContains(t, names, "brain_edit_root")
|
|
}
|
|
|
|
func TestBrainIndexStaysCallableForAdminUseAfterDeSurfacing(t *testing.T) {
|
|
// De-surfacing hides the verb from the model-facing list; it must not
|
|
// remove the operation. A maintenance caller still needs to rebuild a
|
|
// Map of Content by hand.
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
|
"params": map[string]any{"name": "brain_index", "arguments": map[string]any{}},
|
|
}))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
assert.NotContains(t, resp, "error", "brain_index must still dispatch: %v", resp["error"])
|
|
}
|
|
|
|
func TestServerNotificationGetsNoBody(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
"jsonrpc": "2.0", "method": "notifications/initialized",
|
|
}))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
assert.Empty(t, strings.TrimSpace(rr.Body.String()))
|
|
}
|
|
|
|
func TestServerUnknownMethodReturnsError(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
"jsonrpc": "2.0", "id": 3, "method": "unknown/method",
|
|
}))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
require.NotNil(t, resp["error"])
|
|
errObj := resp["error"].(map[string]any)
|
|
assert.Equal(t, float64(-32601), errObj["code"])
|
|
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
|
}
|
|
|
|
type stubTracker struct{}
|
|
|
|
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
|
return capture.IssueRef{}, nil
|
|
}
|
|
|
|
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
|
return capture.IssueRef{}, nil
|
|
}
|
|
|
|
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
|
return capture.IssueRef{}, nil
|
|
}
|
|
|
|
func TestWithIssueTrackerInjects(t *testing.T) {
|
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
|
assert.Nil(t, srv.IssueTracker(), "tracker is off by default")
|
|
srv = srv.WithIssueTracker(stubTracker{})
|
|
assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface")
|
|
}
|