internal/website/docs/guides/mcp-security.md
This guide covers how to secure your MCP gateway for production use, including authentication, per-tool scopes, rate limiting, and audit logging.
The MCP gateway provides four layers of security:
The MCP gateway uses bearer token authentication. Tokens are validated by the configured auth.Auth provider.
import (
"go-micro.dev/v5/gateway/mcp"
"go-micro.dev/v5/auth"
)
gateway := mcp.ListenAndServe(":3000", mcp.Options{
Registry: service.Options().Registry,
Auth: authProvider, // auth.Auth implementation
})
Agents pass tokens in the Authorization header:
curl -X POST http://localhost:3000/mcp/call \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"tool": "tasks.TaskService.Create", "input": {"title": "New task"}}'
When using micro run or micro server, authentication is handled automatically:
micro run): Auth is disabled by default for easy developmentmicro server): JWT auth is enabled with user management at /authCreate tokens with specific scopes via the dashboard at /auth/tokens.
Scopes control which tools a token can access. There are two ways to set scopes.
Set scopes when registering your handler. These travel with the service through the registry:
handler := service.Server().NewHandler(
new(TaskService),
server.WithEndpointScopes("TaskService.Get", "tasks:read"),
server.WithEndpointScopes("TaskService.List", "tasks:read"),
server.WithEndpointScopes("TaskService.Create", "tasks:write"),
server.WithEndpointScopes("TaskService.Update", "tasks:write"),
server.WithEndpointScopes("TaskService.Delete", "tasks:admin"),
)
Override or add scopes at the gateway without modifying services. Gateway scopes take precedence:
mcp.ListenAndServe(":3000", mcp.Options{
Registry: reg,
Auth: authProvider,
Scopes: map[string][]string{
"tasks.TaskService.Create": {"tasks:write"},
"tasks.TaskService.Delete": {"tasks:admin"},
"billing.Billing.Charge": {"billing:admin"},
},
})
When a tool is called:
* has unrestricted access (admin)403 Forbidden| Pattern | Use Case |
|---|---|
service:read | Read-only access to a service |
service:write | Create and update operations |
service:admin | Delete and destructive operations |
* | Full admin access (use sparingly) |
internal | Internal-only tools not exposed to external agents |
Token A: scopes=["tasks:read"]
✅ Can call TaskService.Get, TaskService.List
❌ Cannot call TaskService.Create, TaskService.Delete
Token B: scopes=["tasks:read", "tasks:write"]
✅ Can call Get, List, Create, Update
❌ Cannot call TaskService.Delete (needs tasks:admin)
Token C: scopes=["*"]
✅ Can call everything (admin)
Prevent abuse with per-tool rate limiting using a token bucket algorithm:
mcp.ListenAndServe(":3000", mcp.Options{
Registry: reg,
RateLimit: &mcp.RateLimitConfig{
RequestsPerSecond: 10, // Sustained rate
Burst: 20, // Allow bursts up to 20
},
})
When the rate limit is exceeded, calls return 429 Too Many Requests.
| Service Type | Requests/sec | Burst | Rationale |
|---|---|---|---|
| Read-heavy API | 50 | 100 | High throughput, low cost |
| Write API | 10 | 20 | Moderate, prevents spam |
| Expensive operation | 2 | 5 | Protect downstream resources |
| Internal tool | 100 | 200 | Trusted callers, higher limits |
Record every tool call for compliance, debugging, and analytics:
mcp.ListenAndServe(":3000", mcp.Options{
Registry: reg,
Auth: authProvider,
AuditFunc: func(record mcp.AuditRecord) {
log.Printf("[AUDIT] tool=%s account=%s allowed=%v duration=%v err=%v",
record.Tool,
record.AccountID,
record.Allowed,
record.Duration,
record.Error,
)
},
})
| Field | Type | Description |
|---|---|---|
Tool | string | Full tool name (e.g., tasks.TaskService.Create) |
AccountID | string | Caller's account ID from the auth token |
Scopes | []string | Scopes on the caller's token |
Allowed | bool | Whether the call was permitted |
Duration | time.Duration | How long the call took |
Error | error | Error if the call failed |
TraceID | string | UUID trace ID for correlation |
DeniedReason | string | Why the call was denied (empty if allowed) |
For production, send audit records to a structured logging system:
AuditFunc: func(r mcp.AuditRecord) {
// Structured JSON logging
logger.Info("mcp_tool_call",
"tool", r.Tool,
"account", r.AccountID,
"allowed", r.Allowed,
"duration_ms", r.Duration.Milliseconds(),
"trace_id", r.TraceID,
)
// Alert on denied calls
if !r.Allowed {
alerting.Notify("MCP access denied",
"tool", r.Tool,
"account", r.AccountID,
)
}
},
Every MCP tool call gets a UUID trace ID, propagated via metadata headers:
| Header | Description |
|---|---|
Mcp-Trace-Id | UUID for the tool call |
Mcp-Tool-Name | Name of the tool called |
Mcp-Account-Id | Caller's account ID |
These are available in your handler via context metadata:
func (t *TaskService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
md, _ := metadata.FromContext(ctx)
traceID := md["Mcp-Trace-Id"]
log.Printf("Creating task, trace: %s", traceID)
// ...
}
For full distributed tracing, plug in an OpenTelemetry trace provider:
import (
"go.opentelemetry.io/otel"
"go-micro.dev/v5/gateway/mcp"
)
mcp.ListenAndServe(":3000", mcp.Options{
Registry: reg,
TraceProvider: otel.GetTracerProvider(),
})
Each tool call creates a span (mcp.tool.call) with these attributes:
| Attribute | Example |
|---|---|
mcp.tool.name | tasks.TaskService.Create |
mcp.transport | http, websocket, stdio |
mcp.account.id | user-123 |
mcp.trace.id | a1b2c3d4-... |
mcp.auth.allowed | true |
mcp.auth.denied_reason | insufficient_scope |
mcp.scopes.required | tasks:write |
mcp.rate_limited | false |
The gateway propagates W3C trace context downstream, so you get end-to-end traces from agent → gateway → service in Jaeger, Zipkin, or any OTel-compatible backend.
The WebSocket transport supports two authentication methods:
Pass the token in the WebSocket upgrade request:
const ws = new WebSocket("ws://localhost:3000/mcp/ws", {
headers: { "Authorization": "Bearer <token>" }
});
The token is validated once on connection and applies to all messages on that connection.
For stateless connections, pass a _token parameter with each tool call:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "tasks.TaskService.Create",
"arguments": {"title": "New task"},
"_token": "Bearer <token>"
}
}
Connection-level auth takes precedence over per-message auth.
Before deploying MCP to production:
auth.Auth providermicro mcp testpackage main
import (
"log"
"go-micro.dev/v5"
"go-micro.dev/v5/auth"
"go-micro.dev/v5/gateway/mcp"
"go-micro.dev/v5/server"
)
func main() {
service := micro.NewService(
micro.Name("tasks"),
micro.Address(":8081"),
)
service.Init()
// Register handler with scopes
handler := service.Server().NewHandler(
&TaskService{tasks: make(map[string]*Task)},
server.WithEndpointScopes("TaskService.Get", "tasks:read"),
server.WithEndpointScopes("TaskService.Create", "tasks:write"),
server.WithEndpointScopes("TaskService.Delete", "tasks:admin"),
)
service.Server().Handle(handler)
// Start MCP gateway with full security
go mcp.ListenAndServe(":3000", mcp.Options{
Registry: service.Options().Registry,
Auth: service.Options().Auth,
Scopes: map[string][]string{
// Gateway-level overrides
"billing.Billing.Charge": {"billing:admin"},
},
RateLimit: &mcp.RateLimitConfig{
RequestsPerSecond: 10,
Burst: 20,
},
AuditFunc: func(r mcp.AuditRecord) {
log.Printf("[AUDIT] tool=%s account=%s allowed=%v duration=%v",
r.Tool, r.AccountID, r.Allowed, r.Duration)
},
})
service.Run()
}