MCP Server dan Implementasinya dengan Go
21 min read

MCP Server dan Implementasinya dengan Go

AI yang canggih tidak berguna jika ia tidak bisa mengambil data nyata atau menjalankan aksi di sistem yang sudah ada. Model Context Protocol (MCP) hadir untuk menjawab persis masalah itu — sebuah standar terbuka yang memungkinkan AI client seperti Claude berkomunikasi dengan service eksternal secara terstruktur, aman, dan konsisten. Artikel ini membangun MCP Server lengkap dari nol menggunakan Go, terhubung ke backend service laporan keuangan, dilengkapi pengecekan RBAC berbasis JWT dari Authentik, dan dua tool yang menunjukkan perbedaan akses antar role.

Apa itu MCP dan Mengapa Go?

MCP bukan sebuah library — ia adalah sebuah protokol. Sama seperti HTTP mendefinisikan bagaimana browser berkomunikasi dengan server web, MCP mendefinisikan bagaimana AI client berkomunikasi dengan tool dan resource eksternal. Setiap MCP server mengekspos tiga jenis entitas: tools (fungsi yang bisa dipanggil AI), resources (data yang bisa dibaca AI), dan prompts (template instruksi). Dalam praktiknya, tool adalah yang paling sering digunakan karena memungkinkan AI menjalankan aksi nyata — bukan hanya membaca data.

Go dipilih karena beberapa alasan yang sangat praktis. Binary Go menghasilkan executable tunggal tanpa runtime dependency — cocok untuk MCP server yang di-deploy sebagai sidecar atau standalone process. Goroutine memudahkan penanganan multiple concurrent tool call tanpa kompleksitas threading manual. Dan ekosistem Go untuk HTTP, JSON, dan JWT sudah matang dengan library yang stabil.

BUTUH MCP jika:
  ✓ AI perlu membaca atau memanipulasi data dari sistem yang sudah ada
  ✓ Kamu ingin Claude bisa menjalankan aksi nyata (bukan hanya memberi saran)
  ✓ Ada banyak tool berbeda yang perlu diakses dari satu AI client
  ✓ Kamu butuh audit trail: siapa memanggil tool apa, kapan, dengan data apa
  ✓ Akses ke tool perlu dibatasi berdasarkan role atau permission user

TIDAK BUTUH MCP jika:
  ✗ AI hanya butuh menjawab pertanyaan berdasarkan training data
  ✗ Kamu sudah punya REST API dan user bisa paste hasilnya manual ke chat
  ✗ Interaksi bersifat satu arah dan tidak butuh chaining tool call

Cara Kerja MCP: Protocol dan Transport

Sebelum menulis satu baris kode, penting untuk memahami bagaimana MCP bekerja di level protocol. MCP menggunakan JSON-RPC 2.0 sebagai format pesan. Setiap interaksi antara AI client dan MCP server mengikuti pola request-response yang ketat, dengan method name yang sudah distandardisasi oleh spec MCP.

%%{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: Fase 1 — Inisialisasi
  C->>M: initialize (protocol version, capabilities)
  M-->>C: serverInfo, capabilities

  Note over C,M: Fase 2 — Tool Discovery
  C->>M: tools/list
  M-->>C: Array tools (name, description, inputSchema)

  Note over C,M: Fase 3 — Tool Execution
  C->>M: tools/call { name, arguments, Bearer JWT }
  M->>M: Validasi JWT + cek RBAC
  alt Permission OK
    M->>S: Request data laporan
    S-->>M: Data laporan
    M-->>C: CallToolResult { content }
  else Permission Ditolak
    M-->>C: Error: 403 Forbidden
  end

Ada dua transport yang didukung MCP:

TransportKapan DigunakanCara Kerja
stdioTool lokal, plugin IDE, Claude DesktopAI client spawn MCP server sebagai subprocess, komunikasi via stdin/stdout
SSE (HTTP)Remote server, multi-client, productionMCP server jalan sebagai HTTP server, AI client connect via Server-Sent Events

Artikel ini menggunakan SSE transport karena lebih realistis untuk skenario production — MCP server berjalan sebagai service terpisah yang bisa diakses oleh banyak client sekaligus.

MCP spec versi Juni 2025 mewajibkan MCP server menggunakan OAuth 2.1 dengan PKCE untuk autentikasi. MCP server hanya berperan sebagai resource server — ia memvalidasi token, bukan menerbitkannya. Authorization server eksternal (Authentik, Keycloak, Auth0) yang menerbitkan JWT.

Gambaran Project

Project dalam artikel ini terdiri dari dua repository terpisah yang mencerminkan dua pola penggunaan MCP yang paling umum di dunia nyata. Memisahkan keduanya bukan sekadar pilihan gaya — ini mencerminkan bagaimana MCP seharusnya digunakan: MCP server adalah lapisan adaptasi antara AI dan sistem yang sudah ada, bukan pengganti sistem itu sendiri.

Dua Model Tool dalam Satu MCP Server

Sebelum melihat struktur repository, penting untuk memahami perbedaan mendasar antara dua kategori tool yang akan dibangun:

MODEL A — Proxy ke Backend Service
─────────────────────────────────
AI Client
  → MCP Server (auth + RBAC)
    → report-service (REST API)
      → data/reports.json

Kapan digunakan:
  ✓ Data ada di sistem eksternal yang sudah berjalan
  ✓ Ada tim lain yang mengelola service tersebut
  ✓ Service sudah punya API dan perlu "dibungkus" untuk AI
  ✓ Butuh audit trail end-to-end

Contoh tool: list_reports, get_report


MODEL B — Self-Contained Logic
──────────────────────────────
AI Client
  → MCP Server (auth + RBAC)
    → Komputasi di dalam MCP Server itu sendiri
      (tidak ada network call ke service lain)

Kapan digunakan:
  ✓ Logic sederhana yang tidak butuh persistence
  ✓ Transformasi atau kalkulasi data yang sudah ada
  ✓ Agregasi dari hasil tool lain dalam satu sesi
  ✓ Validasi, formatting, atau konversi

Contoh tool: calculate_growth

Arsitektur Keseluruhan

%%{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: Autentikasi
  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 ke Service
  C->>M: tools/call list_reports
  M->>M: Validasi 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: Validasi JWT + RBAC check
  M->>M: Hitung persentase growth
  M-->>C: CallToolResult

Hubungan Antar Komponen

%%{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\nOAuth2 Provider]:::teal
  end

  subgraph REPO1["📦 repo: report-service"]
    direction TB
    RS[REST API\n:8080]:::green
    DB[(data/reports.json)]:::green
    RS --> DB
  end

  subgraph REPO2["📦 repo: mcp-server"]
    direction TB
    MS[MCP Server\n:3000]:::purple
    subgraph TOOLS["Tools"]
      direction LR
      T1[list_reports\nget_report\nModel A]:::amber
      T2[calculate_growth\nModel B]:::amber
    end
    MS --> TOOLS
  end

  C([AI Client\nClaude]):::purple

  C -->|"1. Login"| AK
  AK -->|"2. JWT"| C
  C -->|"3. tools/call + JWT"| MS
  MS -->|"4. validasi"| AK
  T1 -->|"5a. HTTP request"| RS
  T2 -.->|"5b. komputasi lokal\ntidak ada 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

Struktur Repository

Project ini terdiri dari dua repository dengan tanggung jawab yang sepenuhnya terpisah:

report-service/                  # Repo 1: Backend Service
  ├── main.go                    # HTTP server, route registration
  ├── go.mod
  ├── data/
  │   └── reports.json           # Storage laporan keuangan
  └── internal/
      ├── handler/
      │   └── reports.go         # HTTP handler: GET /reports, GET /reports/:id
      └── store/
          └── reports.go         # Baca dan tulis ke JSON

mcp-server/                      # Repo 2: MCP Server
  ├── main.go                    # MCP server, tool registration
  ├── go.mod
  ├── internal/
  │   ├── auth/
  │   │   └── jwt.go             # Validasi JWT dari Authentik
  │   ├── rbac/
  │   │   └── policy.go          # Mapping tool → permission
  │   ├── client/
  │   │   └── report_client.go   # HTTP client ke report-service (Model A)
  │   └── tools/
  │       ├── list_reports.go    # Tool: list — proxy ke report-service
  │       ├── get_report.go      # Tool: get — proxy ke report-service
  │       └── calculate_growth.go# Tool: kalkulasi — self-contained (Model B)
  └── config/
      └── config.go              # Konfigurasi dari env vars

Pemisahan ini mencerminkan realitas production: report-service bisa sudah ada sebelum MCP diperkenalkan ke sistem, dikelola oleh tim berbeda, atau digunakan juga oleh client lain di luar AI. mcp-server hanya membungkusnya — ia tidak memiliki data, tidak memiliki business logic laporan, dan bisa diganti atau diperbarui tanpa menyentuh report-service sama sekali.

Port dan Dependency Antar Service

Authentik        → :9000  (auth server, sudah jalan)
report-service   → :8080  (harus jalan sebelum mcp-server)
mcp-server       → :3000  (dibuka ke AI client)

AI Client hanya tahu tentang:
  - Authentik  (untuk login dan token)
  - mcp-server (untuk tool call)

AI Client tidak pernah tahu:
  - report-service (tersembunyi di balik mcp-server)
  - Cara data diambil atau disimpan
report-service dalam project ini tidak melakukan validasi auth apapun — ia diasumsikan berada di jaringan internal yang hanya bisa diakses oleh mcp-server. Di production, proteksi network-level (private VPC, service mesh, atau mTLS) menggantikan auth di layer ini.

Backend Service

Backend service dalam project ini menyimpan dan mengelola data laporan keuangan. Untuk kesederhanaan contoh, implementasinya menggunakan file JSON sebagai storage — sehingga kamu bisa langsung menjalankan project tanpa perlu setup database. Di production, layer ini bisa diganti dengan koneksi ke PostgreSQL, MySQL, atau REST API internal tanpa mengubah satu baris pun di MCP layer.

// data/reports.json
{
  "reports": [
    {
      "id": "rpt-001",
      "title": "Laporan Keuangan Q1 2025",
      "department": "finance",
      "period": "Q1 2025",
      "created_at": "2025-01-15T08:00:00Z",
      "created_by": "[email protected]",
      "status": "published",
      "summary": "Total pendapatan Q1 mencapai Rp 4.2 miliar, naik 12% dari Q4 2024.",
      "figures": {
        "revenue": 4200000000,
        "expenses": 2800000000,
        "net_profit": 1400000000,
        "growth_pct": 12.4
      }
    },
    {
      "id": "rpt-002",
      "title": "Laporan Keuangan Q2 2025",
      "department": "finance",
      "period": "Q2 2025",
      "created_at": "2025-04-10T09:30:00Z",
      "created_by": "[email protected]",
      "status": "published",
      "summary": "Pertumbuhan melambat di Q2 akibat kenaikan biaya operasional.",
      "figures": {
        "revenue": 4050000000,
        "expenses": 3100000000,
        "net_profit": 950000000,
        "growth_pct": -3.6
      }
    },
    {
      "id": "rpt-003",
      "title": "Laporan Operasional Semester 1",
      "department": "operations",
      "period": "H1 2025",
      "created_at": "2025-07-01T10:00:00Z",
      "created_by": "[email protected]",
      "status": "draft",
      "summary": "Efisiensi produksi meningkat 8% berkat implementasi sistem baru.",
      "figures": {
        "efficiency_pct": 8.2,
        "downtime_hours": 24,
        "output_units": 125000,
        "defect_rate_pct": 0.8
      }
    },
    {
      "id": "rpt-004",
      "title": "Laporan SDM Kuartal 2",
      "department": "hr",
      "period": "Q2 2025",
      "created_at": "2025-04-20T14:00:00Z",
      "created_by": "[email protected]",
      "status": "published",
      "summary": "Tingkat retensi karyawan 94%, rekrutmen 23 posisi baru.",
      "figures": {
        "headcount": 312,
        "new_hires": 23,
        "resignations": 18,
        "retention_pct": 94.2
      }
    }
  ]
}

Store layer membungkus semua operasi baca-tulis ke backend service:

// internal/store/reports.go
package store

import (
	"encoding/json"
	"fmt"
	"os"
	"sync"
	"time"
)

// Report merepresentasikan satu laporan dalam sistem
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 mengelola operasi baca-tulis ke 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("gagal membaca file: %w", err)
	}
	var db database
	if err := json.Unmarshal(data, &db); err != nil {
		return nil, fmt.Errorf("gagal 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("gagal encode JSON: %w", err)
	}
	return os.WriteFile(s.path, data, 0644)
}

// GetAll mengembalikan semua laporan
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 mengembalikan laporan berdasarkan 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("laporan dengan ID '%s' tidak ditemukan", id)
}

// GetByDepartment mengembalikan laporan berdasarkan departemen
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 menghapus laporan berdasarkan 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("laporan dengan ID '%s' tidak ditemukan", id)
	}
	db.Reports = newReports
	return s.save(db)
}

Auth Layer: Validasi JWT dari Authentik

Ini adalah bagian yang paling krusial. MCP server tidak mengelola login atau session — ia hanya menerima JWT yang sudah diterbitkan oleh Authentik dan memvalidasinya menggunakan JWKS endpoint publik. Setelah token valid, claims di dalamnya (khususnya mcp_permissions) digunakan untuk keputusan RBAC.

// internal/auth/jwt.go
package auth

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/MicahParks/keyfunc/v2"
	"github.com/golang-jwt/jwt/v5"
)

// Claims merepresentasikan payload JWT dari 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 memvalidasi JWT menggunakan JWKS dari Authentik
type Validator struct {
	jwks *keyfunc.JWKS
}

// NewValidator membuat Validator baru yang terhubung ke JWKS endpoint Authentik
func NewValidator(jwksURL string) (*Validator, error) {
	jwks, err := keyfunc.Get(jwksURL, keyfunc.Options{
		RefreshInterval: time.Hour,
		RefreshErrorHandler: func(err error) {
			// Log error refresh JWKS — jangan sampai server crash
			fmt.Printf("JWKS refresh error: %v\n", err)
		},
	})
	if err != nil {
		return nil, fmt.Errorf("gagal inisialisasi JWKS dari %s: %w", jwksURL, err)
	}
	return &Validator{jwks: jwks}, nil
}

// Validate memvalidasi token string dan mengembalikan claims jika valid
func (v *Validator) Validate(tokenStr string) (*Claims, error) {
	if tokenStr == "" {
		return nil, fmt.Errorf("token tidak boleh kosong")
	}

	claims := &Claims{}
	token, err := jwt.ParseWithClaims(tokenStr, claims, v.jwks.Keyfunc)
	if err != nil {
		return nil, fmt.Errorf("token tidak valid: %w", err)
	}
	if !token.Valid {
		return nil, fmt.Errorf("token sudah kedaluwarsa atau tidak valid")
	}

	return claims, nil
}

// ExtractFromContext mengambil Bearer token dari context MCP
// MCP client mengirim token via header Authorization: Bearer <token>
func ExtractFromContext(ctx context.Context) (string, error) {
	// mcp-go menyimpan authorization header di context dengan key ini
	authHeader, ok := ctx.Value("authorization").(string)
	if !ok || authHeader == "" {
		return "", fmt.Errorf("header Authorization tidak ditemukan di request")
	}

	const prefix = "Bearer "
	if !strings.HasPrefix(authHeader, prefix) {
		return "", fmt.Errorf("format Authorization harus 'Bearer <token>'")
	}

	token := strings.TrimPrefix(authHeader, prefix)
	if token == "" {
		return "", fmt.Errorf("token kosong setelah prefix Bearer")
	}

	return token, nil
}

RBAC Layer: Siapa Boleh Apa

Ini adalah titik di mana banyak developer keliru. MCP server tidak secara otomatis tahu bahwa role “admin” boleh menghapus laporan — kamu yang harus mendefinisikannya. RBAC layer ini menyimpan mapping eksplisit antara nama tool dan permission yang dibutuhkan untuk memanggilnya.

// internal/rbac/policy.go
package rbac

import "fmt"

// toolPermissions mendefinisikan permission yang dibutuhkan setiap tool.
// Ini adalah satu-satunya tempat yang perlu diubah ketika kamu menambah tool baru
// atau mengubah aturan akses.
var toolPermissions = map[string]string{
	"list_reports":   "report:read",
	"get_report":     "report:read",
	"delete_report":  "report:delete",
}

// Check memverifikasi apakah daftar permission user mencakup permission
// yang dibutuhkan untuk tool yang diminta.
func Check(toolName string, userPermissions []string) error {
	required, exists := toolPermissions[toolName]
	if !exists {
		// Tool tidak terdaftar di policy — tolak sebagai langkah pengamanan
		return fmt.Errorf("tool '%s' tidak terdaftar dalam policy", toolName)
	}

	for _, p := range userPermissions {
		if p == required {
			return nil // ✓ Akses diizinkan
		}
	}

	return fmt.Errorf(
		"akses ditolak: tool '%s' membutuhkan permission '%s'",
		toolName, required,
	)
}

// RequiredPermission mengembalikan permission yang dibutuhkan sebuah tool.
// Berguna untuk pesan error yang informatif.
func RequiredPermission(toolName string) string {
	if p, ok := toolPermissions[toolName]; ok {
		return p
	}
	return "unknown"
}

Perhatikan bahwa toolPermissions adalah map sederhana — tidak ada magic di sini. Jika kamu menambah tool baru seperti create_report, kamu cukup tambah satu baris:

// BENAR: selalu daftarkan permission untuk setiap tool baru
"create_report": "report:write",

// ANTI-PATTERN: membiarkan tool tanpa entry di policy
// Hasilnya: "tool tidak terdaftar dalam policy" → 403 otomatis
// Ini sebenarnya perilaku yang aman, tapi lebih baik eksplisit

Tool Implementation

Setiap tool adalah sebuah fungsi dengan signature yang spesifik: menerima context.Context dan mcp.CallToolRequest, mengembalikan *mcp.CallToolResult dan error. Pola autentikasi dan otorisasi di setiap tool selalu sama — extract token, validasi, cek RBAC, baru eksekusi logika bisnis.

Tool: list_reports dan 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 membungkus semua tool yang berhubungan dengan laporan
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 adalah helper internal yang melakukan auth + RBAC check.
// Semua tool memanggil ini sebagai langkah pertama.
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 // Pesan error dari RBAC sudah deskriptif
	}

	return claims, nil
}

// HandleListReports mengembalikan semua laporan yang tersedia.
// Membutuhkan 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
	}

	// Ambil filter opsional dari 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("gagal mengambil data: %v", err)), nil
	}

	// Buat summary untuk setiap laporan (tidak expose figures detail)
	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 mengembalikan detail lengkap satu laporan berdasarkan ID.
// Membutuhkan 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("parameter 'report_id' wajib diisi"), 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 menghapus laporan secara permanen dari file JSON.
// Membutuhkan permission: report:delete (hanya role admin)
func (t *ReportTools) HandleDeleteReport(
	ctx context.Context,
	req mcp.CallToolRequest,
) (*mcp.CallToolResult, error) {
	// RBAC check — hanya permission report:delete yang bisa sampai sini
	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("parameter 'report_id' wajib diisi"), nil
	}

	// Verifikasi laporan ada dulu sebelum dihapus
	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("gagal menghapus: %v", err)), nil
	}

	result := fmt.Sprintf(
		"Laporan '%s' (%s) berhasil dihapus oleh %s.",
		report.Title, report.ID, claims.Email,
	)

	return mcp.NewToolResultText(result), nil
}

Konfigurasi

Semua nilai yang bergantung pada environment (URL Authentik, path file data, port server) dibaca dari environment variable. Tidak ada nilai hardcoded yang menyebabkan masalah saat pindah antara development dan production.

// config/config.go
package config

import (
	"fmt"
	"os"
)

// Config menyimpan semua konfigurasi runtime MCP server
type Config struct {
	// AuthentikJWKSURL adalah URL JWKS endpoint dari OAuth2 provider di Authentik
	// Contoh: https://auth.company.com/application/o/mcp-reports/jwks/
	AuthentikJWKSURL string

	// DataPath adalah path ke file JSON yang menyimpan data laporan
	DataPath string

	// ServerPort adalah port HTTP tempat MCP server mendengarkan koneksi
	ServerPort string

	// ServerBaseURL adalah URL publik MCP server, digunakan oleh SSE transport
	ServerBaseURL string
}

// Load membaca konfigurasi dari environment variable dan memvalidasinya
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 wajib diisi")
	}

	return cfg, nil
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

Main: Menyatukan Semua Komponen

Entry point ini menginisialisasi semua dependency secara berurutan, lalu mendaftarkan semua tool beserta definisi schema inputnya ke 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 konfigurasi dari environment
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("Konfigurasi tidak valid: %v", err)
	}

	// 2. Inisialisasi JWT validator yang terhubung ke Authentik JWKS
	validator, err := auth.NewValidator(cfg.AuthentikJWKSURL)
	if err != nil {
		log.Fatalf("Gagal inisialisasi JWT validator: %v", err)
	}

	// 3. Inisialisasi store yang membaca data dari file JSON
	reportStore := store.NewReportStore(cfg.DataPath)

	// 4. Buat instance tool handler
	reportTools := tools.NewReportTools(reportStore, validator)

	// 5. Buat MCP server dengan metadata
	s := server.NewMCPServer(
		"Report MCP Server",
		"1.0.0",
		server.WithToolCapabilities(true),
	)

	// 6. Daftarkan tool list_reports dengan schema inputnya
	s.AddTool(
		mcp.NewTool("list_reports",
			mcp.WithDescription(
				"Menampilkan daftar semua laporan yang tersedia. "+
					"Gunakan filter 'department' untuk mempersempit hasil. "+
					"Membutuhkan permission report:read.",
			),
			mcp.WithString("department",
				mcp.Description("Filter berdasarkan departemen (finance, operations, hr). Kosongkan untuk semua departemen."),
			),
		),
		reportTools.HandleListReports,
	)

	// 7. Daftarkan tool get_report
	s.AddTool(
		mcp.NewTool("get_report",
			mcp.WithDescription(
				"Mengembalikan detail lengkap satu laporan berdasarkan ID, "+
					"termasuk angka-angka finansial atau operasional. "+
					"Gunakan list_reports dulu untuk mendapatkan ID yang valid. "+
					"Membutuhkan permission report:read.",
			),
			mcp.WithString("report_id",
				mcp.Required(),
				mcp.Description("ID laporan dalam format 'rpt-XXX', contoh: rpt-001"),
			),
		),
		reportTools.HandleGetReport,
	)

	// 8. Daftarkan tool delete_report (hanya admin)
	s.AddTool(
		mcp.NewTool("delete_report",
			mcp.WithDescription(
				"Menghapus laporan secara permanen dari sistem. "+
					"TINDAKAN INI TIDAK BISA DIBATALKAN. "+
					"Hanya tersedia untuk user dengan permission report:delete (role admin). "+
					"Gunakan get_report dulu untuk memverifikasi laporan yang akan dihapus.",
			),
			mcp.WithString("report_id",
				mcp.Required(),
				mcp.Description("ID laporan yang akan dihapus dalam format 'rpt-XXX'"),
			),
		),
		reportTools.HandleDeleteReport,
	)

	// 9. Jalankan server menggunakan SSE transport
	addr := fmt.Sprintf(":%s", cfg.ServerPort)
	httpServer := server.NewSSEServer(s,
		server.WithBaseURL(cfg.ServerBaseURL),
	)

	log.Printf("MCP Report Server berjalan di %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 gagal: %v", err)
	}
}

Menjalankan Project

Jalankan server dengan mengekspos environment variable yang dibutuhkan:

# Development — menggunakan Authentik lokal
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

Output yang diharapkan:

2025/07/15 10:00:00 MCP Report Server berjalan di 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

Untuk menguji tool call secara manual dengan curl:

# Test list_reports (perlu JWT yang valid dari Authentik)
curl -X POST http://localhost:3000/message \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <JWT_DARI_AUTHENTIK>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "list_reports",
      "arguments": {}
    }
  }'

Setup Authentik

Di sisi Authentik, kamu perlu membuat OAuth2 Provider dengan Property Mapping khusus yang menyuntikkan mcp_permissions ke dalam JWT berdasarkan group user. Buka Authentik → Customisation → Property Mappings → buat mapping baru dengan tipe “Scope Mapping”:

# Property Mapping di Authentik (Python expression)
# Nama mapping: "MCP Report Permissions"
# Scope name: "mcp"

user_groups = [g.name for g in request.user.ak_groups.all()]

permissions = []

# Admin mendapat semua permission
if "mcp-admin" in user_groups:
    permissions = ["report:read", "report:delete"]

# Finance dan Manager hanya bisa baca
elif any(g in user_groups for g in ["finance", "manager", "analyst"]):
    permissions = ["report:read"]

# User biasa tidak mendapat permission MCP apapun
# (token tetap valid tapi semua tool call akan ditolak)

return permissions

Tambahkan juga mapping untuk department agar MCP server bisa menyaring laporan berdasarkan departemen user:

# Property Mapping: "MCP Department"
# Scope name: "mcp"

# Ambil departemen dari attribute user di Authentik
return request.user.attributes.get("department", "")

Dengan konfigurasi ini, JWT yang diterbitkan Authentik untuk user di group “finance” akan berisi:

{
  "sub": "user-abc123",
  "email": "[email protected]",
  "mcp_permissions": ["report:read"],
  "department": "finance",
  "exp": 1234567890
}

Sedangkan JWT untuk user di group “mcp-admin”:

{
  "sub": "user-xyz789",
  "email": "[email protected]",
  "mcp_permissions": ["report:read", "report:delete"],
  "department": "it",
  "exp": 1234567890
}

MCP server membaca mcp_permissions ini tanpa perlu tahu apapun soal struktur group di Authentik. Authentik yang jadi sumber kebenaran tunggal tentang “siapa boleh apa” — MCP server hanya mengeksekusi kebijakan yang sudah dikodekan di sana.

Jangan pernah hardcode JWT secret atau private key di dalam kode. MCP server memvalidasi token menggunakan public key yang diambil dari JWKS endpoint Authentik — ia tidak pernah memegang secret apapun. Jika JWKS URL tidak bisa diakses saat startup, server gagal dengan error eksplisit daripada berjalan tanpa validasi token.

Alur Lengkap: Dari Login Sampai Data

Untuk memastikan pemahaman menyeluruh, berikut adalah flow lengkap yang terjadi ketika Claude memanggil tool get_report:

%%{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 dengan SSO| B[🔐 Authentik\nOAuth2 Provider]:::teal
    B -->|2. JWT berisi mcp_permissions| A
    A -->|3. tools/call get_report + Bearer JWT| C[⚙️ MCP Server Go]:::amber
    C -->|4. ExtractFromContext| D{Token ada?}:::decision
    D -->|Tidak| E([❌ 401 Unauthorized]):::red
    D -->|Ya| F[🔍 Validasi JWT\nvia JWKS]:::amber
    F -->|Signature invalid / expired| G([❌ 401 Invalid Token]):::red
    F -->|Valid| H[📋 Parse mcp_permissions\ndari claims]:::amber
    H -->|rbac.Check| I{report:read\nada?}:::decision
    I -->|Tidak| J([❌ 403 Forbidden]):::red
    I -->|Ya| K[🔎 store.GetByID]:::amber
    K -->|ID tidak ada| L([❌ 404 Not Found]):::red
    K -->|Laporan ditemukan| M[📂 Request ke\nBackend Service]:::green
    M -->|Data| N([✅ CallToolResult\nData laporan]):::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

Kapan Tidak Menggunakan MCP

MCP bukan solusi untuk semua skenario integrasi AI. Ada kasus di mana pendekatan lain lebih tepat.

Tetap gunakan MCP jika:
  ✓ AI butuh memanggil lebih dari satu tool dalam satu respons
  ✓ Kamu butuh audit log siapa memanggil tool apa
  ✓ Tool perlu dibatasi per user/role secara granular
  ✓ Kamu mengintegrasikan AI ke dalam workflow yang sudah ada

Pertimbangkan REST API biasa jika:
  ✗ User selalu copy-paste hasil API ke chat secara manual (tidak butuh otomasi)
  ✗ Hanya ada satu tool dengan satu fungsi yang sangat sederhana
  ✗ Tidak ada kebutuhan untuk chaining tool call

Pertimbangkan function calling langsung (tanpa MCP) jika:
  ✗ Kamu membangun aplikasi custom dengan SDK Anthropic sendiri
  ✗ Tidak perlu interoperabilitas dengan AI client lain
  ✗ Tool hanya digunakan dari satu aplikasi yang kamu kontrol penuh

Ringkasan

  • MCP adalah protocol, bukan library — ia mendefinisikan standar komunikasi antara AI client dan tool eksternal menggunakan JSON-RPC 2.0 di atas SSE atau stdio transport.
  • MCP server tidak punya “otak” otorisasi sendiri — ia hanya memvalidasi JWT dan mengeksekusi policy yang kamu definisikan. Authentik (atau IdP manapun) adalah sumber kebenaran tentang siapa boleh apa.
  • Mapping tool → permission harus eksplisit — tidak ada koneksi otomatis antara nama tool dan permission. Kamu mendefinisikannya di rbac/policy.go dan itulah satu-satunya tempat yang perlu diubah ketika aturan akses berubah.
  • Backend service sepenuhnya terpisah dari MCP layerstore.ReportStore tidak tahu apapun tentang MCP. Kamu bisa mengganti implementasi storage dengan PostgreSQL atau REST API internal tanpa mengubah satu baris kode di MCP handler.
  • Tool description harus presisi — Claude membaca deskripsi tool untuk memutuskan kapan memanggilnya. Deskripsi yang ambigu menyebabkan Claude salah pilih tool atau memanggil tool di waktu yang tidak tepat.
  • Setiap tool handler mengikuti pola yang sama — extract token → validasi JWT → cek RBAC → eksekusi logika bisnis. Konsistensi ini membuat audit dan debugging jauh lebih mudah.
  • JWKS refresh otomatiskeyfunc me-refresh public key dari Authentik secara berkala, sehingga key rotation di Authentik tidak membutuhkan restart MCP server.
  • MCP bukan pengganti API — MCP adalah jembatan yang memungkinkan AI client memanggil API yang sudah ada tanpa user menjadi perantara manual.

Portofolio