Skip to content

Commit 420d6d9

Browse files
Alexey Panfilovclaude
andcommitted
feat(adminapi): MCP tab — DB-backed server list + bridge file sync
MCP servers move from config/mcp.json to kv_settings key 'cfg.mcp.servers'. Startup prefers the DB value; falls back to the legacy file when the DB key is unset so existing deployments keep working without migration. New /mcp admin tab lists every configured server and exposes per-row edit (URL, type, headers, allow/deny tools) + delete, plus an "Add new server" form at the bottom. Saves POST to /mcp/{name}/set and always re-render the full page so the row reflects the persisted state. Claude Bridge sync: when MCP_BRIDGE_EXPORT_PATH is set, every save mirrors the list to that path in Claude Desktop format (\"mcpServers\" wrapper). Atomic write via tempfile+rename. The bridge re-reads the file on each claude -p call, so no restart on either side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 53af8d4 commit 420d6d9

7 files changed

Lines changed: 456 additions & 4 deletions

File tree

cmd/agent/main.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,32 @@ func main() {
224224
// runtime role changes (admin UI) take effect without a restart.
225225
compacter := agent.NewCompacter(router)
226226

227-
// Init MCP client
227+
// Init MCP client. DB (kv_settings) wins over the legacy mcp.json file —
228+
// the admin UI writes there, so any user-edited list is authoritative.
228229
var mcpClient *mcp.Client
229-
mcpServers, err := config.LoadMCPServers("config/mcp.json")
230-
if err != nil {
231-
logger.Warn("failed to load mcp.json", "err", err)
230+
var mcpServers map[string]config.MCPServerConfig
231+
var mcpSource string
232+
if ss, ok := s.(llm.SettingsStore); ok {
233+
mcpCtx, mcpCancel := context.WithTimeout(context.Background(), 3*time.Second)
234+
if dbServers, found, dbErr := adminapi.LoadMCPServersFromSettings(mcpCtx, ss); dbErr != nil {
235+
logger.Warn("failed to load MCP servers from DB; falling back to file", "err", dbErr)
236+
} else if found {
237+
mcpServers = dbServers
238+
mcpSource = "db"
239+
}
240+
mcpCancel()
241+
}
242+
if mcpServers == nil {
243+
fileServers, err := config.LoadMCPServers("config/mcp.json")
244+
if err != nil {
245+
logger.Warn("failed to load mcp.json", "err", err)
246+
}
247+
mcpServers = fileServers
248+
if fileServers != nil {
249+
mcpSource = "file"
250+
}
232251
}
252+
logger.Info("MCP config loaded", "servers", len(mcpServers), "source", mcpSource)
233253
if len(mcpServers) > 0 {
234254
mcpClient = mcp.NewClient(mcpServers, logger)
235255
initCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)

internal/adminapi/handlers.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"os"
78
"sort"
89
"strings"
910
"time"
1011

12+
"telegram-agent/internal/config"
1113
"telegram-agent/internal/llm"
1214
)
1315

@@ -661,6 +663,225 @@ func (s *Server) firstGeminiAPIKey() string {
661663
return ""
662664
}
663665

666+
// --- MCP page ---
667+
668+
type uiMCPRow struct {
669+
Name string
670+
URL string
671+
Headers string // JSON-serialised, one per line, for display/edit in a textarea
672+
AllowTools string // comma-separated
673+
DenyTools string // comma-separated
674+
Type string
675+
}
676+
677+
type uiMCPData struct {
678+
ActiveTab string
679+
Servers []uiMCPRow
680+
BridgeExport string // MCP_BRIDGE_EXPORT_PATH if set, shown as a banner note
681+
SavedName string // non-empty after a successful save — used by template to flash a success message
682+
}
683+
684+
// loadMCPForUI returns the current MCP list flattened into UI rows. Prefers
685+
// the DB list; falls back to the legacy mcp.json file so users landing on
686+
// the page for the first time see their existing config instead of an
687+
// empty table.
688+
func (s *Server) loadMCPForUI(ctx context.Context) map[string]config.MCPServerConfig {
689+
if servers, found, _ := LoadMCPServersFromSettings(ctx, s.settings); found {
690+
return servers
691+
}
692+
// File fallback — best effort.
693+
if servers, err := config.LoadMCPServers("config/mcp.json"); err == nil {
694+
return servers
695+
}
696+
return nil
697+
}
698+
699+
func mcpToRows(servers map[string]config.MCPServerConfig) []uiMCPRow {
700+
names := sortedMCPServerNames(servers)
701+
out := make([]uiMCPRow, 0, len(names))
702+
for _, n := range names {
703+
sv := servers[n]
704+
hdrLines := make([]string, 0, len(sv.Headers))
705+
hdrKeys := make([]string, 0, len(sv.Headers))
706+
for k := range sv.Headers {
707+
hdrKeys = append(hdrKeys, k)
708+
}
709+
sort.Strings(hdrKeys)
710+
for _, k := range hdrKeys {
711+
hdrLines = append(hdrLines, k+": "+sv.Headers[k])
712+
}
713+
out = append(out, uiMCPRow{
714+
Name: n,
715+
URL: sv.URL,
716+
Type: sv.Type,
717+
Headers: strings.Join(hdrLines, "\n"),
718+
AllowTools: strings.Join(sv.AllowTools, ","),
719+
DenyTools: strings.Join(sv.DenyTools, ","),
720+
})
721+
}
722+
return out
723+
}
724+
725+
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
726+
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
727+
defer cancel()
728+
data := uiMCPData{
729+
ActiveTab: "mcp",
730+
Servers: mcpToRows(s.loadMCPForUI(ctx)),
731+
BridgeExport: os.Getenv("MCP_BRIDGE_EXPORT_PATH"),
732+
}
733+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
734+
if err := render(w, viewMCP, data); err != nil {
735+
s.logger.Error("render mcp", "err", err)
736+
http.Error(w, "render error", http.StatusInternalServerError)
737+
}
738+
}
739+
740+
// parseHeadersText splits "Key: value" lines into a map. Blank lines are
741+
// ignored; malformed lines are silently dropped so a UI save with partial
742+
// typing doesn't wipe the entry.
743+
func parseHeadersText(text string) map[string]string {
744+
out := map[string]string{}
745+
for _, line := range strings.Split(text, "\n") {
746+
line = strings.TrimSpace(line)
747+
if line == "" {
748+
continue
749+
}
750+
k, v, ok := strings.Cut(line, ":")
751+
if !ok {
752+
continue
753+
}
754+
k = strings.TrimSpace(k)
755+
v = strings.TrimSpace(v)
756+
if k == "" {
757+
continue
758+
}
759+
out[k] = v
760+
}
761+
if len(out) == 0 {
762+
return nil
763+
}
764+
return out
765+
}
766+
767+
// splitCSV turns "a,b, c" into ["a","b","c"], trimming and dropping blanks.
768+
func splitCSV(s string) []string {
769+
if strings.TrimSpace(s) == "" {
770+
return nil
771+
}
772+
parts := strings.Split(s, ",")
773+
out := make([]string, 0, len(parts))
774+
for _, p := range parts {
775+
if t := strings.TrimSpace(p); t != "" {
776+
out = append(out, t)
777+
}
778+
}
779+
if len(out) == 0 {
780+
return nil
781+
}
782+
return out
783+
}
784+
785+
// handleMCPSet: POST /mcp/{name}/set with body url, headers, allow_tools,
786+
// deny_tools, type. Upserts the named server in the DB-backed list. Writes
787+
// the bridge mirror file on success.
788+
func (s *Server) handleMCPSet(w http.ResponseWriter, r *http.Request) {
789+
if r.Method != http.MethodPost {
790+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
791+
return
792+
}
793+
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/mcp/"), "/set")
794+
name = strings.TrimSpace(name)
795+
if name == "" || strings.ContainsAny(name, "/ \t\n") {
796+
http.Error(w, "invalid server name", http.StatusBadRequest)
797+
return
798+
}
799+
if err := r.ParseForm(); err != nil {
800+
http.Error(w, "parse form", http.StatusBadRequest)
801+
return
802+
}
803+
if s.settings == nil {
804+
http.Error(w, "settings store not available", http.StatusServiceUnavailable)
805+
return
806+
}
807+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
808+
defer cancel()
809+
810+
current := s.loadMCPForUI(ctx)
811+
if current == nil {
812+
current = map[string]config.MCPServerConfig{}
813+
}
814+
url := strings.TrimSpace(r.FormValue("url"))
815+
if url == "" {
816+
http.Error(w, "url required", http.StatusBadRequest)
817+
return
818+
}
819+
current[name] = config.MCPServerConfig{
820+
Type: strings.TrimSpace(r.FormValue("type")),
821+
URL: url,
822+
Headers: parseHeadersText(r.FormValue("headers")),
823+
AllowTools: splitCSV(r.FormValue("allow_tools")),
824+
DenyTools: splitCSV(r.FormValue("deny_tools")),
825+
}
826+
if err := saveMCPServers(ctx, s.settings, current); err != nil {
827+
s.logger.Warn("mcp save failed", "name", name, "err", err)
828+
http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)
829+
return
830+
}
831+
s.logger.Info("mcp server updated", "name", name, "url", url)
832+
// Re-render the page so the new row shows up.
833+
data := uiMCPData{
834+
ActiveTab: "mcp",
835+
Servers: mcpToRows(current),
836+
BridgeExport: os.Getenv("MCP_BRIDGE_EXPORT_PATH"),
837+
SavedName: name,
838+
}
839+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
840+
if err := render(w, viewMCP, data); err != nil {
841+
s.logger.Error("render mcp after save", "err", err)
842+
}
843+
}
844+
845+
// handleMCPDelete: POST /mcp/{name}/delete. Removes the named server.
846+
func (s *Server) handleMCPDelete(w http.ResponseWriter, r *http.Request) {
847+
if r.Method != http.MethodPost {
848+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
849+
return
850+
}
851+
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/mcp/"), "/delete")
852+
name = strings.TrimSpace(name)
853+
if name == "" {
854+
http.Error(w, "name required", http.StatusBadRequest)
855+
return
856+
}
857+
if s.settings == nil {
858+
http.Error(w, "settings store not available", http.StatusServiceUnavailable)
859+
return
860+
}
861+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
862+
defer cancel()
863+
864+
current := s.loadMCPForUI(ctx)
865+
if _, ok := current[name]; !ok {
866+
http.Error(w, "unknown server: "+name, http.StatusNotFound)
867+
return
868+
}
869+
delete(current, name)
870+
if err := saveMCPServers(ctx, s.settings, current); err != nil {
871+
s.logger.Warn("mcp delete failed", "name", name, "err", err)
872+
http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)
873+
return
874+
}
875+
s.logger.Info("mcp server deleted", "name", name)
876+
data := uiMCPData{
877+
ActiveTab: "mcp",
878+
Servers: mcpToRows(current),
879+
BridgeExport: os.Getenv("MCP_BRIDGE_EXPORT_PATH"),
880+
}
881+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
882+
_ = render(w, viewMCP, data)
883+
}
884+
664885
// --- Settings page ---
665886

666887
// settingSpec describes one user-editable scalar for the Settings tab.

internal/adminapi/mcp.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package adminapi
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"strings"
11+
12+
"telegram-agent/internal/config"
13+
"telegram-agent/internal/llm"
14+
)
15+
16+
// SettingKeyMCPServers is the kv_settings key holding the current MCP
17+
// server list (JSON-encoded map[name]MCPServerConfig). Source of truth —
18+
// whatever the admin UI writes wins over the legacy mcp.json file at
19+
// startup.
20+
const SettingKeyMCPServers = "cfg.mcp.servers"
21+
22+
// LoadMCPServersFromSettings reads the DB-backed MCP list. Returns (nil,
23+
// false, nil) when no override is set so the caller can fall back to the
24+
// legacy file. Returns an error only on corrupted JSON — missing value is
25+
// not an error.
26+
func LoadMCPServersFromSettings(ctx context.Context, s llm.SettingsStore) (map[string]config.MCPServerConfig, bool, error) {
27+
if s == nil {
28+
return nil, false, nil
29+
}
30+
v, ok, err := s.GetSetting(ctx, SettingKeyMCPServers)
31+
if err != nil || !ok || strings.TrimSpace(v) == "" {
32+
return nil, false, err
33+
}
34+
var out map[string]config.MCPServerConfig
35+
if err := json.Unmarshal([]byte(v), &out); err != nil {
36+
return nil, true, fmt.Errorf("parse mcp servers from settings: %w", err)
37+
}
38+
return out, true, nil
39+
}
40+
41+
// saveMCPServers writes the full MCP list to kv_settings. Replaces the
42+
// entire value — the admin UI always sends a complete picture. Also
43+
// mirrors the list to MCP_BRIDGE_EXPORT_PATH when set (for claude-bridge).
44+
func saveMCPServers(ctx context.Context, s llm.SettingsStore, servers map[string]config.MCPServerConfig) error {
45+
if s == nil {
46+
return fmt.Errorf("settings store not available")
47+
}
48+
data, err := json.Marshal(servers)
49+
if err != nil {
50+
return err
51+
}
52+
if err := s.PutSetting(ctx, SettingKeyMCPServers, string(data)); err != nil {
53+
return err
54+
}
55+
// Best-effort mirror for claude-bridge — failures are logged upstream
56+
// but don't roll back the DB write.
57+
if path := os.Getenv("MCP_BRIDGE_EXPORT_PATH"); path != "" {
58+
if err := writeBridgeMCPFile(path, servers); err != nil {
59+
return fmt.Errorf("mirror to bridge file: %w", err)
60+
}
61+
}
62+
return nil
63+
}
64+
65+
// writeBridgeMCPFile renders the MCP server map in Claude Desktop format
66+
// (wrapped in {"mcpServers": {...}}) and writes it atomically. The bridge
67+
// reads this file once per `claude -p` invocation so no restart is needed
68+
// on either side.
69+
func writeBridgeMCPFile(path string, servers map[string]config.MCPServerConfig) error {
70+
wrapper := map[string]any{"mcpServers": servers}
71+
data, err := json.MarshalIndent(wrapper, "", " ")
72+
if err != nil {
73+
return err
74+
}
75+
// Ensure parent dir exists — the first deploy may run before the path is created.
76+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
77+
return err
78+
}
79+
// Atomic write via tempfile + rename so claude-bridge never observes a
80+
// half-written file.
81+
tmp := path + ".tmp"
82+
if err := os.WriteFile(tmp, data, 0o644); err != nil {
83+
return err
84+
}
85+
return os.Rename(tmp, path)
86+
}
87+
88+
// sortedMCPServerNames returns map keys sorted alphabetically for stable UI
89+
// rendering.
90+
func sortedMCPServerNames(m map[string]config.MCPServerConfig) []string {
91+
names := make([]string, 0, len(m))
92+
for k := range m {
93+
names = append(names, k)
94+
}
95+
sort.Strings(names)
96+
return names
97+
}

0 commit comments

Comments
 (0)