Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,51 @@ func (s *RDBConfigStore) GetMCPClientsPaginated(ctx context.Context, params MCPC
if params.ClientID != "" {
baseQuery = baseQuery.Where("client_id = ?", params.ClientID)
}
if len(params.ConnectionTypes) > 0 {
baseQuery = baseQuery.Where("connection_type IN ?", params.ConnectionTypes)
}
if len(params.AuthTypes) > 0 {
baseQuery = baseQuery.Where("auth_type IN ?", params.AuthTypes)
}
if params.IsCodeModeClient != nil {
baseQuery = baseQuery.Where("is_code_mode_client = ?", *params.IsCodeModeClient)
}
if params.Disabled != nil {
baseQuery = baseQuery.Where("disabled = ?", *params.Disabled)
}
// Runtime state filter, resolved by the caller into a connected-id set.
if params.StateInclude != nil {
if *params.StateInclude {
// connected: must be in the connected set. An empty set (nothing
// connected) yields IN (NULL) → matches no rows, which is correct.
baseQuery = baseQuery.Where("client_id IN ?", params.StateClientIDs)
} else if len(params.StateClientIDs) > 0 {
// disconnected: everything not currently connected. An empty
// connected set means all rows are disconnected → no constraint.
baseQuery = baseQuery.Where("client_id NOT IN ?", params.StateClientIDs)
}
}
// VK access filter: OR the "open to all VKs" flag with an explicit-assignment
// subquery over the VK⇄MCP join table (matched on the numeric primary key).
if params.OnlyAllVirtualKeys || len(params.VirtualKeyIDs) > 0 {
var assignedSub *gorm.DB
if len(params.VirtualKeyIDs) > 0 {
assignedSub = s.DB().WithContext(ctx).
Model(&tables.TableVirtualKeyMCPConfig{}).
Select("mcp_client_id").
Where("virtual_key_id IN ?", params.VirtualKeyIDs)
}
switch {
case params.OnlyAllVirtualKeys && assignedSub != nil:
baseQuery = baseQuery.Where(
s.DB().Where("allow_on_all_virtual_keys = ?", true).Or("id IN (?)", assignedSub),
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
case params.OnlyAllVirtualKeys:
baseQuery = baseQuery.Where("allow_on_all_virtual_keys = ?", true)
default:
baseQuery = baseQuery.Where("id IN (?)", assignedSub)
}
}

var totalCount int64
if err := baseQuery.Count(&totalCount).Error; err != nil {
Expand Down
25 changes: 21 additions & 4 deletions framework/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,27 @@ type RoutingRulesQueryParams struct {

// MCPClientsQueryParams holds pagination, filtering, and search parameters for MCP client queries.
type MCPClientsQueryParams struct {
Limit int
Offset int
Search string
ClientID string
Limit int
Offset int
Search string // matches name (case-insensitive)
ClientID string // exact client_id match
ConnectionTypes []string // exact connection_type filter(s), OR semantics (http | sse | stdio)
AuthTypes []string // exact auth_type filter(s), OR semantics (none | headers | oauth | per_user_oauth | per_user_headers)
IsCodeModeClient *bool // nil = no filter; true/false = filter on is_code_mode_client
Disabled *bool // nil = no filter; true/false = filter on disabled

// Runtime connection-state filter. State is not persisted, so the caller
// resolves the set of currently-connected client_ids from the engine and
// passes it here. StateInclude nil = no filter; true = client_id IN set
// (connected); false = client_id NOT IN set (disconnected).
StateClientIDs []string
StateInclude *bool

// Virtual-key access filter (OR semantics within the group). When both are
// set, a client matches if it is open to all VKs OR explicitly assigned to
// one of VirtualKeyIDs.
OnlyAllVirtualKeys bool // include clients with allow_on_all_virtual_keys=true
VirtualKeyIDs []string // include clients explicitly assigned to any of these VK IDs
}

// MCPLibraryQueryParams holds pagination, filtering, search, and sort
Expand Down
155 changes: 112 additions & 43 deletions transports/bifrost-http/handlers/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -111,12 +112,86 @@ func (h *MCPHandler) getMCPClients(ctx *fasthttp.RequestCtx) {
return
}

limitStr := string(ctx.QueryArgs().Peek("limit"))
offsetStr := string(ctx.QueryArgs().Peek("offset"))
searchStr := string(ctx.QueryArgs().Peek("search"))
serverStr := string(ctx.QueryArgs().Peek("server"))
params := configstore.MCPClientsQueryParams{
Search: string(ctx.QueryArgs().Peek("search")),
ClientID: string(ctx.QueryArgs().Peek("server")),
ConnectionTypes: parseCommaSeparated(string(ctx.QueryArgs().Peek("connection_type"))),
AuthTypes: parseCommaSeparated(string(ctx.QueryArgs().Peek("auth_type"))),
VirtualKeyIDs: parseCommaSeparated(string(ctx.QueryArgs().Peek("virtual_keys"))),
}
if b, ok, err := parseBoolQueryArg(ctx, "all_virtual_keys"); err != nil {
SendError(ctx, 400, "Invalid all_virtual_keys parameter: must be a boolean")
return
} else if ok {
params.OnlyAllVirtualKeys = b
}
// Runtime state selection (connected/disconnected) — resolved against the
// live engine inside getMCPClientsPaginated since it isn't a DB column.
states := parseCommaSeparated(string(ctx.QueryArgs().Peek("state")))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
for _, s := range states {
if s != "connected" && s != "disconnected" {
SendError(ctx, 400, "Invalid state parameter: must be 'connected' or 'disconnected'")
return
}
}

if limitStr := string(ctx.QueryArgs().Peek("limit")); limitStr != "" {
n, err := strconv.Atoi(limitStr)
if err != nil {
SendError(ctx, 400, "Invalid limit parameter: must be a number")
return
}
if n < 0 {
SendError(ctx, 400, "Invalid limit parameter: must be non-negative")
return
}
params.Limit = n
}
if offsetStr := string(ctx.QueryArgs().Peek("offset")); offsetStr != "" {
n, err := strconv.Atoi(offsetStr)
if err != nil {
SendError(ctx, 400, "Invalid offset parameter: must be a number")
return
}
if n < 0 {
SendError(ctx, 400, "Invalid offset parameter: must be non-negative")
return
}
params.Offset = n
}
// Optional boolean facets — nil = no filter. Unparseable values are a hard
// error (like limit/offset) so a typo can't silently drop the filter.
if b, ok, err := parseBoolQueryArg(ctx, "code_mode"); err != nil {
SendError(ctx, 400, "Invalid code_mode parameter: must be a boolean")
return
} else if ok {
params.IsCodeModeClient = &b
}
if b, ok, err := parseBoolQueryArg(ctx, "disabled"); err != nil {
SendError(ctx, 400, "Invalid disabled parameter: must be a boolean")
return
} else if ok {
params.Disabled = &b
}

h.getMCPClientsPaginated(ctx, params, states)
}

h.getMCPClientsPaginated(ctx, limitStr, offsetStr, searchStr, serverStr)
// parseBoolQueryArg reads an optional boolean query parameter. It returns
// (value, true, nil) when the parameter is present and parses as a bool,
// (false, false, nil) when the parameter is absent (no filter), and
// (false, false, err) when present but unparseable — callers should surface
// the last case as an HTTP 400 rather than silently dropping the filter.
func parseBoolQueryArg(ctx *fasthttp.RequestCtx, key string) (bool, bool, error) {
raw := string(ctx.QueryArgs().Peek(key))
if raw == "" {
return false, false, nil
}
b, err := strconv.ParseBool(raw)
if err != nil {
return false, false, err
}
return b, true, nil
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// getMCPLibrary handles GET /api/mcp/library — paginated, searchable, filterable
Expand Down Expand Up @@ -245,56 +320,50 @@ func (h *MCPHandler) forceSyncMCPLibrary(ctx *fasthttp.RequestCtx) {
})
}

// getMCPClientsPaginated handles the paginated path for GET /api/mcp/clients
func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, limitStr, offsetStr, searchStr, serverStr string) {
params := configstore.MCPClientsQueryParams{
Search: searchStr,
ClientID: serverStr,
Limit: 100,
// getMCPClientsPaginated handles the paginated path for GET /api/mcp/clients.
// states carries the raw connection-state selection (connected/disconnected);
// it is resolved against the live engine here because state is not a DB column.
func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, params configstore.MCPClientsQueryParams, states []string) {
// Get connected clients from Bifrost engine — used both to resolve the
// runtime state filter and to merge live state/tools onto each row below.
clientsInBifrost, err := h.client.GetMCPClients()
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get MCP clients from Bifrost: %v", err))
return
}
if limitStr != "" {
n, err := strconv.Atoi(limitStr)
if err != nil {
SendError(ctx, 400, "Invalid limit parameter: must be a number")
return
}
if n < 0 {
SendError(ctx, 400, "Invalid limit parameter: must be non-negative")
return
}
params.Limit = n
connectedClientsMap := make(map[string]schemas.MCPClient)
for _, client := range clientsInBifrost {
connectedClientsMap[client.Config.ID] = client
}
if offsetStr != "" {
n, err := strconv.Atoi(offsetStr)
if err != nil {
SendError(ctx, 400, "Invalid offset parameter: must be a number")
return
}
if n < 0 {
SendError(ctx, 400, "Invalid offset parameter: must be non-negative")
return

// Resolve the runtime state filter into a connected-id allow/block list the
// store can apply within the same paginated query. "connected" means the
// engine reports MCPConnectionStateConnected; everything else (disconnected,
// error, disabled, not-in-engine) counts as disconnected. Selecting both —
// or neither — is a no-op.
if wantConnected, wantDisconnected := slices.Contains(states, "connected"), slices.Contains(states, "disconnected"); wantConnected != wantDisconnected {
connectedIDs := make([]string, 0, len(clientsInBifrost))
for _, c := range clientsInBifrost {
if c.State == schemas.MCPConnectionStateConnected {
connectedIDs = append(connectedIDs, c.Config.ID)
}
}
params.Offset = n
params.StateClientIDs = connectedIDs
params.StateInclude = &wantConnected
}

// Normalise pagination (0 → 25 default, cap 100) before the query so the
// echoed limit/offset match the rows actually returned — same helper every
// other paginated handler uses.
params.Limit, params.Offset = ClampPaginationParams(params.Limit, params.Offset)

dbClients, totalCount, err := h.store.ConfigStore.GetMCPClientsPaginated(ctx, params)
if err != nil {
logger.Error("failed to retrieve MCP clients: %v", err)
SendError(ctx, 500, "Failed to retrieve MCP clients")
return
}

// Get connected clients from Bifrost engine for state/tools merge
clientsInBifrost, err := h.client.GetMCPClients()
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to get MCP clients from Bifrost: %v", err))
return
}
connectedClientsMap := make(map[string]schemas.MCPClient)
for _, client := range clientsInBifrost {
connectedClientsMap[client.Config.ID] = client
}

// Batch-fetch all VK assignments for this page in a single query, then group by client ID.
vkNameByID := make(map[string]string)
assignmentsByClientID := make(map[uint][]configstoreTables.TableVirtualKeyMCPConfig)
Expand Down
Loading