@@ -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.
0 commit comments