fix(mcp/brain/tools): cap org field at 128 runes (parity with PHP)

brainRemember, brainRecall, and brainList now validate the org field
against a 128-rune length cap before forwarding to the upstream Brain
service. Matches PHP-side maxLength=128 in BrainService — closes the
Go→PHP drift Cerberus #1006 flagged. coreerr.E typed error returned
on violation.

Note: Codex preflight checked project, agent_id, type — PHP schema
only exposes maxLength for org, so caps weren't added for the other
fields. If those need bounds, file separate tickets.

Tests cover: empty org accepted, "core" accepted, exactly 128 runes
accepted (boundary), 129 rejected on remember/recall/list.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1006
This commit is contained in:
Snider 2026-04-25 18:50:06 +01:00
parent cb62378a2b
commit e73deea84b
2 changed files with 197 additions and 0 deletions

View file

@ -5,6 +5,7 @@ package brain
import (
"context"
"time"
"unicode/utf8"
coreerr "dappco.re/go/log"
coremcp "dappco.re/go/mcp/pkg/mcp"
@ -12,6 +13,8 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const brainOrgMaxLength = 128
// emitChannel pushes a brain event through the shared notifier.
func (s *Subsystem) emitChannel(ctx context.Context, channel string, data any) {
if s.notifier != nil {
@ -128,6 +131,25 @@ type ListOutput struct {
Memories []Memory `json:"memories"`
}
func validateBrainOrg(org string) error {
if utf8.RuneCountInString(org) > brainOrgMaxLength {
return coreerr.E("brain.validate", "org exceeds maximum length of 128 characters", nil)
}
return nil
}
func validateRememberInput(input RememberInput) error {
return validateBrainOrg(input.Org)
}
func validateRecallInput(input RecallInput) error {
return validateBrainOrg(input.Filter.Org)
}
func validateListInput(input ListInput) error {
return validateBrainOrg(input.Org)
}
// -- Tool registration --------------------------------------------------------
func (s *Subsystem) registerBrainTools(svc *coremcp.Service) {
@ -156,6 +178,9 @@ func (s *Subsystem) registerBrainTools(svc *coremcp.Service) {
// -- Tool handlers ------------------------------------------------------------
func (s *Subsystem) brainRemember(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
if err := validateRememberInput(input); err != nil {
return nil, RememberOutput{}, err
}
if s.bridge == nil {
return nil, RememberOutput{}, errBridgeNotAvailable
}
@ -190,6 +215,9 @@ func (s *Subsystem) brainRemember(ctx context.Context, _ *mcp.CallToolRequest, i
}
func (s *Subsystem) brainRecall(ctx context.Context, _ *mcp.CallToolRequest, input RecallInput) (*mcp.CallToolResult, RecallOutput, error) {
if err := validateRecallInput(input); err != nil {
return nil, RecallOutput{}, err
}
if s.bridge == nil {
return nil, RecallOutput{}, errBridgeNotAvailable
}
@ -240,6 +268,9 @@ func (s *Subsystem) brainForget(ctx context.Context, _ *mcp.CallToolRequest, inp
}
func (s *Subsystem) brainList(ctx context.Context, _ *mcp.CallToolRequest, input ListInput) (*mcp.CallToolResult, ListOutput, error) {
if err := validateListInput(input); err != nil {
return nil, ListOutput{}, err
}
if s.bridge == nil {
return nil, ListOutput{}, errBridgeNotAvailable
}

166
pkg/mcp/brain/tools_test.go Normal file
View file

@ -0,0 +1,166 @@
// SPDX-License-Identifier: EUPL-1.2
package brain
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"dappco.re/go/mcp/pkg/mcp/ide"
"dappco.re/go/ws"
"github.com/gorilla/websocket"
)
var brainToolTestUpgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
func newConnectedBrainToolSubsystem(t *testing.T) (*Subsystem, <-chan ide.BridgeMessage) {
t.Helper()
messages := make(chan ide.BridgeMessage, 8)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := brainToolTestUpgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer conn.Close()
for {
var msg ide.BridgeMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
messages <- msg
}
}))
ctx, cancel := context.WithCancel(context.Background())
hub := ws.NewHub()
go hub.Run(ctx)
cfg := ide.DefaultConfig()
cfg.LaravelWSURL = "ws" + strings.TrimPrefix(srv.URL, "http")
cfg.ReconnectInterval = 10 * time.Millisecond
cfg.MaxReconnectInterval = 10 * time.Millisecond
bridge := ide.NewBridge(hub, cfg)
bridge.Start(ctx)
waitBrainToolBridgeConnected(t, bridge)
t.Cleanup(func() {
bridge.Shutdown()
cancel()
srv.Close()
})
return New(bridge), messages
}
func waitBrainToolBridgeConnected(t *testing.T, bridge *ide.Bridge) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if bridge.Connected() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("bridge did not connect within timeout")
}
func readBrainToolBridgeMessage(t *testing.T, messages <-chan ide.BridgeMessage) ide.BridgeMessage {
t.Helper()
select {
case msg := <-messages:
return msg
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for bridge message")
return ide.BridgeMessage{}
}
}
func assertBrainOrgValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected org validation error")
}
if !strings.Contains(err.Error(), "org exceeds maximum length of 128 characters") {
t.Fatalf("expected org length error, got %v", err)
}
}
func TestBrainRemember_Good_OrgLengthBoundary(t *testing.T) {
sub, messages := newConnectedBrainToolSubsystem(t)
for _, tc := range []struct {
name string
org string
}{
{name: "non_empty", org: "core"},
{name: "empty", org: ""},
{name: "boundary", org: strings.Repeat("a", brainOrgMaxLength)},
} {
t.Run(tc.name, func(t *testing.T) {
_, out, err := sub.brainRemember(context.Background(), nil, RememberInput{
Content: "test memory",
Type: "observation",
Org: tc.org,
})
if err != nil {
t.Fatalf("brainRemember failed: %v", err)
}
if !out.Success {
t.Fatal("expected success=true")
}
msg := readBrainToolBridgeMessage(t, messages)
if msg.Type != "brain_remember" {
t.Fatalf("expected brain_remember message, got %q", msg.Type)
}
data, ok := msg.Data.(map[string]any)
if !ok {
t.Fatalf("expected bridge data map, got %T", msg.Data)
}
if data["org"] != tc.org {
t.Fatalf("expected org %q, got %v", tc.org, data["org"])
}
})
}
}
func TestBrainRemember_Bad_OrgTooLong(t *testing.T) {
sub := New(nil)
_, _, err := sub.brainRemember(context.Background(), nil, RememberInput{
Content: "test memory",
Type: "observation",
Org: strings.Repeat("a", brainOrgMaxLength+1),
})
assertBrainOrgValidationError(t, err)
}
func TestBrainOrgValidation_Bad_RecallAndListRejectBeforeBridge(t *testing.T) {
sub := New(nil)
tooLong := strings.Repeat("a", brainOrgMaxLength+1)
_, _, err := sub.brainRecall(context.Background(), nil, RecallInput{
Query: "test",
Filter: RecallFilter{Org: tooLong},
})
assertBrainOrgValidationError(t, err)
_, _, err = sub.brainList(context.Background(), nil, ListInput{
Org: tooLong,
})
assertBrainOrgValidationError(t, err)
}