项目文件夹

文件
Copilot ac47a4650a MCP gateway: add per-tool scopes, tracing, rate limiting, and audit logging (#2850)
* Initial plan

* Add MCP per-tool scopes, tracing, rate limiting, and audit logging

- Add Scopes field to Tool struct for per-tool scope requirements
- Add Auth (auth.Auth) integration to Options for token inspection
- Add trace ID generation (UUID) propagated via metadata to downstream RPCs
- Add per-tool rate limiting with configurable requests/sec and burst
- Add AuditFunc callback for immutable tool-call audit records
- Extract tool scopes from registry endpoint metadata ("scopes" key)
- Update both HTTP and stdio transports with auth/trace/rate/audit
- Add comprehensive tests for all new functionality

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* Revert unrelated example go.mod changes

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* Remove auto-generated example go.sum files

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* Add WithEndpointScopes helper, gateway-level ToolScopes, and documentation

- Add server.WithEndpointScopes() for declaring per-endpoint auth scopes at
  handler registration time
- Add mcp.Options.ToolScopes for gateway-level scope overrides without
  changing individual services
- Update documented example to show WithEndpointScopes usage
- Update examples/mcp/README.md with scopes, tracing, and rate-limiting docs
- Update gateway/mcp/DOCUMENTATION.md with scopes section and FAQ
- Add tests for both new features

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* Fix ToolScopes doc comment: clarify override (not merge) semantics

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* Revert unrelated example go.mod/go.sum changes

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* Rename ToolScopes to Scopes in MCP Options

The field name "Scopes" is more universal and consistent with how
auth scopes are used throughout go-micro. Updated all code references,
tests, and documentation.

Co-authored-by: asim <17530+asim@users.noreply.github.com>

* MCP gateway: add per-tool scopes, tracing, rate limiting, and audit logging

Co-authored-by: asim <17530+asim@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: asim <17530+asim@users.noreply.github.com>
2026-02-11 21:01:31 +00:00

631 行
17 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/client"
"go-micro.dev/v5/codec/bytes"
"go-micro.dev/v5/metadata"
"go-micro.dev/v5/registry"
"github.com/google/uuid"
)
// 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
// 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
}
// 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
}
// 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:"-"`
}
// 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),
}
// 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()
}
}
}
s.opts.Logger.Printf("[mcp] Discovered %d tools from %d services", len(s.tools), len(services))
return 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 transport
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)
s.server = &http.Server{
Addr: s.opts.Address,
Handler: mux,
}
s.opts.Logger.Printf("[mcp] MCP gateway listening on %s", 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()
// 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 == "" {
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 {
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
// Check per-tool scopes
if len(tool.Scopes) > 0 {
if !hasScope(account.Scopes, tool.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 {
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
}
// Build context with tracing metadata
ctx := r.Context()
md := metadata.Metadata{}
md.Set(TraceIDKey, traceID)
md.Set(ToolNameKey, req.Tool)
if account != nil {
md.Set(AccountIDKey, account.ID)
}
ctx = metadata.MergeContext(ctx, md, true)
// 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
start := time.Now()
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.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
}
// 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
}
// 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()
}
}