Skip to content

Granular Per-Tool Scopes & Access Control

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.


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 middleware
if 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
}

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))

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.