mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-20 18:24:03 +00:00
* feat(mcp): filter tools at registration + detect FastMCP session reset with force-reconnect Two foundational MCP reliability improvements: (1) Tool allow/deny filtering at BridgeTool registration: Previously the runtime grant-check at execute time surfaced "grant revoked" errors when the LLM called a tool it wasn't allowed to call. Filter upfront in both the per-agent registration path (manager_connect.go) and per-user registration path (loop_mcp_user.go). Adds tool_filter.go with IsToolAllowed + tests. This eliminates the "registered then runtime-denied" loop. (2) FastMCP session reset detection + force-reconnect: FastMCP/Python mcp servers reject tools/call as "invalid during session initialization" when the upstream session lifecycle resets but our pool still holds the old Mcp-Session-Id. Detector matches three known phrasings (FastMCP, mcp-go, mcp-go transport ErrSessionTerminated) in session_reset.go. On detection, BridgeTool.Execute requests a force-reconnect via atomic CAS dedup so N concurrent failing calls collapse to one reconnect. Health loops skip ping while pending so a server answering ping in "initializing" state cannot clobber connected=true before the fresh Initialize completes. Includes 30s timeout, structured slog telemetry, concurrent CAS dedup test. Files: tool_filter.go + tool_filter_test.go (new), session_reset.go + session_reset_test.go (new), manager_connect.go (connectServer/connectViaPool signatures + registerBridgeTools/registerPoolBridgeTools filter logic + reconnPending skip), manager.go (connectAndFilter + connectServer call signature changes), loop_mcp_user.go (filter-at-register block + WithForceReconnect wiring), pool.go (reconnPending skip), bridge_tool.go (session reset detection + WithForceReconnect callback). * fix(mcp): self-heal grant cache + bypass per-user grant for system/empty userID Two production fixes for "MCP tool: grant revoked" recurring on song-nhi-v2. (1) System-user bypass in ListAccessible: Registration uses LoadForAgent(ctx, agentID, "") while execute uses IsAllowed(ctx, agentID, "system", ...). The LEFT JOIN on mcp_user_grants could match a stale disabled row keyed user_id='system' and silently filter the server out only at execute. Skip the join entirely for synthetic owner identities (userID="" or "system") so registration and execute see the same set. Applied to both PostgreSQL (mcp_servers_access.go) and SQLite (mcp_servers_access.go). (2) Grant-checker no-cache on empty allowByServer: grant_checker.loadEntry now skips the cache write when allowByServer is empty. Without this, a single transient empty result pinned permanent denial until a bus invalidate fired. Re-queries until the empty condition clears, then caches normally. Includes TestStoreGrantChecker_EmptyEntryNotCached.
89 lines
3.3 KiB
Go
89 lines
3.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsSessionUninitializedErr(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"unrelated", errors.New("connection refused"), false},
|
|
// FastMCP / Python mcp server phrasing — the exact text observed in
|
|
// the production trace that motivated this detector.
|
|
{"fastmcp tools/call", errors.New(`method "tools/call" is invalid during session initialization`), true},
|
|
{"fastmcp other method", errors.New(`method "resources/list" is invalid during session initialization`), true},
|
|
// mcp-go server / Node implementations.
|
|
{"session not initialized", errors.New("session not initialized"), true},
|
|
// mcp-go transport ErrSessionTerminated text (HTTP 404 path).
|
|
{"session terminated", errors.New("session terminated (404). need to re-initialize"), true},
|
|
// Case-insensitive matching keeps detection robust to upstream
|
|
// rewording without dragging the maintainer into a string-match
|
|
// audit every time a server logs slightly differently.
|
|
{"mixed case", errors.New("Session Not Initialized"), true},
|
|
// 401 must NOT match — handled by isUnauthorizedErr to drive a
|
|
// different recovery path (credential purge).
|
|
{"unauthorized 401", errors.New("unauthorized (401)"), false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isSessionUninitializedErr(tc.err); got != tc.want {
|
|
t.Errorf("isSessionUninitializedErr(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequestForceReconnect_Dedup verifies the CAS guard collapses N
|
|
// concurrent failing tool calls into a single reconnect in flight. Without
|
|
// this, a high-QPS agent burst would launch one fullReconnect per failed
|
|
// call — defeating the purpose of pooling and hammering the recovering
|
|
// server during a restart window.
|
|
func TestRequestForceReconnect_Dedup(t *testing.T) {
|
|
// Stand up a serverState whose Pending flag starts cleared. We don't
|
|
// run the goroutine body to completion (that requires a real client);
|
|
// the CAS check itself is what we're verifying. The goroutine will
|
|
// fail fast on the nil client and clear the flag — but only after
|
|
// the test observation point.
|
|
ss := &serverState{name: "test", transport: "streamable-http"}
|
|
|
|
var attempted atomic.Int32
|
|
var wg sync.WaitGroup
|
|
const concurrent = 20
|
|
|
|
for range concurrent {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
// Inline the CAS half of requestForceReconnect — testing the
|
|
// real method would race the cleanup goroutine. The dedup
|
|
// guarantee lives entirely in the CAS, so this is faithful.
|
|
if ss.reconnPending.CompareAndSwap(false, true) {
|
|
attempted.Add(1)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := attempted.Load(); got != 1 {
|
|
t.Errorf("expected exactly 1 reconnect attempt after %d concurrent requests, got %d", concurrent, got)
|
|
}
|
|
if !ss.reconnPending.Load() {
|
|
t.Error("expected reconnPending to be set after first CAS")
|
|
}
|
|
|
|
// Once cleared (simulating reconnect completion), the next request must
|
|
// be allowed through — otherwise a single transient reset would freeze
|
|
// recovery forever.
|
|
ss.reconnPending.Store(false)
|
|
if !ss.reconnPending.CompareAndSwap(false, true) {
|
|
t.Error("expected CAS to succeed after pending flag cleared")
|
|
}
|
|
}
|