mirror of
https://github.com/go-micro/go-micro.git
synced 2026-06-15 19:35:13 +02:00
* 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>
919 lines
26 KiB
Go
919 lines
26 KiB
Go
// Package mcp provides Model Context Protocol (MCP) gateway functionality for go-micro services.
|
|
// It automatically exposes your microservices as AI-accessible tools through MCP.
|
|
//
|
|
// Example usage:
|
|
//
|
|
// service := micro.NewService(micro.Name("myservice"))
|
|
// service.Init()
|
|
//
|
|
// // Add MCP gateway
|
|
// go mcp.Serve(mcp.Options{
|
|
// Registry: service.Options().Registry,
|
|
// Address: ":3000",
|
|
// })
|
|
//
|
|
// service.Run()
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
// Metadata keys for MCP tracing and auth propagated via context/metadata.
|
|
const (
|
|
// TraceIDKey is the metadata key for the MCP trace ID.
|
|
TraceIDKey = "Mcp-Trace-Id"
|
|
// ToolNameKey is the metadata key for the tool being invoked.
|
|
ToolNameKey = "Mcp-Tool-Name"
|
|
// AccountIDKey is the metadata key for the authenticated account ID.
|
|
AccountIDKey = "Mcp-Account-Id"
|
|
)
|
|
|
|
// AuditRecord represents an immutable log entry for an MCP tool call.
|
|
type AuditRecord struct {
|
|
// TraceID uniquely identifies this tool call chain.
|
|
TraceID string `json:"trace_id"`
|
|
// Timestamp of the tool call.
|
|
Timestamp time.Time `json:"timestamp"`
|
|
// Tool is the name of the tool that was called.
|
|
Tool string `json:"tool"`
|
|
// AccountID is the ID of the authenticated account (empty if unauthenticated).
|
|
AccountID string `json:"account_id,omitempty"`
|
|
// Scopes that were required for this tool.
|
|
ScopesRequired []string `json:"scopes_required,omitempty"`
|
|
// Allowed indicates whether the call was authorized.
|
|
Allowed bool `json:"allowed"`
|
|
// Denied reason, if the call was not allowed.
|
|
DeniedReason string `json:"denied_reason,omitempty"`
|
|
// Duration of the RPC call (zero if call was denied before execution).
|
|
Duration time.Duration `json:"duration,omitempty"`
|
|
// Error from the RPC call, if any.
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// AuditFunc is called for every tool call with an audit record.
|
|
// Implementations should treat the record as immutable and persist it
|
|
// (e.g. to a log, database, or event stream).
|
|
type AuditFunc func(record AuditRecord)
|
|
|
|
// RateLimitConfig configures rate limiting for the MCP gateway.
|
|
type RateLimitConfig struct {
|
|
// Requests per second allowed per tool (0 = unlimited).
|
|
RequestsPerSecond float64
|
|
// Burst size (maximum number of requests that can be made at once).
|
|
Burst int
|
|
}
|
|
|
|
// Options configures the MCP gateway
|
|
type Options struct {
|
|
// Registry for service discovery (required)
|
|
Registry registry.Registry
|
|
|
|
// Address to listen on for SSE transport (e.g., ":3000")
|
|
// Leave empty for stdio transport
|
|
Address string
|
|
|
|
// Client for making RPC calls (defaults to client.DefaultClient)
|
|
Client client.Client
|
|
|
|
// Context for cancellation (defaults to background context)
|
|
Context context.Context
|
|
|
|
// Logger for debug output (defaults to log.Default())
|
|
Logger *log.Logger
|
|
|
|
// AuthFunc validates requests (optional, legacy)
|
|
// Return error to reject, nil to allow
|
|
AuthFunc func(r *http.Request) error
|
|
|
|
// Auth provider for token inspection (optional).
|
|
// When set, incoming requests must carry a Bearer token which is
|
|
// inspected to obtain an account. The account's scopes are then
|
|
// checked against the tool's required scopes.
|
|
Auth auth.Auth
|
|
|
|
// AuditFunc is called for every tool call with an immutable audit record.
|
|
// Use this to persist tool-call logs for compliance and debugging.
|
|
AuditFunc AuditFunc
|
|
|
|
// RateLimit configures per-tool rate limiting.
|
|
// When set, each tool is limited to the configured requests per second.
|
|
RateLimit *RateLimitConfig
|
|
|
|
// CircuitBreaker configures per-tool circuit breaking.
|
|
// When set, tools that fail repeatedly are temporarily blocked to
|
|
// protect downstream services from cascading failures.
|
|
CircuitBreaker *CircuitBreakerConfig
|
|
|
|
// Scopes lets the gateway operator define or override per-tool
|
|
// scope requirements without changing the services themselves.
|
|
// Keys are tool names (e.g. "blog.Blog.Create") and values are the
|
|
// required scopes. When a tool appears in Scopes its scopes
|
|
// replace any scopes declared by the service via endpoint metadata.
|
|
//
|
|
// Example:
|
|
//
|
|
// Scopes: map[string][]string{
|
|
// "blog.Blog.Create": {"blog:write"},
|
|
// "blog.Blog.Delete": {"blog:admin"},
|
|
// }
|
|
Scopes map[string][]string
|
|
|
|
// TraceProvider enables OpenTelemetry tracing for MCP tool calls.
|
|
// When set, each tool call creates a span with attributes for the
|
|
// tool name, account ID, auth outcome, and transport type.
|
|
// Trace context is propagated to downstream RPC calls via metadata.
|
|
//
|
|
// Example:
|
|
//
|
|
// tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
|
|
// mcp.Serve(mcp.Options{
|
|
// Registry: reg,
|
|
// TraceProvider: tp,
|
|
// })
|
|
TraceProvider trace.TracerProvider
|
|
}
|
|
|
|
// Server represents a running MCP gateway
|
|
type Server struct {
|
|
opts Options
|
|
tools map[string]*Tool
|
|
toolsMu sync.RWMutex
|
|
server *http.Server
|
|
watching bool
|
|
|
|
// limiters holds per-tool rate limiters (nil if rate limiting is disabled).
|
|
limiters map[string]*rateLimiter
|
|
limitersMu sync.RWMutex
|
|
|
|
// breakers holds per-tool circuit breakers (nil if circuit breaking is disabled).
|
|
breakers map[string]*circuitBreaker
|
|
breakersMu sync.RWMutex
|
|
}
|
|
|
|
// Tool represents an MCP tool (exposed service endpoint)
|
|
type Tool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema map[string]interface{} `json:"inputSchema"`
|
|
// Scopes lists the auth scopes required to call this tool.
|
|
// An empty list means no scope restriction (subject to Auth provider).
|
|
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.
|
|
// For stdio transport, leave Address empty.
|
|
// For SSE transport, set Address (e.g., ":3000").
|
|
func Serve(opts Options) error {
|
|
// Set defaults
|
|
if opts.Client == nil {
|
|
opts.Client = client.DefaultClient
|
|
}
|
|
if opts.Context == nil {
|
|
opts.Context = context.Background()
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = log.Default()
|
|
}
|
|
if opts.Registry == nil {
|
|
return fmt.Errorf("registry is required")
|
|
}
|
|
|
|
server := &Server{
|
|
opts: opts,
|
|
tools: make(map[string]*Tool),
|
|
limiters: make(map[string]*rateLimiter),
|
|
breakers: make(map[string]*circuitBreaker),
|
|
}
|
|
|
|
// Discover services and build tool list
|
|
if err := server.discoverServices(); err != nil {
|
|
return fmt.Errorf("failed to discover services: %w", err)
|
|
}
|
|
|
|
// Watch for service changes
|
|
go server.watchServices()
|
|
|
|
// Start server based on transport
|
|
if opts.Address != "" {
|
|
return server.serveHTTP()
|
|
}
|
|
return server.serveStdio()
|
|
}
|
|
|
|
// ListenAndServe is a convenience function that starts an MCP gateway on the given address.
|
|
func ListenAndServe(address string, opts Options) error {
|
|
opts.Address = address
|
|
return Serve(opts)
|
|
}
|
|
|
|
// discoverServices queries the registry and builds the tool list
|
|
func (s *Server) discoverServices() error {
|
|
services, err := s.opts.Registry.ListServices()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.toolsMu.Lock()
|
|
defer s.toolsMu.Unlock()
|
|
|
|
for _, svc := range services {
|
|
// Get full service details
|
|
fullSvcs, err := s.opts.Registry.GetService(svc.Name)
|
|
if err != nil || len(fullSvcs) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Convert endpoints to tools
|
|
for _, ep := range fullSvcs[0].Endpoints {
|
|
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
|
|
|
// Build input schema from endpoint request type
|
|
inputSchema := s.buildInputSchema(ep.Request)
|
|
|
|
// Get description from endpoint metadata (set by service during registration)
|
|
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
|
if ep.Metadata != nil {
|
|
if desc, ok := ep.Metadata["description"]; ok && desc != "" {
|
|
description = desc
|
|
}
|
|
}
|
|
|
|
tool := &Tool{
|
|
Name: toolName,
|
|
Description: description,
|
|
InputSchema: inputSchema,
|
|
Service: svc.Name,
|
|
Endpoint: ep.Name,
|
|
}
|
|
|
|
// Extract scopes from endpoint metadata
|
|
if ep.Metadata != nil {
|
|
if scopes, ok := ep.Metadata["scopes"]; ok && scopes != "" {
|
|
tool.Scopes = strings.Split(scopes, ",")
|
|
}
|
|
}
|
|
|
|
// Gateway-level Scopes override service-level scopes
|
|
if s.opts.Scopes != nil {
|
|
if scopes, ok := s.opts.Scopes[toolName]; ok {
|
|
tool.Scopes = scopes
|
|
}
|
|
}
|
|
|
|
// Add example from metadata if available
|
|
if ep.Metadata != nil {
|
|
if example, ok := ep.Metadata["example"]; ok && example != "" {
|
|
inputSchema["examples"] = []string{example}
|
|
}
|
|
}
|
|
|
|
s.tools[toolName] = tool
|
|
|
|
// Create rate limiter for this tool if rate limiting is configured
|
|
if s.opts.RateLimit != nil && s.opts.RateLimit.RequestsPerSecond > 0 {
|
|
s.limitersMu.Lock()
|
|
if _, exists := s.limiters[toolName]; !exists {
|
|
s.limiters[toolName] = newRateLimiter(
|
|
s.opts.RateLimit.RequestsPerSecond,
|
|
s.opts.RateLimit.Burst,
|
|
)
|
|
}
|
|
s.limitersMu.Unlock()
|
|
}
|
|
|
|
// Create circuit breaker for this tool if configured
|
|
if s.opts.CircuitBreaker != nil {
|
|
s.breakersMu.Lock()
|
|
if _, exists := s.breakers[toolName]; !exists {
|
|
s.breakers[toolName] = newCircuitBreaker(*s.opts.CircuitBreaker)
|
|
}
|
|
s.breakersMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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{}{
|
|
"type": "object",
|
|
"properties": make(map[string]interface{}),
|
|
}
|
|
|
|
if value == nil || len(value.Values) == 0 {
|
|
return schema
|
|
}
|
|
|
|
properties := schema["properties"].(map[string]interface{})
|
|
for _, field := range value.Values {
|
|
properties[field.Name] = map[string]interface{}{
|
|
"type": s.mapGoTypeToJSON(field.Type),
|
|
"description": fmt.Sprintf("%s field", field.Name),
|
|
}
|
|
}
|
|
|
|
return schema
|
|
}
|
|
|
|
// mapGoTypeToJSON maps Go types to JSON schema types
|
|
func (s *Server) mapGoTypeToJSON(goType string) string {
|
|
switch goType {
|
|
case "string":
|
|
return "string"
|
|
case "int", "int32", "int64", "uint", "uint32", "uint64":
|
|
return "integer"
|
|
case "float32", "float64":
|
|
return "number"
|
|
case "bool":
|
|
return "boolean"
|
|
default:
|
|
return "object"
|
|
}
|
|
}
|
|
|
|
// watchServices watches for service registry changes
|
|
func (s *Server) watchServices() {
|
|
if s.watching {
|
|
return
|
|
}
|
|
s.watching = true
|
|
|
|
watcher, err := s.opts.Registry.Watch()
|
|
if err != nil {
|
|
s.opts.Logger.Printf("[mcp] Failed to watch registry: %v", err)
|
|
return
|
|
}
|
|
defer watcher.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-s.opts.Context.Done():
|
|
return
|
|
default:
|
|
_, err := watcher.Next()
|
|
if err != nil {
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
|
|
// Rediscover services on any change
|
|
if err := s.discoverServices(); err != nil {
|
|
s.opts.Logger.Printf("[mcp] Failed to rediscover services: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// serveHTTP starts an HTTP server with SSE and WebSocket transports
|
|
func (s *Server) serveHTTP() error {
|
|
mux := http.NewServeMux()
|
|
|
|
// MCP endpoints
|
|
mux.HandleFunc("/mcp/tools", s.handleListTools)
|
|
mux.HandleFunc("/mcp/call", s.handleCallTool)
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
|
|
// WebSocket endpoint for bidirectional streaming
|
|
ws := NewWebSocketTransport(s)
|
|
mux.Handle("/mcp/ws", ws)
|
|
|
|
s.server = &http.Server{
|
|
Addr: s.opts.Address,
|
|
Handler: mux,
|
|
}
|
|
|
|
s.opts.Logger.Printf("[mcp] MCP gateway listening on %s (HTTP + WebSocket)", s.opts.Address)
|
|
return s.server.ListenAndServe()
|
|
}
|
|
|
|
// serveStdio starts stdio-based MCP server (for Claude Code, etc.)
|
|
func (s *Server) serveStdio() error {
|
|
transport := NewStdioTransport(s)
|
|
return transport.Serve()
|
|
}
|
|
|
|
// handleListTools returns the list of available tools
|
|
func (s *Server) handleListTools(w http.ResponseWriter, r *http.Request) {
|
|
if s.opts.AuthFunc != nil {
|
|
if err := s.opts.AuthFunc(r); err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.toolsMu.RLock()
|
|
tools := make([]*Tool, 0, len(s.tools))
|
|
for _, tool := range s.tools {
|
|
tools = append(tools, tool)
|
|
}
|
|
s.toolsMu.RUnlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"tools": tools,
|
|
})
|
|
}
|
|
|
|
// handleCallTool executes a tool (makes an RPC call)
|
|
func (s *Server) handleCallTool(w http.ResponseWriter, r *http.Request) {
|
|
if s.opts.AuthFunc != nil {
|
|
if err := s.opts.AuthFunc(r); err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse request
|
|
var req struct {
|
|
Tool string `json:"tool"`
|
|
Input map[string]interface{} `json:"input"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get tool info
|
|
s.toolsMu.RLock()
|
|
tool, exists := s.tools[req.Tool]
|
|
s.toolsMu.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "Tool not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Generate trace ID for this call
|
|
traceID := uuid.New().String()
|
|
|
|
// Start OTel span (noop if TraceProvider is nil)
|
|
ctx, span := s.startToolSpan(r.Context(), req.Tool, "http", traceID)
|
|
defer span.End()
|
|
|
|
// Authenticate and authorise
|
|
var account *auth.Account
|
|
if s.opts.Auth != nil {
|
|
token := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(token, "Bearer ") {
|
|
token = strings.TrimPrefix(token, "Bearer ")
|
|
}
|
|
if token == "" {
|
|
span.SetAttributes(attribute.Bool(AttrAuthAllowed, false), attribute.String(AttrAuthDeniedReason, "missing token"))
|
|
setSpanError(span, fmt.Errorf("missing token"))
|
|
s.audit(AuditRecord{TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool, Allowed: false, DeniedReason: "missing token"})
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
acc, err := s.opts.Auth.Inspect(token)
|
|
if err != nil {
|
|
span.SetAttributes(attribute.Bool(AttrAuthAllowed, false), attribute.String(AttrAuthDeniedReason, "invalid token"))
|
|
setSpanError(span, fmt.Errorf("invalid token"))
|
|
s.audit(AuditRecord{TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool, Allowed: false, DeniedReason: "invalid token"})
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
account = acc
|
|
span.SetAttributes(attribute.String(AttrAccountID, account.ID))
|
|
|
|
// Check per-tool scopes
|
|
if len(tool.Scopes) > 0 {
|
|
span.SetAttributes(attribute.StringSlice(AttrScopesRequired, tool.Scopes))
|
|
if !hasScope(account.Scopes, tool.Scopes) {
|
|
span.SetAttributes(attribute.Bool(AttrAuthAllowed, false), attribute.String(AttrAuthDeniedReason, "insufficient scopes"))
|
|
setSpanError(span, fmt.Errorf("insufficient scopes"))
|
|
s.audit(AuditRecord{
|
|
TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool,
|
|
AccountID: account.ID, ScopesRequired: tool.Scopes,
|
|
Allowed: false, DeniedReason: "insufficient scopes",
|
|
})
|
|
http.Error(w, "Forbidden: insufficient scopes", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rate limit check
|
|
if err := s.allowRate(req.Tool); err != nil {
|
|
span.SetAttributes(attribute.Bool(AttrRateLimited, true))
|
|
setSpanError(span, err)
|
|
accountID := ""
|
|
if account != nil {
|
|
accountID = account.ID
|
|
}
|
|
s.audit(AuditRecord{
|
|
TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool,
|
|
AccountID: accountID, Allowed: false, DeniedReason: "rate limited",
|
|
})
|
|
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
span.SetAttributes(attribute.Bool(AttrAuthAllowed, true))
|
|
|
|
// Circuit breaker check
|
|
if err := s.allowCircuit(req.Tool); err != nil {
|
|
span.SetAttributes(attribute.String("mcp.circuit_breaker", "open"))
|
|
setSpanError(span, err)
|
|
accountID := ""
|
|
if account != nil {
|
|
accountID = account.ID
|
|
}
|
|
s.audit(AuditRecord{
|
|
TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool,
|
|
AccountID: accountID, Allowed: false, DeniedReason: "circuit breaker open",
|
|
})
|
|
http.Error(w, "Service unavailable: circuit breaker open", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Build context with tracing metadata
|
|
// OTel trace context was already injected by startToolSpan; add MCP metadata.
|
|
md, _ := metadata.FromContext(ctx)
|
|
if md == nil {
|
|
md = make(metadata.Metadata)
|
|
}
|
|
md.Set(TraceIDKey, traceID)
|
|
md.Set(ToolNameKey, req.Tool)
|
|
if account != nil {
|
|
md.Set(AccountIDKey, account.ID)
|
|
}
|
|
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 {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Make RPC call
|
|
rpcReq := s.opts.Client.NewRequest(tool.Service, tool.Endpoint, &bytes.Frame{Data: inputBytes})
|
|
var rsp bytes.Frame
|
|
|
|
if err := s.opts.Client.Call(ctx, rpcReq, &rsp); err != nil {
|
|
s.recordCircuit(req.Tool, false)
|
|
setSpanError(span, err)
|
|
s.opts.Logger.Printf("[mcp] RPC call failed: %v", err)
|
|
accountID := ""
|
|
if account != nil {
|
|
accountID = account.ID
|
|
}
|
|
s.audit(AuditRecord{
|
|
TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool,
|
|
AccountID: accountID, ScopesRequired: tool.Scopes,
|
|
Allowed: true, Duration: time.Since(start), Error: err.Error(),
|
|
})
|
|
http.Error(w, fmt.Sprintf("RPC call failed: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.recordCircuit(req.Tool, true)
|
|
setSpanOK(span)
|
|
|
|
// Audit successful call
|
|
accountID := ""
|
|
if account != nil {
|
|
accountID = account.ID
|
|
}
|
|
s.audit(AuditRecord{
|
|
TraceID: traceID, Timestamp: time.Now(), Tool: req.Tool,
|
|
AccountID: accountID, ScopesRequired: tool.Scopes,
|
|
Allowed: true, Duration: time.Since(start),
|
|
})
|
|
|
|
// Return response with trace ID
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set(TraceIDKey, traceID)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"result": json.RawMessage(rsp.Data),
|
|
"trace_id": traceID,
|
|
})
|
|
}
|
|
|
|
// handleHealth returns gateway health status
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
s.toolsMu.RLock()
|
|
toolCount := len(s.tools)
|
|
s.toolsMu.RUnlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "ok",
|
|
"tools": toolCount,
|
|
})
|
|
}
|
|
|
|
// Stop gracefully shuts down the MCP gateway
|
|
func (s *Server) Stop() error {
|
|
if s.server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return s.server.Shutdown(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetTools returns the current list of available tools
|
|
func (s *Server) GetTools() []*Tool {
|
|
s.toolsMu.RLock()
|
|
defer s.toolsMu.RUnlock()
|
|
|
|
tools := make([]*Tool, 0, len(s.tools))
|
|
for _, tool := range s.tools {
|
|
tools = append(tools, tool)
|
|
}
|
|
return tools
|
|
}
|
|
|
|
// audit emits an audit record if an AuditFunc is configured.
|
|
func (s *Server) audit(record AuditRecord) {
|
|
if s.opts.AuditFunc != nil {
|
|
s.opts.AuditFunc(record)
|
|
}
|
|
}
|
|
|
|
// allowRate checks if the tool call is allowed under the configured rate limit.
|
|
// Returns nil if allowed, non-nil error if rate-limited.
|
|
func (s *Server) allowRate(toolName string) error {
|
|
if s.opts.RateLimit == nil {
|
|
return nil
|
|
}
|
|
s.limitersMu.RLock()
|
|
limiter, ok := s.limiters[toolName]
|
|
s.limitersMu.RUnlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !limiter.Allow() {
|
|
return fmt.Errorf("rate limit exceeded for tool %s", toolName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// allowCircuit checks if the tool call is allowed by the circuit breaker.
|
|
// Returns nil if allowed, non-nil error if the circuit is open.
|
|
func (s *Server) allowCircuit(toolName string) error {
|
|
if s.opts.CircuitBreaker == nil {
|
|
return nil
|
|
}
|
|
s.breakersMu.RLock()
|
|
cb, ok := s.breakers[toolName]
|
|
s.breakersMu.RUnlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return cb.Allow()
|
|
}
|
|
|
|
// recordCircuit records a success or failure for the tool's circuit breaker.
|
|
func (s *Server) recordCircuit(toolName string, success bool) {
|
|
if s.opts.CircuitBreaker == nil {
|
|
return
|
|
}
|
|
s.breakersMu.RLock()
|
|
cb, ok := s.breakers[toolName]
|
|
s.breakersMu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
if success {
|
|
cb.RecordSuccess()
|
|
} else {
|
|
cb.RecordFailure()
|
|
}
|
|
}
|
|
|
|
// hasScope checks if the account has at least one of the required scopes.
|
|
func hasScope(accountScopes, requiredScopes []string) bool {
|
|
for _, req := range requiredScopes {
|
|
for _, have := range accountScopes {
|
|
if strings.EqualFold(have, req) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Example shows how to use the MCP gateway in your code
|
|
func Example() {
|
|
// This function is never called - it's just documentation
|
|
_ = func() {
|
|
// In your service code:
|
|
// service := micro.NewService(micro.Name("myservice"))
|
|
// service.Init()
|
|
|
|
// Start MCP gateway
|
|
go func() {
|
|
if err := Serve(Options{
|
|
Registry: registry.DefaultRegistry,
|
|
Address: ":3000",
|
|
}); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
// service.Run()
|
|
}
|
|
}
|