MCP Server and Its Implementation with Go
22 min read

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
  end

There are two transports supported by MCP:

TransportWhen UsedHow It Works
stdioLocal tools, IDE plugins, Claude DesktopThe AI client spawns the MCP server as a subprocess, communicating via stdin/stdout
SSE (HTTP)Remote servers, multi-client, productionThe 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: CallToolResult

Component 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:#04342C

Repository 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-service in this project doesn’t do any auth validation — it’s assumed to be on an internal network only accessible by mcp-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:#3E2723

When 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.go and that’s the only place to change when access rules change.
  • The backend service is completely separate from the MCP layerstore.ReportStore knows 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 refreshkeyfunc refreshes 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.

Portfolio