MCP Server and Its Implementation with Go
A sophisticated AI is useless if it can’t fetch real data or execute actions on existing systems. The Model Context Protocol (MCP) exists to answer exactly that problem — an open standard that lets AI clients like Claude communicate with external services in a structured, secure, and consistent way. This article builds a complete MCP Server from scratch using Go, connected to a financial report backend service, equipped with JWT-based RBAC checks from Authentik, and two tools demonstrating the access differences between roles.
What Is MCP and Why Go?
MCP isn’t a library — it’s a protocol. Just as HTTP defines how browsers communicate with web servers, MCP defines how AI clients communicate with external tools and resources. Every MCP server exposes three kinds of entities: tools (functions AI can call), resources (data AI can read), and prompts (instruction templates). In practice, tools are the most used because they let AI execute real actions — not just read data.
Go is chosen for several very practical reasons. Go binaries produce a single executable without runtime dependencies — suitable for MCP servers deployed as a sidecar or standalone process. Goroutines make handling multiple concurrent tool calls easy without manual threading complexity. And Go’s ecosystem for HTTP, JSON, and JWT is mature with stable libraries.
USE MCP if:
✓ AI needs to read or manipulate data from existing systems
✓ You want Claude to be able to execute real actions (not just give advice)
✓ There are many different tools that need to be accessed from one AI client
✓ You need an audit trail: who called what tool, when, with what data
✓ Tool access needs to be restricted based on user role or permission
DON'T USE MCP if:
✗ AI only needs to answer questions based on training data
✗ You already have a REST API and users can paste results manually into chat
✗ Interaction is one-way and doesn't need tool call chaining
How MCP Works: Protocol and Transport
Before writing a single line of code, it’s important to understand how MCP works at the protocol level. MCP uses JSON-RPC 2.0 as its message format. Every interaction between an AI client and an MCP server follows a strict request-response pattern, with method names standardized by the MCP spec.
%%{init: {
"theme": "base",
"themeVariables": {
"fontSize": "14px",
"actorBkg": "#EEEDFE",
"actorBorder": "#534AB7",
"actorTextColor": "#26215C",
"actorLineColor": "#534AB7",
"signalColor": "#534AB7",
"signalTextColor": "#26215C",
"labelBoxBkgColor": "#FAEEDA",
"labelBoxBorderColor": "#854F0B",
"labelTextColor": "#412402",
"loopTextColor": "#412402",
"noteBkgColor": "#E1F5EE",
"noteBorderColor": "#0F6E56",
"noteTextColor": "#04342C",
"activationBkgColor": "#FAEEDA",
"activationBorderColor": "#854F0B",
"sequenceNumberColor": "#FFFFFF"
}
}}%%
sequenceDiagram
autonumber
participant C as AI Client
participant M as MCP Server
participant S as Backend Service
Note over C,M: Phase 1 — Initialization
C->>M: initialize (protocol version, capabilities)
M-->>C: serverInfo, capabilities
Note over C,M: Phase 2 — Tool Discovery
C->>M: tools/list
M-->>C: Array tools (name, description, inputSchema)
Note over C,M: Phase 3 — Tool Execution
C->>M: tools/call { name, arguments, Bearer JWT }
M->>M: Validate JWT + RBAC check
alt Permission OK
M->>S: Request report data
S-->>M: Report data
M-->>C: CallToolResult { content }
else Permission Denied
M-->>C: Error: 403 Forbidden
endThere are two transports supported by MCP:
| Transport | When Used | How It Works |
|---|---|---|
| stdio | Local tools, IDE plugins, Claude Desktop | The AI client spawns the MCP server as a subprocess, communicating via stdin/stdout |
| SSE (HTTP) | Remote servers, multi-client, production | The MCP server runs as an HTTP server, the AI client connects via Server-Sent Events |
This article uses SSE transport because it’s more realistic for production scenarios — the MCP server runs as a separate service accessible by many clients at once.
The MCP spec as of June 2025 requires MCP servers to use OAuth 2.1 with PKCE for authentication. The MCP server only plays the role of a resource server — it validates tokens, not issues them. An external authorization server (Authentik, Keycloak, Auth0) issues the JWT.
Project Overview
The project in this article consists of two separate repositories reflecting the two most common MCP usage patterns in the real world. Separating them isn’t just a style choice — it reflects how MCP should be used: the MCP server is an adaptation layer between AI and existing systems, not a replacement for those systems.
Two Tool Models in One MCP Server
Before looking at the repository structure, it’s important to understand the fundamental difference between the two tool categories that will be built:
MODEL A — Proxy to a Backend Service
─────────────────────────────────
AI Client
→ MCP Server (auth + RBAC)
→ report-service (REST API)
→ data/reports.json
When to use:
✓ Data lives in an existing external system
✓ Another team manages that service
✓ The service already has an API and needs to be "wrapped" for AI
✓ End-to-end audit trail is needed
Example tools: list_reports, get_report
MODEL B — Self-Contained Logic
──────────────────────────────
AI Client
→ MCP Server (auth + RBAC)
→ Computation inside the MCP Server itself
(no network call to another service)
When to use:
✓ Simple logic that doesn't need persistence
✓ Transformation or calculation of existing data
✓ Aggregation of results from other tools in one session
✓ Validation, formatting, or conversion
Example tool: calculate_growth
Overall Architecture
%%{init: {
"theme": "base",
"themeVariables": {
"fontSize": "13px",
"actorBkg": "#EEEDFE",
"actorBorder": "#534AB7",
"actorTextColor": "#26215C",
"actorLineColor": "#7F77DD",
"signalColor": "#534AB7",
"signalTextColor": "#26215C",
"labelBoxBkgColor": "#FAEEDA",
"labelBoxBorderColor": "#854F0B",
"labelTextColor": "#412402",
"loopTextColor": "#412402",
"noteBkgColor": "#E1F5EE",
"noteBorderColor": "#0F6E56",
"noteTextColor": "#04342C",
"activationBkgColor": "#FAEEDA",
"activationBorderColor": "#854F0B",
"sequenceNumberColor": "#FFFFFF"
}
}}%%
sequenceDiagram
autonumber
participant C as AI Client
participant A as Authentik
participant M as mcp-server
participant R as report-service
Note over C,A: Authentication
C->>A: Login (OAuth2)
A-->>C: JWT (mcp_permissions, department)
Note over C,M: Tool Discovery
C->>M: tools/list + Bearer JWT
M-->>C: list_reports, get_report, calculate_growth
Note over C,M: Model A — Proxy to Service
C->>M: tools/call list_reports
M->>M: Validate JWT + RBAC check
M->>R: GET /reports
R-->>M: []Report
M-->>C: CallToolResult
Note over C,M: Model B — Self-Contained
C->>M: tools/call calculate_growth
M->>M: Validate JWT + RBAC check
M->>M: Calculate growth percentage
M-->>C: CallToolResultComponent Relationships
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#EEEDFE",
"primaryTextColor": "#26215C",
"primaryBorderColor": "#534AB7",
"secondaryColor": "#E1F5EE",
"secondaryTextColor": "#04342C",
"secondaryBorderColor": "#0F6E56",
"tertiaryColor": "#FAEEDA",
"tertiaryTextColor": "#412402",
"tertiaryBorderColor": "#854F0B",
"edgeLabelBackground": "#F1EFE8",
"lineColor": "#73726c",
"fontSize": "13px"
}
}}%%
flowchart LR
subgraph AUTH["🔐 Auth Layer"]
direction TB
AK[Authentik<br/>OAuth2 Provider]:::teal
end
subgraph REPO1["📦 repo: report-service"]
direction TB
RS[REST API<br/>:8080]:::green
DB[(data/reports.json)]:::green
RS --> DB
end
subgraph REPO2["📦 repo: mcp-server"]
direction TB
MS[MCP Server<br/>:3000]:::purple
subgraph TOOLS["Tools"]
direction LR
T1[list_reports<br/>get_report<br/>Model A]:::amber
T2[calculate_growth<br/>Model B]:::amber
end
MS --> TOOLS
end
C([AI Client<br/>Claude]):::purple
C -->|"1. Login"| AK
AK -->|"2. JWT"| C
C -->|"3. tools/call + JWT"| MS
MS -->|"4. validation"| AK
T1 -->|"5a. HTTP request"| RS
T2 -.->|"5b. local computation<br/>no network call"| MS
classDef purple fill:#EEEDFE,stroke:#534AB7,color:#26215C
classDef teal fill:#E1F5EE,stroke:#0F6E56,color:#04342C
classDef amber fill:#FAEEDA,stroke:#854F0B,color:#412402
classDef green fill:#E1F5EE,stroke:#1D9E75,color:#04342CRepository Structure
This project consists of two repositories with completely separate responsibilities:
report-service/ # Repo 1: Backend Service
├── main.go # HTTP server, route registration
├── go.mod
├── data/
│ └── reports.json # Financial report storage
└── internal/
├── handler/
│ └── reports.go # HTTP handler: GET /reports, GET /reports/:id
└── store/
└── reports.go # Reads and writes to JSON
mcp-server/ # Repo 2: MCP Server
├── main.go # MCP server, tool registration
├── go.mod
├── internal/
│ ├── auth/
│ │ └── jwt.go # JWT validation from Authentik
│ ├── rbac/
│ │ └── policy.go # Tool → permission mapping
│ ├── client/
│ │ └── report_client.go # HTTP client to report-service (Model A)
│ └── tools/
│ ├── list_reports.go # Tool: list — proxy to report-service
│ ├── get_report.go # Tool: get — proxy to report-service
│ └── calculate_growth.go# Tool: calculation — self-contained (Model B)
└── config/
└── config.go # Configuration from env vars
This separation reflects production reality: report-service might already exist before MCP is introduced to the system, be managed by a different team, or also be used by other clients outside of AI. mcp-server only wraps it — it owns no data, no report business logic, and can be replaced or updated without touching report-service at all.
Ports and Dependencies Between Services
Authentik → :9000 (auth server, already running)
report-service → :8080 (must be running before mcp-server)
mcp-server → :3000 (opened to the AI client)
The AI Client only knows about:
- Authentik (for login and tokens)
- mcp-server (for tool calls)
The AI Client never knows about:
- report-service (hidden behind mcp-server)
- How data is fetched or stored
report-servicein this project doesn’t do any auth validation — it’s assumed to be on an internal network only accessible bymcp-server. In production, network-level protection (private VPC, service mesh, or mTLS) replaces auth at this layer.
Backend Service
The backend service in this project stores and manages financial report data. For example simplicity, its implementation uses a JSON file as storage — so you can run the project directly without needing to set up a database. In production, this layer can be replaced with a PostgreSQL connection, MySQL, or an internal REST API without changing a single line in the MCP layer.
// data/reports.json
{
"reports": [
{
"id": "rpt-001",
"title": "Q1 2025 Financial Report",
"department": "finance",
"period": "Q1 2025",
"created_at": "2025-01-15T08:00:00Z",
"created_by": "[email protected]",
"status": "published",
"summary": "Q1 total revenue reached Rp 4.2 billion, up 12% from Q4 2024.",
"figures": {
"revenue": 4200000000,
"expenses": 2800000000,
"net_profit": 1400000000,
"growth_pct": 12.4
}
},
{
"id": "rpt-002",
"title": "Q2 2025 Financial Report",
"department": "finance",
"period": "Q2 2025",
"created_at": "2025-04-10T09:30:00Z",
"created_by": "[email protected]",
"status": "published",
"summary": "Growth slowed in Q2 due to rising operational costs.",
"figures": {
"revenue": 4050000000,
"expenses": 3100000000,
"net_profit": 950000000,
"growth_pct": -3.6
}
},
{
"id": "rpt-003",
"title": "H1 2025 Operations Report",
"department": "operations",
"period": "H1 2025",
"created_at": "2025-07-01T10:00:00Z",
"created_by": "[email protected]",
"status": "draft",
"summary": "Production efficiency improved 8% thanks to the new system implementation.",
"figures": {
"efficiency_pct": 8.2,
"downtime_hours": 24,
"output_units": 125000,
"defect_rate_pct": 0.8
}
},
{
"id": "rpt-004",
"title": "Q2 HR Report",
"department": "hr",
"period": "Q2 2025",
"created_at": "2025-04-20T14:00:00Z",
"created_by": "[email protected]",
"status": "published",
"summary": "Employee retention rate 94%, recruited 23 new positions.",
"figures": {
"headcount": 312,
"new_hires": 23,
"resignations": 18,
"retention_pct": 94.2
}
}
]
}
The store layer wraps all read-write operations to the backend service:
// internal/store/reports.go
package store
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// Report represents one report in the system
type Report struct {
ID string `json:"id"`
Title string `json:"title"`
Department string `json:"department"`
Period string `json:"period"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
Status string `json:"status"`
Summary string `json:"summary"`
Figures map[string]interface{} `json:"figures"`
}
type database struct {
Reports []Report `json:"reports"`
}
// ReportStore manages read-write operations to the backend service
type ReportStore struct {
path string
mu sync.RWMutex
}
func NewReportStore(path string) *ReportStore {
return &ReportStore{path: path}
}
func (s *ReportStore) load() (*database, error) {
data, err := os.ReadFile(s.path)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
var db database
if err := json.Unmarshal(data, &db); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
return &db, nil
}
func (s *ReportStore) save(db *database) error {
data, err := json.MarshalIndent(db, "", " ")
if err != nil {
return fmt.Errorf("failed to encode JSON: %w", err)
}
return os.WriteFile(s.path, data, 0644)
}
// GetAll returns all reports
func (s *ReportStore) GetAll() ([]Report, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, err := s.load()
if err != nil {
return nil, err
}
return db.Reports, nil
}
// GetByID returns a report by ID
func (s *ReportStore) GetByID(id string) (*Report, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, err := s.load()
if err != nil {
return nil, err
}
for _, r := range db.Reports {
if r.ID == id {
return &r, nil
}
}
return nil, fmt.Errorf("report with ID '%s' not found", id)
}
// GetByDepartment returns reports by department
func (s *ReportStore) GetByDepartment(dept string) ([]Report, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, err := s.load()
if err != nil {
return nil, err
}
var result []Report
for _, r := range db.Reports {
if r.Department == dept {
result = append(result, r)
}
}
return result, nil
}
// Delete removes a report by ID
func (s *ReportStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
db, err := s.load()
if err != nil {
return err
}
newReports := make([]Report, 0, len(db.Reports))
found := false
for _, r := range db.Reports {
if r.ID == id {
found = true
continue
}
newReports = append(newReports, r)
}
if !found {
return fmt.Errorf("report with ID '%s' not found", id)
}
db.Reports = newReports
return s.save(db)
}
Auth Layer: JWT Validation from Authentik
This is the most crucial part. The MCP server doesn’t manage logins or sessions — it only receives JWTs already issued by Authentik and validates them using the public JWKS endpoint. Once the token is valid, the claims inside it (specifically mcp_permissions) are used for RBAC decisions.
// internal/auth/jwt.go
package auth
import (
"context"
"fmt"
"strings"
"time"
"github.com/MicahParks/keyfunc/v2"
"github.com/golang-jwt/jwt/v5"
)
// Claims represents the JWT payload from Authentik
type Claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
MCPPermissions []string `json:"mcp_permissions"`
Department string `json:"department"`
jwt.RegisteredClaims
}
// Validator validates JWTs using the JWKS from Authentik
type Validator struct {
jwks *keyfunc.JWKS
}
// NewValidator creates a new Validator connected to Authentik's JWKS endpoint
func NewValidator(jwksURL string) (*Validator, error) {
jwks, err := keyfunc.Get(jwksURL, keyfunc.Options{
RefreshInterval: time.Hour,
RefreshErrorHandler: func(err error) {
// Log the JWKS refresh error — don't crash the server
fmt.Printf("JWKS refresh error: %v\n", err)
},
})
if err != nil {
return nil, fmt.Errorf("failed to initialize JWKS from %s: %w", jwksURL, err)
}
return &Validator{jwks: jwks}, nil
}
// Validate validates a token string and returns the claims if valid
func (v *Validator) Validate(tokenStr string) (*Claims, error) {
if tokenStr == "" {
return nil, fmt.Errorf("token must not be empty")
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, v.jwks.Keyfunc)
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("token expired or invalid")
}
return claims, nil
}
// ExtractFromContext takes the Bearer token from the MCP context
// The MCP client sends the token via the Authorization: Bearer *** header
func ExtractFromContext(ctx context.Context) (string, error) {
// mcp-go stores the authorization header in the context with this key
authHeader, ok := ctx.Value("authorization").(string)
if !ok || authHeader == "" {
return "", fmt.Errorf("Authorization header not found in request")
}
const prefix = "Bearer "
if !strings.HasPrefix(authHeader, prefix) {
return "", fmt.Errorf("Authorization format must be 'Bearer <token>'")
}
token := strings.TrimPrefix(authHeader, prefix)
if token == "" {
return "", fmt.Errorf("token is empty after Bearer prefix")
}
return token, nil
}
RBAC Layer: Who Can Do What
This is where many developers go wrong. The MCP server doesn’t automatically know that the “admin” role can delete reports — you have to define it. This RBAC layer stores an explicit mapping between tool names and the permissions required to call them.
// internal/rbac/policy.go
package rbac
import "fmt"
// toolPermissions defines the permission required by each tool.
// This is the only place to change when you add a new tool
// or change access rules.
var toolPermissions = map[string]string{
"list_reports": "report:read",
"get_report": "report:read",
"delete_report": "report:delete",
}
// Check verifies whether the user's permission list includes the
// permission required for the requested tool.
func Check(toolName string, userPermissions []string) error {
required, exists := toolPermissions[toolName]
if !exists {
// Tool not registered in the policy — deny as a safety measure
return fmt.Errorf("tool '%s' is not registered in the policy", toolName)
}
for _, p := range userPermissions {
if p == required {
return nil // ✓ Access granted
}
}
return fmt.Errorf(
"access denied: tool '%s' requires permission '%s'",
toolName, required,
)
}
// RequiredPermission returns the permission required by a tool.
// Useful for informative error messages.
func RequiredPermission(toolName string) string {
if p, ok := toolPermissions[toolName]; ok {
return p
}
return "unknown"
}
Note that toolPermissions is a simple map — there’s no magic here. If you add a new tool like create_report, you just add one line:
// CORRECT: always register a permission for every new tool
"create_report": "report:write",
// ANTI-PATTERN: leaving a tool without a policy entry
// Result: "tool not registered in the policy" → automatic 403
// This is actually safe behavior, but explicit is better
Tool Implementation
Every tool is a function with a specific signature: it accepts a context.Context and mcp.CallToolRequest, and returns a *mcp.CallToolResult and error. The authentication and authorization pattern in every tool is always the same — extract the token, validate, check RBAC, then execute the business logic.
Tools: list_reports and get_report
// internal/tools/get_report.go
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/kamu/mcp-reports/internal/auth"
"github.com/kamu/mcp-reports/internal/rbac"
"github.com/kamu/mcp-reports/internal/store"
"github.com/mark3labs/mcp-go/mcp"
)
// ReportTools wraps all report-related tools
type ReportTools struct {
store *store.ReportStore
validator *auth.Validator
}
func NewReportTools(s *store.ReportStore, v *auth.Validator) *ReportTools {
return &ReportTools{store: s, validator: v}
}
// checkAccess is an internal helper that does the auth + RBAC check.
// All tools call this as their first step.
func (t *ReportTools) checkAccess(ctx context.Context, toolName string) (*auth.Claims, error) {
tokenStr, err := auth.ExtractFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("unauthorized: %w", err)
}
claims, err := t.validator.Validate(tokenStr)
if err != nil {
return nil, fmt.Errorf("unauthorized: %w", err)
}
if err := rbac.Check(toolName, claims.MCPPermissions); err != nil {
return nil, err // The error message from RBAC is already descriptive
}
return claims, nil
}
// HandleListReports returns all available reports.
// Requires permission: report:read
func (t *ReportTools) HandleListReports(
ctx context.Context,
req mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
claims, err := t.checkAccess(ctx, "list_reports")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Get the optional filter from arguments
deptFilter, _ := req.Params.Arguments["department"].(string)
var reports []store.Report
if deptFilter != "" {
reports, err = t.store.GetByDepartment(deptFilter)
} else {
reports, err = t.store.GetAll()
}
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to fetch data: %v", err)), nil
}
// Create a summary for each report (don't expose detailed figures)
type reportSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Department string `json:"department"`
Period string `json:"period"`
Status string `json:"status"`
CreatedBy string `json:"created_by"`
}
summaries := make([]reportSummary, len(reports))
for i, r := range reports {
summaries[i] = reportSummary{
ID: r.ID,
Title: r.Title,
Department: r.Department,
Period: r.Period,
Status: r.Status,
CreatedBy: r.CreatedBy,
}
}
data, _ := json.MarshalIndent(map[string]interface{}{
"total": len(summaries),
"reports": summaries,
"accessed_by": claims.Email,
}, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
// HandleGetReport returns the complete details of one report by ID.
// Requires permission: report:read
func (t *ReportTools) HandleGetReport(
ctx context.Context,
req mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
claims, err := t.checkAccess(ctx, "get_report")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
reportID, ok := req.Params.Arguments["report_id"].(string)
if !ok || reportID == "" {
return mcp.NewToolResultError("the 'report_id' parameter is required"), nil
}
report, err := t.store.GetByID(reportID)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(map[string]interface{}{
"report": report,
"accessed_by": claims.Email,
"accessed_at": "now",
}, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
Tool: delete_report (Admin Only)
// internal/tools/delete_report.go
package tools
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
)
// HandleDeleteReport permanently deletes a report from the JSON file.
// Requires permission: report:delete (admin role only)
func (t *ReportTools) HandleDeleteReport(
ctx context.Context,
req mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
// RBAC check — only report:delete permission can reach this point
claims, err := t.checkAccess(ctx, "delete_report")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
reportID, ok := req.Params.Arguments["report_id"].(string)
if !ok || reportID == "" {
return mcp.NewToolResultError("the 'report_id' parameter is required"), nil
}
// Verify the report exists before deleting it
report, err := t.store.GetByID(reportID)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if err := t.store.Delete(reportID); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to delete: %v", err)), nil
}
result := fmt.Sprintf(
"Report '%s' (%s) successfully deleted by %s.",
report.Title, report.ID, claims.Email,
)
return mcp.NewToolResultText(result), nil
}
Configuration
All values that depend on the environment (Authentik URL, data file path, server port) are read from environment variables. No hardcoded values that cause problems when moving between development and production.
// config/config.go
package config
import (
"fmt"
"os"
)
// Config stores all MCP server runtime configuration
type Config struct {
// AuthentikJWKSURL is the JWKS endpoint URL of the OAuth2 provider in Authentik
// Example: https://auth.company.com/application/o/mcp-reports/jwks/
AuthentikJWKSURL string
// DataPath is the path to the JSON file storing report data
DataPath string
// ServerPort is the HTTP port where the MCP server listens for connections
ServerPort string
// ServerBaseURL is the MCP server's public URL, used by the SSE transport
ServerBaseURL string
}
// Load reads configuration from environment variables and validates it
func Load() (*Config, error) {
cfg := &Config{
AuthentikJWKSURL: getEnv("AUTHENTIK_JWKS_URL", ""),
DataPath: getEnv("DATA_PATH", "./data/reports.json"),
ServerPort: getEnv("SERVER_PORT", "3000"),
ServerBaseURL: getEnv("SERVER_BASE_URL", "http://localhost:3000"),
}
if cfg.AuthentikJWKSURL == "" {
return nil, fmt.Errorf("AUTHENTIK_JWKS_URL is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Main: Putting All Components Together
This entry point initializes all dependencies in order, then registers all tools along with their input schema definitions to the MCP server.
// main.go
package main
import (
"fmt"
"log"
"github.com/kamu/mcp-reports/config"
"github.com/kamu/mcp-reports/internal/auth"
"github.com/kamu/mcp-reports/internal/store"
"github.com/kamu/mcp-reports/internal/tools"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
// 1. Load configuration from the environment
cfg, err := config.Load()
if err != nil {
log.Fatalf("Invalid configuration: %v", err)
}
// 2. Initialize the JWT validator connected to Authentik JWKS
validator, err := auth.NewValidator(cfg.AuthentikJWKSURL)
if err != nil {
log.Fatalf("Failed to initialize JWT validator: %v", err)
}
// 3. Initialize the store reading data from the JSON file
reportStore := store.NewReportStore(cfg.DataPath)
// 4. Create the tool handler instance
reportTools := tools.NewReportTools(reportStore, validator)
// 5. Create the MCP server with metadata
s := server.NewMCPServer(
"Report MCP Server",
"1.0.0",
server.WithToolCapabilities(true),
)
// 6. Register the list_reports tool with its input schema
s.AddTool(
mcp.NewTool("list_reports",
mcp.WithDescription(
"Lists all available reports. "+
"Use the 'department' filter to narrow results. "+
"Requires the report:read permission.",
),
mcp.WithString("department",
mcp.Description("Filter by department (finance, operations, hr). Leave empty for all departments."),
),
),
reportTools.HandleListReports,
)
// 7. Register the get_report tool
s.AddTool(
mcp.NewTool("get_report",
mcp.WithDescription(
"Returns the complete details of one report by ID, "+
"including financial or operational figures. "+
"Use list_reports first to get a valid ID. "+
"Requires the report:read permission.",
),
mcp.WithString("report_id",
mcp.Required(),
mcp.Description("Report ID in the 'rpt-XXX' format, e.g. rpt-001"),
),
),
reportTools.HandleGetReport,
)
// 8. Register the delete_report tool (admin only)
s.AddTool(
mcp.NewTool("delete_report",
mcp.WithDescription(
"Permanently deletes a report from the system. "+
"THIS ACTION CANNOT BE UNDONE. "+
"Only available to users with the report:delete permission (admin role). "+
"Use get_report first to verify the report to be deleted.",
),
mcp.WithString("report_id",
mcp.Required(),
mcp.Description("ID of the report to delete in the 'rpt-XXX' format"),
),
),
reportTools.HandleDeleteReport,
)
// 9. Run the server using SSE transport
addr := fmt.Sprintf(":%s", cfg.ServerPort)
httpServer := server.NewSSEServer(s,
server.WithBaseURL(cfg.ServerBaseURL),
)
log.Printf("MCP Report Server running at %s", cfg.ServerBaseURL)
log.Printf("SSE endpoint: %s/sse", cfg.ServerBaseURL)
log.Printf("Data source: %s", cfg.DataPath)
if err := httpServer.Start(addr); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
Running the Project
Run the server by exposing the required environment variables:
# Development — using a local Authentik
export AUTHENTIK_JWKS_URL="https://authentik.local/application/o/mcp-reports/jwks/"
export DATA_PATH="./data/reports.json"
export SERVER_PORT="3000"
export SERVER_BASE_URL="http://localhost:3000"
go run ./main.go
Expected output:
2025/07/15 10:00:00 MCP Report Server running at http://localhost:3000
2025/07/15 10:00:00 SSE endpoint: http://localhost:3000/sse
2025/07/15 10:00:00 Data source: ./data/reports.json
To manually test a tool call with curl:
# Test list_reports (needs a valid JWT from Authentik)
curl -X POST http://localhost:3000/message \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <JWT_FROM_AUTHENTIK>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_reports",
"arguments": {}
}
}'
Authentik Setup
On the Authentik side, you need to create an OAuth2 Provider with a special Property Mapping that injects mcp_permissions into the JWT based on the user’s group. Open Authentik → Customisation → Property Mappings → create a new mapping with the “Scope Mapping” type:
# Property Mapping in Authentik (Python expression)
# Mapping name: "MCP Report Permissions"
# Scope name: "mcp"
user_groups = [g.name for g in request.user.ak_groups.all()]
permissions = []
# Admin gets all permissions
if "mcp-admin" in user_groups:
permissions = ["report:read", "report:delete"]
# Finance and Managers can only read
elif any(g in user_groups for g in ["finance", "manager", "analyst"]):
permissions = ["report:read"]
# Regular users get no MCP permissions
# (the token stays valid but all tool calls will be denied)
return permissions
Also add a mapping for department so the MCP server can filter reports by the user’s department:
# Property Mapping: "MCP Department"
# Scope name: "mcp"
# Take the department from the user attributes in Authentik
return request.user.attributes.get("department", "")
With this configuration, the JWT issued by Authentik for a user in the “finance” group will contain:
{
"sub": "user-abc123",
"email": "[email protected]",
"mcp_permissions": ["report:read"],
"department": "finance",
"exp": 1234567890
}
While the JWT for a user in the “mcp-admin” group:
{
"sub": "user-xyz789",
"email": "[email protected]",
"mcp_permissions": ["report:read", "report:delete"],
"department": "it",
"exp": 1234567890
}
The MCP server reads this mcp_permissions without needing to know anything about the group structure in Authentik. Authentik is the single source of truth about “who can do what” — the MCP server only executes the policy encoded there.
Never hardcode a JWT secret or private key in code. The MCP server validates tokens using the public key fetched from the Authentik JWKS endpoint — it never holds any secret. If the JWKS URL is unreachable at startup, the server fails with an explicit error rather than running without token validation.
Complete Flow: From Login to Data
To ensure complete understanding, here’s the full flow that happens when Claude calls the get_report tool:
%%{init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#EEEDFE",
"primaryTextColor": "#26215C",
"primaryBorderColor": "#534AB7",
"secondaryColor": "#E1F5EE",
"secondaryTextColor": "#04342C",
"secondaryBorderColor": "#0F6E56",
"tertiaryColor": "#FAEEDA",
"tertiaryTextColor": "#412402",
"tertiaryBorderColor": "#854F0B",
"edgeLabelBackground": "#F1EFE8",
"lineColor": "#73726c",
"fontSize": "14px"
}
}}%%
flowchart TD
A([👤 User / Claude]):::purple -->|1. Login with SSO| B[🔐 Authentik<br/>OAuth2 Provider]:::teal
B -->|2. JWT containing mcp_permissions| A
A -->|3. tools/call get_report + Bearer JWT| C[⚙️ MCP Server Go]:::amber
C -->|4. ExtractFromContext| D{Token present?}:::decision
D -->|No| E([❌ 401 Unauthorized]):::red
D -->|Yes| F[🔍 Validate JWT<br/>via JWKS]:::amber
F -->|Signature invalid / expired| G([❌ 401 Invalid Token]):::red
F -->|Valid| H[📋 Parse mcp_permissions<br/>from claims]:::amber
H -->|rbac.Check| I{report:read<br/>present?}:::decision
I -->|No| J([❌ 403 Forbidden]):::red
I -->|Yes| K[🔎 store.GetByID]:::amber
K -->|ID doesn't exist| L([❌ 404 Not Found]):::red
K -->|Report found| M[📂 Request to<br/>Backend Service]:::green
M -->|Data| N([✅ CallToolResult<br/>Report data]):::success
classDef purple fill:#EEEDFE,stroke:#534AB7,color:#26215C
classDef teal fill:#E1F5EE,stroke:#0F6E56,color:#04342C
classDef amber fill:#FAEEDA,stroke:#854F0B,color:#412402
classDef green fill:#E1F5EE,stroke:#0F6E56,color:#04342C
classDef red fill:#FDECEA,stroke:#C0392B,color:#7B241C
classDef success fill:#E1F5EE,stroke:#0F6E56,color:#04342C
classDef decision fill:#FFF8E1,stroke:#F9A825,color:#3E2723When Not to Use MCP
MCP isn’t the solution for every AI integration scenario. There are cases where other approaches are more appropriate.
Keep using MCP if:
✓ AI needs to call more than one tool in a single response
✓ You need an audit log of who called what tool
✓ Tools need to be restricted per user/role granularly
✓ You're integrating AI into an existing workflow
Consider a plain REST API if:
✗ Users always copy-paste API results into chat manually (no automation needed)
✗ There's only one tool with one very simple function
✗ There's no need for tool call chaining
Consider direct function calling (without MCP) if:
✗ You're building a custom application with your own Anthropic SDK
✗ No interoperability with other AI clients is needed
✗ The tool is only used from one application you fully control
Summary
- MCP is a protocol, not a library — it defines a communication standard between AI clients and external tools using JSON-RPC 2.0 over SSE or stdio transport.
- The MCP server doesn’t have its own authorization “brain” — it only validates JWTs and executes the policy you define. Authentik (or any IdP) is the source of truth about who can do what.
- The tool → permission mapping must be explicit — there’s no automatic connection between a tool name and a permission. You define it in
rbac/policy.goand that’s the only place to change when access rules change.- The backend service is completely separate from the MCP layer —
store.ReportStoreknows nothing about MCP. You can replace the storage implementation with PostgreSQL or an internal REST API without changing a single line of code in the MCP handlers.- Tool descriptions must be precise — Claude reads tool descriptions to decide when to call them. Ambiguous descriptions cause Claude to pick the wrong tool or call tools at the wrong time.
- Every tool handler follows the same pattern — extract token → validate JWT → check RBAC → execute business logic. This consistency makes auditing and debugging much easier.
- Automatic JWKS refresh —
keyfuncrefreshes the public key from Authentik periodically, so key rotation in Authentik doesn’t require an MCP server restart.- MCP isn’t an API replacement — MCP is a bridge that lets AI clients call existing APIs without the user becoming a manual intermediary.