feat: centrally mount and dispatch agent MCP services
This commit is contained in:
@@ -76,6 +76,157 @@ func (s *Server) AuditsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, logs)
|
||||
}
|
||||
|
||||
type MCPServiceRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Transport string `json:"transport"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Scope string `json:"scope"`
|
||||
TargetNodes []string `json:"target_nodes"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type MCPCallRequest struct {
|
||||
Device string `json:"device"`
|
||||
Server string `json:"server"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments any `json:"arguments"`
|
||||
}
|
||||
|
||||
func (s *Server) MCPServicesJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Store == nil {
|
||||
http.Error(w, "storage unavailable", 500)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
v, err := s.Store.ListMCPServices()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
jsonOut(w, v)
|
||||
case http.MethodPost:
|
||||
var q MCPServiceRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&q); err != nil {
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
if q.ID == "" || q.Name == "" || q.Transport == "stdio" && q.Command == "" || (q.Transport != "stdio" && q.URL == "") {
|
||||
http.Error(w, "invalid MCP service", 400)
|
||||
return
|
||||
}
|
||||
args, _ := json.Marshal(q.Args)
|
||||
env, _ := json.Marshal(q.Env)
|
||||
headers, _ := json.Marshal(q.Headers)
|
||||
nodes, _ := json.Marshal(q.TargetNodes)
|
||||
if q.Scope == "" {
|
||||
q.Scope = "all"
|
||||
}
|
||||
if q.Scope != "all" && q.Scope != "custom" {
|
||||
http.Error(w, "invalid scope", 400)
|
||||
return
|
||||
}
|
||||
v := storage.MCPService{ID: q.ID, Name: q.Name, Description: q.Description, Transport: q.Transport, Command: q.Command, ArgsJSON: string(args), EnvJSON: string(env), URL: q.URL, HeadersJSON: string(headers), Scope: q.Scope, TargetNodesJSON: string(nodes), Enabled: q.Enabled}
|
||||
if err := s.Store.SaveMCPService(v); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if s.Cluster != nil {
|
||||
s.Cluster.SyncMCPServices()
|
||||
}
|
||||
_ = s.Store.Audit(clientIP(r), "", "mcp_service_save", q.ID, 0, 0, "")
|
||||
jsonOut(w, v)
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
}
|
||||
func (s *Server) DeleteMCPService(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "id required", 400)
|
||||
return
|
||||
}
|
||||
if err := s.Store.DeleteMCPService(id); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if s.Cluster != nil {
|
||||
s.Cluster.SyncMCPServices()
|
||||
}
|
||||
_ = s.Store.Audit(clientIP(r), "", "mcp_service_delete", id, 0, 0, "")
|
||||
jsonOut(w, map[string]any{"ok": true})
|
||||
}
|
||||
func (s *Server) ListAgentMCPJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
device := r.URL.Query().Get("device")
|
||||
if device == "" {
|
||||
http.Error(w, "device required", 400)
|
||||
return
|
||||
}
|
||||
if s.isRemote(device) && (s.Cluster == nil || !s.Cluster.Online(device)) {
|
||||
http.Error(w, "device offline", 409)
|
||||
return
|
||||
}
|
||||
if s.isRemote(device) {
|
||||
v, err := s.Cluster.ListMCP(device)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
jsonOut(w, map[string]any{"device": device, "services": v})
|
||||
return
|
||||
}
|
||||
jsonOut(w, map[string]any{"device": s.LocalName, "services": []any{}})
|
||||
}
|
||||
func (s *Server) CallAgentMCPJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var q MCPCallRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024)).Decode(&q); err != nil {
|
||||
http.Error(w, "bad json", 400)
|
||||
return
|
||||
}
|
||||
if q.Device == "" || q.Server == "" || q.Tool == "" {
|
||||
http.Error(w, "device, server and tool required", 400)
|
||||
return
|
||||
}
|
||||
if s.isRemote(q.Device) && (s.Cluster == nil || !s.Cluster.Online(q.Device)) {
|
||||
if s.Store != nil {
|
||||
_ = s.Store.Audit(clientIP(r), q.Device, "mcp_call", q.Server+"/"+q.Tool, 1, 0, "device offline")
|
||||
}
|
||||
http.Error(w, "device offline", 409)
|
||||
return
|
||||
}
|
||||
if !s.isRemote(q.Device) {
|
||||
http.Error(w, "agent MCP requires a remote device", 400)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
v, err := s.Cluster.CallMCP(r.Context(), q.Device, q.Server, q.Tool, q.Arguments)
|
||||
ms := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
_ = s.Store.Audit(clientIP(r), q.Device, "mcp_call", q.Server+"/"+q.Tool, 1, ms, err.Error())
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
_ = s.Store.Audit(clientIP(r), q.Device, "mcp_call", q.Server+"/"+q.Tool, 0, ms, "")
|
||||
jsonOut(w, v)
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.health)
|
||||
@@ -83,6 +234,10 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("/api/v1/exec", s.exec)
|
||||
mux.HandleFunc("/api/v1/file/read", s.fileRead)
|
||||
mux.HandleFunc("/api/v1/file/write", s.fileWrite)
|
||||
mux.HandleFunc("/api/v1/mcp/services", s.MCPServicesJSON)
|
||||
mux.HandleFunc("/api/v1/mcp/service", s.DeleteMCPService)
|
||||
mux.HandleFunc("/api/v1/mcp/list", s.ListAgentMCPJSON)
|
||||
mux.HandleFunc("/api/v1/mcp/call", s.CallAgentMCPJSON)
|
||||
mux.HandleFunc("/agent/ws", s.agentWS)
|
||||
mux.HandleFunc("/openapi.json", s.openapi)
|
||||
return mux
|
||||
|
||||
@@ -188,3 +188,38 @@ func (b *MCPBackend) WriteFile(ctx context.Context, clientIP, device, path, cont
|
||||
Bytes: n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *MCPBackend) ListAgentMCP(ctx context.Context, device string) (any, error) {
|
||||
if device == "" {
|
||||
return nil, errors.New("device required")
|
||||
}
|
||||
if b.server.isRemote(device) && (b.server.Cluster == nil || !b.server.Cluster.Online(device)) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
if !b.server.isRemote(device) {
|
||||
return map[string]any{"device": b.server.LocalName, "services": []any{}}, nil
|
||||
}
|
||||
return b.server.Cluster.ListMCP(device)
|
||||
}
|
||||
func (b *MCPBackend) CallAgentMCP(ctx context.Context, clientIP, device, server, tool string, args any) (any, error) {
|
||||
if device == "" || server == "" || tool == "" {
|
||||
return nil, errors.New("device, server and tool required")
|
||||
}
|
||||
if b.server.isRemote(device) && (b.server.Cluster == nil || !b.server.Cluster.Online(device)) {
|
||||
return nil, fmt.Errorf("device %s is offline", device)
|
||||
}
|
||||
if !b.server.isRemote(device) {
|
||||
return nil, errors.New("agent MCP requires a remote device")
|
||||
}
|
||||
start := time.Now()
|
||||
v, err := b.server.Cluster.CallMCP(ctx, device, server, tool, args)
|
||||
ms := time.Since(start).Milliseconds()
|
||||
if b.server.Store != nil {
|
||||
if err != nil {
|
||||
_ = b.server.Store.Audit(clientIP, device, "mcp_call", server+"/"+tool, 1, ms, err.Error())
|
||||
} else {
|
||||
_ = b.server.Store.Audit(clientIP, device, "mcp_call", server+"/"+tool, 0, ms, "")
|
||||
}
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
@@ -157,6 +157,69 @@
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MCPService": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"transport": {
|
||||
"type": "string"
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"args_json": {
|
||||
"type": "string"
|
||||
},
|
||||
"env_json": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"headers_json": {
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_nodes_json": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MCPCallRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"device",
|
||||
"server",
|
||||
"tool"
|
||||
],
|
||||
"properties": {
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool": {
|
||||
"type": "string"
|
||||
},
|
||||
"arguments": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -281,6 +344,135 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/mcp/services": {
|
||||
"get": {
|
||||
"operationId": "list_mcp_services",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "MCP services",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MCPService"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "save_mcp_service",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MCPService"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Saved MCP service"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/mcp/service": {
|
||||
"delete": {
|
||||
"operationId": "delete_mcp_service",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/mcp/list": {
|
||||
"get": {
|
||||
"operationId": "list_agent_mcp",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "device",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Mounted MCP services"
|
||||
},
|
||||
"409": {
|
||||
"description": "Device offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/mcp/call": {
|
||||
"post": {
|
||||
"operationId": "call_agent_mcp",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MCPCallRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "MCP result"
|
||||
},
|
||||
"409": {
|
||||
"description": "Device offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user