mirror of
https://github.com/go-micro/go-micro.git
synced 2026-06-15 19:35:13 +02:00
Enhance CLI color output and expose framework primitives via API (#2924)
* feat: expose framework primitives via API gateway and MCP
Add registry, store, and broker as both HTTP routes and MCP tools
so AI agents and HTTP clients can inspect and operate the framework.
API gateway (/micro/* namespace):
GET /micro/registry List registered services
GET /micro/registry/{name} Describe a service
GET /micro/store List store keys
GET /micro/store/{key} Read a record
POST /micro/store/{key} Write a record
POST /micro/broker/{topic} Publish a message
MCP gateway (micro_* tool prefix):
micro_registry_list List services
micro_registry_get Describe a service
micro_store_list List keys
micro_store_read Read a record
micro_store_write Write a record
micro_broker_publish Publish a message
Framework tools use a Handler field on the MCP Tool struct for
direct dispatch (no RPC). Service tools continue to use RPC.
Rate limiters and circuit breakers are applied to framework
tools the same as service tools.
* fix: make framework internals opt-in on API and MCP gateways
Framework primitives (registry, broker, store) are now only
exposed when explicitly enabled:
API gateway: micro api --internal
MCP gateway: Options{Internal: true}
Off by default — user services are always exposed, framework
internals require the flag. Banner output only shows framework
routes when enabled.
* fix: always expose framework internals, gate by auth in production
Revert the --internal flag approach. Framework primitives (registry,
broker, store) are now always exposed:
- micro api: /micro/* routes always available (dev tool)
- MCP gateway: micro_* tools always registered. When Auth is
configured (production), they require micro:admin scope.
Without Auth (dev), they're open — same as all other tools.
This follows the existing pattern: micro run/api = dev (open),
micro server = production (auth + scopes). Framework internals
follow the same security model as user services.
Remove the Internal option from MCP Options. Remove --internal
flag from micro api.
Note: scope persistence depends on the store backend. The default
in-memory store does not survive restarts. Use MICRO_STORE=file
for persistent scopes in production.
* fix: correct DefaultStore comment — it's file-backed, not memory
* fix(server): don't recreate deleted admin user on restart
When the default admin account is deleted via the dashboard, set
a marker key (auth/.admin-deleted) in the store. On startup, skip
admin creation if the marker exists. This prevents the default
admin/micro credentials from reappearing after restart when the
user has intentionally removed them.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+103
-4
@@ -27,10 +27,12 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/broker"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -78,6 +80,9 @@ func run(c *cli.Context) error {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Framework primitives under /micro/
|
||||
registerFrameworkRoutes(mux)
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -136,10 +141,18 @@ func run(c *cli.Context) error {
|
||||
fmt.Printf(" Listening \033[36m%s\033[0m\n", addr)
|
||||
fmt.Println()
|
||||
fmt.Println(" Routes:")
|
||||
fmt.Println(" \033[32mGET\033[0m / List services")
|
||||
fmt.Println(" \033[32mGET\033[0m /{service} Describe a service")
|
||||
fmt.Println(" \033[33mPOST\033[0m /{service}/{endpoint} Call an endpoint")
|
||||
fmt.Println(" \033[32mGET\033[0m /health Health check")
|
||||
fmt.Println(" \033[32mGET\033[0m / List services")
|
||||
fmt.Println(" \033[32mGET\033[0m /{service} Describe a service")
|
||||
fmt.Println(" \033[33mPOST\033[0m /{service}/{endpoint} Call an endpoint")
|
||||
fmt.Println(" \033[32mGET\033[0m /health Health check")
|
||||
fmt.Println()
|
||||
fmt.Println(" Framework:")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/registry List registered services")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/registry/{name} Describe a service")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/store List store keys")
|
||||
fmt.Println(" \033[32mGET\033[0m /micro/store/{key} Read a record")
|
||||
fmt.Println(" \033[33mPOST\033[0m /micro/store/{key} Write a record")
|
||||
fmt.Println(" \033[33mPOST\033[0m /micro/broker/{topic} Publish a message")
|
||||
fmt.Println()
|
||||
|
||||
server := &http.Server{Addr: addr, Handler: mux}
|
||||
@@ -224,3 +237,89 @@ func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// registerFrameworkRoutes adds /micro/* routes for registry, broker, and store.
|
||||
func registerFrameworkRoutes(mux *http.ServeMux) {
|
||||
// Registry
|
||||
mux.HandleFunc("/micro/registry", func(w http.ResponseWriter, r *http.Request) {
|
||||
listServices(w)
|
||||
})
|
||||
mux.HandleFunc("/micro/registry/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/micro/registry/")
|
||||
if name == "" {
|
||||
listServices(w)
|
||||
return
|
||||
}
|
||||
describeService(w, name)
|
||||
})
|
||||
|
||||
// Store
|
||||
mux.HandleFunc("/micro/store", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
keys, err := store.List()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
})
|
||||
mux.HandleFunc("/micro/store/", func(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(r.URL.Path, "/micro/store/")
|
||||
if key == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
keys, _ := store.List()
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
records, err := store.Read(key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
writeError(w, http.StatusNotFound, "key not found")
|
||||
return
|
||||
}
|
||||
w.Write(records[0].Value)
|
||||
case http.MethodPost:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := store.Write(&store.Record{Key: key, Value: body}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "key": key})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "use GET or POST")
|
||||
}
|
||||
})
|
||||
|
||||
// Broker
|
||||
mux.HandleFunc("/micro/broker/", func(w http.ResponseWriter, r *http.Request) {
|
||||
topic := strings.TrimPrefix(r.URL.Path, "/micro/broker/")
|
||||
if topic == "" {
|
||||
writeError(w, http.StatusBadRequest, "topic required: /micro/broker/{topic}")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "use POST to publish")
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
b := broker.DefaultBroker
|
||||
if err := b.Connect(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "broker connect: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := b.Publish(topic, &broker.Message{Body: body}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "topic": topic})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1413,7 +1413,11 @@ You can generate tokens on the <a href='/auth/tokens'>Tokens page</a>.
|
||||
if del := r.FormValue("delete"); del != "" {
|
||||
// Delete user
|
||||
storeInst.Delete("auth/" + del)
|
||||
deleteUserTokens(storeInst, del) // Delete all JWT tokens for this user
|
||||
deleteUserTokens(storeInst, del)
|
||||
// Mark default admin as deleted so it won't be recreated on restart
|
||||
if del == "admin" {
|
||||
storeInst.Write(&store.Record{Key: "auth/.admin-deleted", Value: []byte("true")})
|
||||
}
|
||||
http.Redirect(w, r, "/auth/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -1627,11 +1631,15 @@ func initAuth() error {
|
||||
_, _ = os.ReadFile(privPath)
|
||||
_, _ = os.ReadFile(pubPath)
|
||||
storeInst := store.DefaultStore
|
||||
// --- Ensure default admin account exists ---
|
||||
// --- Ensure default admin account exists on first run ---
|
||||
// If the admin was explicitly deleted (marker key exists), don't recreate.
|
||||
adminID := "admin"
|
||||
adminPass := "micro"
|
||||
adminKey := "auth/" + adminID
|
||||
if recs, _ := storeInst.Read(adminKey); len(recs) == 0 {
|
||||
adminDeletedKey := "auth/.admin-deleted"
|
||||
if recs, _ := storeInst.Read(adminDeletedKey); len(recs) > 0 {
|
||||
// Admin was explicitly deleted — don't recreate
|
||||
} else if recs, _ := storeInst.Read(adminKey); len(recs) == 0 {
|
||||
// Hash the admin password with bcrypt
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
+178
-2
@@ -26,10 +26,12 @@ import (
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/auth"
|
||||
"go-micro.dev/v5/broker"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/metadata"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -178,6 +180,10 @@ type Tool struct {
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Service string `json:"-"`
|
||||
Endpoint string `json:"-"`
|
||||
// Handler is an optional direct handler for framework tools that don't
|
||||
// go through RPC. When set, handleCallTool calls this instead of making
|
||||
// an RPC request.
|
||||
Handler func(input map[string]interface{}) (interface{}, error) `json:"-"`
|
||||
}
|
||||
|
||||
// Serve starts an MCP gateway with the given options.
|
||||
@@ -312,10 +318,166 @@ func (s *Server) discoverServices() error {
|
||||
}
|
||||
}
|
||||
|
||||
s.opts.Logger.Printf("[mcp] Discovered %d tools from %d services", len(s.tools), len(services))
|
||||
// Register framework primitives as tools.
|
||||
// When Auth is configured, they require micro:admin scope.
|
||||
s.registerFrameworkTools()
|
||||
|
||||
s.opts.Logger.Printf("[mcp] Discovered %d tools from %d services (incl. framework)", len(s.tools), len(services))
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerFrameworkTools adds registry, broker, store, and config as MCP tools.
|
||||
func (s *Server) registerFrameworkTools() {
|
||||
addFramework := func(tool *Tool) {
|
||||
// When auth is configured, require micro:admin scope
|
||||
if s.opts.Auth != nil {
|
||||
tool.Scopes = []string{"micro:admin"}
|
||||
}
|
||||
s.tools[tool.Name] = tool
|
||||
if s.opts.RateLimit != nil && s.opts.RateLimit.RequestsPerSecond > 0 {
|
||||
s.limitersMu.Lock()
|
||||
if _, exists := s.limiters[tool.Name]; !exists {
|
||||
s.limiters[tool.Name] = newRateLimiter(s.opts.RateLimit.RequestsPerSecond, s.opts.RateLimit.Burst)
|
||||
}
|
||||
s.limitersMu.Unlock()
|
||||
}
|
||||
if s.opts.CircuitBreaker != nil {
|
||||
s.breakersMu.Lock()
|
||||
if _, exists := s.breakers[tool.Name]; !exists {
|
||||
s.breakers[tool.Name] = newCircuitBreaker(*s.opts.CircuitBreaker)
|
||||
}
|
||||
s.breakersMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
addFramework(&Tool{
|
||||
Name: "micro_registry_list",
|
||||
Description: "List all registered services in the service registry",
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
Handler: func(input map[string]interface{}) (interface{}, error) {
|
||||
services, err := s.opts.Registry.ListServices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, svc := range services {
|
||||
names = append(names, svc.Name)
|
||||
}
|
||||
return map[string]interface{}{"services": names}, nil
|
||||
},
|
||||
})
|
||||
|
||||
addFramework(&Tool{
|
||||
Name: "micro_registry_get",
|
||||
Description: "Get details for a registered service including nodes and endpoints",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "Service name"},
|
||||
},
|
||||
},
|
||||
Handler: func(input map[string]interface{}) (interface{}, error) {
|
||||
name, _ := input["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
services, err := s.opts.Registry.GetService(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return services, nil
|
||||
},
|
||||
})
|
||||
|
||||
addFramework(&Tool{
|
||||
Name: "micro_store_list",
|
||||
Description: "List keys in the data store",
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
Handler: func(input map[string]interface{}) (interface{}, error) {
|
||||
keys, err := store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"keys": keys}, nil
|
||||
},
|
||||
})
|
||||
|
||||
addFramework(&Tool{
|
||||
Name: "micro_store_read",
|
||||
Description: "Read a record from the data store by key",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"key": map[string]interface{}{"type": "string", "description": "Record key"},
|
||||
},
|
||||
},
|
||||
Handler: func(input map[string]interface{}) (interface{}, error) {
|
||||
key, _ := input["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("key is required")
|
||||
}
|
||||
records, err := store.Read(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return map[string]interface{}{"error": "not found"}, nil
|
||||
}
|
||||
return map[string]interface{}{"key": key, "value": string(records[0].Value)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
addFramework(&Tool{
|
||||
Name: "micro_store_write",
|
||||
Description: "Write a record to the data store",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"key": map[string]interface{}{"type": "string", "description": "Record key"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "Record value"},
|
||||
},
|
||||
},
|
||||
Handler: func(input map[string]interface{}) (interface{}, error) {
|
||||
key, _ := input["key"].(string)
|
||||
value, _ := input["value"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("key is required")
|
||||
}
|
||||
if err := store.Write(&store.Record{Key: key, Value: []byte(value)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"status": "ok", "key": key}, nil
|
||||
},
|
||||
})
|
||||
|
||||
addFramework(&Tool{
|
||||
Name: "micro_broker_publish",
|
||||
Description: "Publish a message to a broker topic",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"topic": map[string]interface{}{"type": "string", "description": "Topic name"},
|
||||
"message": map[string]interface{}{"type": "string", "description": "Message body"},
|
||||
},
|
||||
},
|
||||
Handler: func(input map[string]interface{}) (interface{}, error) {
|
||||
topic, _ := input["topic"].(string)
|
||||
message, _ := input["message"].(string)
|
||||
if topic == "" {
|
||||
return nil, fmt.Errorf("topic is required")
|
||||
}
|
||||
b := broker.DefaultBroker
|
||||
if err := b.Connect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.Publish(topic, &broker.Message{Body: []byte(message)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"status": "ok", "topic": topic}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildInputSchema converts registry value type information to JSON schema
|
||||
func (s *Server) buildInputSchema(value *registry.Value) map[string]interface{} {
|
||||
schema := map[string]interface{}{
|
||||
@@ -562,6 +724,21 @@ func (s *Server) handleCallTool(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ctx = metadata.NewContext(ctx, md)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Framework tools have a direct handler; service tools go through RPC.
|
||||
if tool.Handler != nil {
|
||||
result, err := tool.Handler(req.Input)
|
||||
if err != nil {
|
||||
setSpanError(span, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert input to JSON bytes for RPC call
|
||||
inputBytes, err := json.Marshal(req.Input)
|
||||
if err != nil {
|
||||
@@ -570,7 +747,6 @@ func (s *Server) handleCallTool(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Make RPC call
|
||||
start := time.Now()
|
||||
rpcReq := s.opts.Client.NewRequest(tool.Service, tool.Endpoint, &bytes.Frame{Data: inputBytes})
|
||||
var rsp bytes.Frame
|
||||
|
||||
|
||||
@@ -502,8 +502,8 @@ func TestDiscoverServices_RateLimiters(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(s.limiters) != 2 {
|
||||
t.Errorf("expected 2 limiters, got %d", len(s.limiters))
|
||||
if len(s.limiters) != len(s.tools) {
|
||||
t.Errorf("expected %d limiters (one per tool), got %d", len(s.tools), len(s.limiters))
|
||||
}
|
||||
for name := range s.tools {
|
||||
if _, ok := s.limiters[name]; !ok {
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
||||
var (
|
||||
// ErrNotFound is returned when a key doesn't exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// DefaultStore is the memory store.
|
||||
// DefaultStore is the file store (persists to ~/micro/store/).
|
||||
DefaultStore Store = NewStore()
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user