Files
micro--go-micro/gateway/mcp/mcp.go
T
wehub-resource-sync e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:33 +08:00

996 lines
29 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("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/v6/auth"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
"go-micro.dev/v6/wrapper/x402"
"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
// Payment, when set, requires an x402 payment for tool calls
// (the /mcp/call endpoint). Listing tools and health stay free.
// Opt-in: leave nil to disable payments.
Payment *x402.Config
// ReflectedGRPCTargets exposes unary methods from external gRPC servers
// that support server reflection as MCP tools. This bridges existing gRPC
// services into the agent tool catalog without requiring go-micro handlers.
ReflectedGRPCTargets []ReflectedGRPCTarget
}
// 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"`
// Payment advertises the x402 payment required to call this tool, so
// the catalog is shoppable — an agent sees the price before calling.
// Populated at list time when the gateway has payments enabled; nil
// means free.
Payment *PaymentInfo `json:"payment,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:"-"`
}
// PaymentInfo advertises, in the tool catalog, the x402 payment required
// to call a tool: how much, in what asset, on which network, and where it
// goes. It lets an agent shop the catalog and choose by price before
// calling.
type PaymentInfo struct {
Amount string `json:"amount"` // smallest units (e.g. "10000" = 0.01 USDC)
Network string `json:"network"`
Asset string `json:"asset,omitempty"`
PayTo string `json:"payTo"`
}
// paymentFor returns the catalog payment info for a tool, or nil if the
// gateway has no payments configured or the tool is free.
func (s *Server) paymentFor(toolName string) *PaymentInfo {
if s.opts.Payment == nil {
return nil
}
amount := s.opts.Payment.AmountFor(toolName)
if amount == "" || amount == "0" {
return nil
}
net := s.opts.Payment.Network
if net == "" {
net = "base"
}
return &PaymentInfo{
Amount: amount,
Network: net,
Asset: s.opts.Payment.Asset,
PayTo: s.opts.Payment.PayTo,
}
}
// 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()
if err := s.discoverReflectedGRPC(); err != nil {
return err
}
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. Tool calls can be gated behind an x402 payment
// (enforced per-tool inside handleCallTool); listing tools and health
// stay free.
if s.opts.Payment != nil {
net := s.opts.Payment.Network
if net == "" {
net = "base"
}
s.opts.Logger.Printf("[mcp] x402 payments enabled (network=%s, payTo=%s)", net, s.opts.Payment.PayTo)
}
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 {
// Attach payment info for the catalog. Copy when pricing so the
// shared tool struct isn't mutated.
if pay := s.paymentFor(tool.Name); pay != nil {
cp := *tool
cp.Payment = pay
tools = append(tools, &cp)
continue
}
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
}
// x402 payment gate: require the tool's amount before doing work.
// Free tools (amount "" or "0") pass through; Require writes the 402
// challenge itself when payment is missing or invalid.
if s.opts.Payment != nil {
if !s.opts.Payment.Require(w, r, s.opts.Payment.AmountFor(req.Tool), req.Tool) {
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 authorize
var account *auth.Account
if s.opts.Auth != nil {
token := r.Header.Get("Authorization")
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("myservice", )
// service.Init()
// Start MCP gateway
go func() {
if err := Serve(Options{
Registry: registry.DefaultRegistry,
Address: ":3000",
}); err != nil {
log.Fatal(err)
}
}()
// service.Run()
}
}