Granular Per-Tool Scopes & Access Control
1. The Coarse Gating Anti-Pattern
Section titled “1. The Coarse Gating Anti-Pattern”Most basic MCP tutorials only implement coarse-grained endpoint gating:
// The entire /sse endpoint requires "tools:read"mux.Handle("/sse", gate("tools:read", mcpHandler))This works if every tool on your server shares identical risk and cost profiles. In production, an MCP server often aggregates tools with varying privileges:
| Tool | Action | Risk Level | Required Scope |
|---|---|---|---|
dictionary_lookup |
Read-only search | Low / Zero-risk | tools:read |
update_record |
Mutates data | Moderate risk | tools:write |
admin_purge_cache |
Flushes system state | High risk / Privileged | admin:access |
generate_audio |
Invokes GPU proxy | High cost per call | audio:generate |
Because all tools are dispatched over the same multiplexed connection, gating /sse at the HTTP route level cannot prevent a client with tools:read from calling admin_purge_cache.
2. Context-Propagated JWT Claims
Section titled “2. Context-Propagated JWT Claims”To enforce authorization per tool, the ingress middleware stashes the parsed token claims into Go’s request context:
// Stash claims in request context during ingress middlewareif claims, ok := token.Claims.(jwt.MapClaims); ok { r = r.WithContext(ContextWithClaims(r.Context(), claims))}Downstream tool handlers extract claims from the context.Context passed by the SDK:
claims, ok := ClaimsFromContext(ctx)if !ok || !HasScope(claims, "admin:access") { // Deny tool execution}3. Generic Type-Safe Tool Decorators
Section titled “3. Generic Type-Safe Tool Decorators”Using Go generics, you can wrap any tool handler with RequireScope[In, Out any] without writing repetitive boilerplate inside your business logic:
func RequireScope[In, Out any]( requiredScope string, handler func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error),) func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error) { return func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { claims, ok := ClaimsFromContext(ctx) if !ok || !HasScope(claims, requiredScope) { var zeroOut Out return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: fmt.Sprintf( "Permission Denied: Caller lacks required OAuth scope %q. "+ "Upgrade permissions at the authorization portal or request this scope from your administrator.", requiredScope, ), }, }, IsError: true, }, zeroOut, nil } return handler(ctx, req, args) }}4. Declarative Tool Registration in mcp.go
Section titled “4. Declarative Tool Registration in mcp.go”Tool registration becomes concise and self-documenting:
// Baseline read-only tool (accessible to any authenticated client)mcp.AddTool(server, &mcp.Tool{ Name: "dictionary_lookup", Title: "Dictionary Lookup", Description: "Searches vocabulary terms.", Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},}, dictionaryLookupHandler)
// Privileged administrative tool (gated by 'admin:access')mcp.AddTool(server, &mcp.Tool{ Name: "admin_purge_cache", Title: "Purge System Cache", Description: "Flushes runtime cache partitions (requires 'admin:access' scope).", Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false},}, RequireScope("admin:access", adminPurgeCacheHandler))5. Step-Up Guidance & Error Payloads
Section titled “5. Step-Up Guidance & Error Payloads”When an unprivileged client attempts to execute a restricted tool, the server returns an explicit IsError: true payload detailing the missing scope. AI assistants interpret this payload to inform the user or initiate a step-up authorization flow rather than hallucinating tool failures.