Backend AI • 15 min read

Building an MCP Server in Go: Connect AI Models to Your APIs

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Sep 21, 2026

Model Context Protocol (MCP) is an open protocol from Anthropic that lets AI models like Claude interact with external tools and data sources in a standardized way. Instead of hardcoding logic into prompts or building custom integrations for each AI platform, you expose your APIs as MCP tools that AI can automatically discover and use.

This guide walks through building a complete MCP server in Go that connects to an existing Books API, handling everything from tool registration to production deployment.

What is MCP and Why Build a Server?

Imagine you have a Books API with these endpoints:

  • GET /books - List books with pagination
  • GET /books/:id - Get book details
  • GET /books/search?q=... - Search books
  • GET /books/stats - Get statistics

The traditional approach: Copy-paste data into prompts or write custom integrations for each AI platform.

With MCP: Build once, and AI models automatically:

  • Discover available tools (list_books, get_book, search_books, get_book_stats)
  • Understand input schemas (parameters, types, validation)
  • Call tools and process JSON responses
  • Chain multiple tool calls to solve complex queries

Real-world use cases:

  • User: “Find all architecture books published after 2020” → AI calls search_books with filters
  • User: “Compare these two books” → AI calls get_book twice and analyzes
  • User: “How many books are in the database?” → AI calls get_book_stats

The AI handles the logic; you just expose the tools.

Architecture Overview

┌─────────────────────────────────────────────┐
│       Claude Desktop / MCP Inspector        │
│              (MCP Client)                   │
└────────────────┬────────────────────────────┘
                 │ 
                 │ MCP Protocol (stdio/SSE/HTTP)
                 │ JSON-RPC 2.0 messages
                 ▼
        ┌────────────────────┐
        │   MCP Server       │
        │   (Go)             │
        │                    │
        │   Tools:           │
        │   • list_books     │
        │   • get_book       │
        │   • search_books   │
        │   • get_book_stats │
        │                    │
        │   Auth: Bearer     │
        └────────┬───────────┘
                 │
                 │ gRPC / REST / GraphQL
                 │ (Your existing API)
                 ▼
        ┌────────────────┐
        │  Books API     │
        │  (Backend)     │
        └────────────────┘

How it works:

  1. Tool Discovery: Claude calls tools/list → MCP server returns available tools + schemas
  2. Tool Execution: Claude calls tools/call with tool name + arguments → MCP server validates, calls backend API, returns result
  3. AI Processing: Claude receives JSON response and synthesizes answer for user

Part 1: Project Setup

Initialize Go Module

mkdir book-mcp-server
cd book-mcp-server
go mod init github.com/yourusername/book-mcp-server

# Install dependencies
go get github.com/mark3labs/mcp-go@latest
go get github.com/urfave/cli/v2
go get connectrpc.com/connect  # If using gRPC backend

Dependencies explained:

  • mark3labs/mcp-go: Official Go SDK for MCP
  • urfave/cli/v2: CLI framework for parsing flags
  • connectrpc.com/connect: Modern gRPC client (if backend uses gRPC)

Project Structure

book-mcp-server/
├── cmd/
│   └── server/
│       └── main.go           # Entry point
├── internal/
│   ├── client/
│   │   └── book_client.go    # API client wrapper
│   ├── tools/
│   │   └── handler.go        # MCP tools registration
│   └── auth/
│       └── validator.go      # Token authentication
├── go.mod
└── go.sum

Part 2: Connect to Backend API

Assuming you have an existing Books API (gRPC or REST), create a client wrapper.

Option A: gRPC Backend (with Connect)

If your backend uses gRPC with Connect protocol:

// internal/client/book_client.go
package client

import (
	"context"
	"fmt"
	"net/http"

	"connectrpc.com/connect"
	booksv1 "github.com/yourusername/book-mcp-server/gen/books/v1"
	"github.com/yourusername/book-mcp-server/gen/books/v1/booksv1connect"
)

type BookClient struct {
	client booksv1connect.BookServiceClient
}

func NewBookClient(baseURL string) *BookClient {
	httpClient := &http.Client{}
	client := booksv1connect.NewBookServiceClient(httpClient, baseURL)

	return &BookClient{client: client}
}

func (c *BookClient) ListBooks(ctx context.Context, page, limit int32, query string) ([]Book, int64, error) {
	req := connect.NewRequest(&booksv1.ListBooksRequest{
		Page:  page,
		Limit: limit,
		Query: &query,
	})

	res, err := c.client.ListBooks(ctx, req)
	if err != nil {
		return nil, 0, fmt.Errorf("failed to list books: %w", err)
	}

	books := make([]Book, len(res.Msg.Books))
	for i, b := range res.Msg.Books {
		books[i] = Book{
			ID:       b.Id,
			Title:    b.Title,
			Author:   b.Author,
			Year:     b.Year,
			Category: b.Category,
		}
	}

	return books, res.Msg.Total, nil
}

func (c *BookClient) GetBook(ctx context.Context, id int32) (*Book, error) {
	req := connect.NewRequest(&booksv1.GetBookRequest{Id: id})

	res, err := c.client.GetBook(ctx, req)
	if err != nil {
		return nil, fmt.Errorf("failed to get book: %w", err)
	}

	book := res.Msg.GetBook()
	return &Book{
		ID:       book.Id,
		Title:    book.Title,
		Author:   book.Author,
		Year:     book.Year,
		Category: book.Category,
	}, nil
}

Option B: REST API Backend

If your backend is a REST API:

// internal/client/book_client.go
package client

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
)

type Book struct {
	ID       int32  `json:"id"`
	Title    string `json:"title"`
	Author   string `json:"author"`
	Year     *int32 `json:"year,omitempty"`
	Category string `json:"category,omitempty"`
}

type ListBooksResponse struct {
	Books      []Book `json:"books"`
	Total      int64  `json:"total"`
	Page       int32  `json:"page"`
	TotalPages int32  `json:"total_pages"`
}

type BookClient struct {
	baseURL    string
	httpClient *http.Client
}

func NewBookClient(baseURL string) *BookClient {
	return &BookClient{
		baseURL:    baseURL,
		httpClient: &http.Client{},
	}
}

func (c *BookClient) ListBooks(ctx context.Context, page, limit int32, query string) ([]Book, int64, error) {
	u, err := url.Parse(fmt.Sprintf("%s/api/v1/books", c.baseURL))
	if err != nil {
		return nil, 0, err
	}

	q := u.Query()
	q.Set("page", fmt.Sprintf("%d", page))
	q.Set("limit", fmt.Sprintf("%d", limit))
	if query != "" {
		q.Set("q", query)
	}
	u.RawQuery = q.Encode()

	req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
	if err != nil {
		return nil, 0, err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, 0, fmt.Errorf("API returned status %d", resp.StatusCode)
	}

	var result ListBooksResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, 0, err
	}

	return result.Books, result.Total, nil
}

func (c *BookClient) GetBook(ctx context.Context, id int32) (*Book, error) {
	url := fmt.Sprintf("%s/api/v1/books/%d", c.baseURL, id)

	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
	}

	var book Book
	if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
		return nil, err
	}

	return &book, nil
}

Part 3: Build the MCP Server

Initialize MCP Server

// cmd/server/main.go
package main

import (
	"log"
	"os"

	"github.com/mark3labs/mcp-go/server"
	"github.com/yourusername/book-mcp-server/internal/client"
	"github.com/yourusername/book-mcp-server/internal/tools"
	"github.com/urfave/cli/v2"
)

func main() {
	app := &cli.App{
		Name:    "book-mcp-server",
		Usage:   "MCP Server for Books API",
		Version: "1.0.0",
		Flags: []cli.Flag{
			&cli.StringFlag{
				Name:    "api-url",
				Value:   "http://localhost:8080",
				Usage:   "Backend API URL",
				EnvVars: []string{"BOOKS_API_URL"},
			},
			&cli.StringFlag{
				Name:    "port",
				Value:   "4000",
				Usage:   "Port to listen on",
				EnvVars: []string{"MCP_PORT"},
			},
		},
		Action: runServer,
	}

	if err := app.Run(os.Args); err != nil {
		log.Fatal(err)
	}
}

func runServer(ctx *cli.Context) error {
	apiURL := ctx.String("api-url")
	port := ctx.String("port")

	log.Printf("Starting MCP server on port %s", port)
	log.Printf("Backend API: %s", apiURL)

	// Create API client
	bookClient := client.NewBookClient(apiURL)

	// Create MCP server
	mcpServer := server.NewMCPServer(
		"Books MCP Server",
		"1.0.0",
	)

	// Register tools
	toolsHandler := tools.NewHandler(bookClient)
	toolsHandler.RegisterTools(mcpServer)

	// Start HTTP server (Streamable SSE endpoint)
	httpServer := server.NewStreamableHTTPServer(mcpServer)
	
	log.Printf("MCP endpoint: http://localhost:%s/mcp", port)
	return httpServer.Start(":" + port)
}

Key points:

  • server.NewMCPServer(): Creates MCP server with name and version
  • server.NewStreamableHTTPServer(): Creates HTTP server with SSE (Server-Sent Events) transport
  • The /mcp endpoint handles MCP protocol messages (JSON-RPC over SSE)

Part 4: Register MCP Tools

This is the core part - defining tools that AI can use.

Tool Handler Structure

// internal/tools/handler.go
package tools

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/yourusername/book-mcp-server/internal/client"
)

type Handler struct {
	client *client.BookClient
}

func NewHandler(client *client.BookClient) *Handler {
	return &Handler{client: client}
}

func (h *Handler) RegisterTools(s *server.MCPServer) {
	h.registerListBooks(s)
	h.registerGetBook(s)
	h.registerSearchBooks(s)
}

Tool 1: List Books

func (h *Handler) registerListBooks(s *server.MCPServer) {
	// Define tool schema
	tool := mcp.NewTool("list_books",
		mcp.WithDescription("List books with pagination. Returns a list of books with total count and page info."),
		mcp.WithNumber("page",
			mcp.Required(),
			mcp.Description("Page number (starting from 1)"),
		),
		mcp.WithNumber("limit",
			mcp.Required(),
			mcp.Description("Number of books per page (1-100)"),
		),
	)

	// Define handler function
	handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
		// Parse arguments (JSON-RPC sends numbers as float64)
		page := int32(arguments["page"].(float64))
		limit := int32(arguments["limit"].(float64))

		// Validate inputs
		if page < 1 {
			return mcp.NewToolResultError("page must be >= 1"), nil
		}
		if limit < 1 || limit > 100 {
			return mcp.NewToolResultError("limit must be between 1 and 100"), nil
		}

		// Call backend API
		books, total, err := h.client.ListBooks(ctx, page, limit, "")
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("Failed to list books: %v", err)), nil
		}

		// Build response
		response := map[string]interface{}{
			"books": books,
			"total": total,
			"page":  page,
			"limit": limit,
		}

		// Format as JSON
		jsonData, err := json.MarshalIndent(response, "", "  ")
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil
		}

		// Return to AI
		return mcp.NewToolResultText(string(jsonData)), nil
	}

	// Register tool + handler
	s.AddTool(tool, handler)
}

Understanding the code:

  1. Tool Definition (mcp.NewTool):

    • First arg: tool name (used by AI to call it)
    • WithDescription(): Explains what the tool does (AI reads this to decide when to use it)
    • WithNumber(), WithString(): Define input parameters with types
    • mcp.Required(): Mark parameter as required
  2. Handler Function:

    • Receives context.Context and arguments map[string]interface{}
    • Parse arguments (JSON-RPC sends numbers as float64, so cast them)
    • Validate inputs before calling backend
    • Call backend API with context
    • Return JSON response with mcp.NewToolResultText()
  3. Error Handling:

    • Use mcp.NewToolResultError() for user-facing errors
    • AI will read the error message and might retry with different arguments
    • Return (result, nil) not (nil, err) - framework errors are rare

Tool 2: Get Book by ID

func (h *Handler) registerGetBook(s *server.MCPServer) {
	tool := mcp.NewTool("get_book",
		mcp.WithDescription("Get detailed information about a specific book by ID"),
		mcp.WithNumber("id",
			mcp.Required(),
			mcp.Description("Book ID"),
		),
	)

	handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
		id := int32(arguments["id"].(float64))

		if id < 1 {
			return mcp.NewToolResultError("id must be >= 1"), nil
		}

		book, err := h.client.GetBook(ctx, id)
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("Failed to get book: %v", err)), nil
		}

		jsonData, err := json.MarshalIndent(book, "", "  ")
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil
		}

		return mcp.NewToolResultText(string(jsonData)), nil
	}

	s.AddTool(tool, handler)
}

Tool 3: Search Books

func (h *Handler) registerSearchBooks(s *server.MCPServer) {
	tool := mcp.NewTool("search_books",
		mcp.WithDescription("Search books by query (searches in title and author fields). Supports pagination."),
		mcp.WithString("query",
			mcp.Required(),
			mcp.Description("Search query (e.g., 'architecture', 'Martin Fowler')"),
		),
		mcp.WithNumber("page",
			mcp.Description("Page number (default: 1)"),
		),
		mcp.WithNumber("limit",
			mcp.Description("Results per page (default: 10, max: 100)"),
		),
	)

	handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
		query := arguments["query"].(string)
		
		// Optional parameters with defaults
		page := int32(1)
		limit := int32(10)

		if pageVal, ok := arguments["page"]; ok && pageVal != nil {
			page = int32(pageVal.(float64))
		}

		if limitVal, ok := arguments["limit"]; ok && limitVal != nil {
			limit = int32(limitVal.(float64))
		}

		// Validate
		if query == "" {
			return mcp.NewToolResultError("query cannot be empty"), nil
		}
		if limit > 100 {
			return mcp.NewToolResultError("limit cannot exceed 100"), nil
		}

		books, total, err := h.client.ListBooks(ctx, page, limit, query)
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil
		}

		response := map[string]interface{}{
			"books": books,
			"total": total,
			"page":  page,
			"limit": limit,
			"query": query,
		}

		jsonData, err := json.MarshalIndent(response, "", "  ")
		if err != nil {
			return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil
		}

		return mcp.NewToolResultText(string(jsonData)), nil
	}

	s.AddTool(tool, handler)
}

Handling optional parameters:

// Default value
page := int32(1)

// Check if parameter exists and is not nil
if pageVal, ok := arguments["page"]; ok && pageVal != nil {
	page = int32(pageVal.(float64))
}

Token-based Auth Middleware

// internal/auth/validator.go
package auth

import (
	"context"
	"fmt"
	"os"

	"github.com/mark3labs/mcp-go/mcp"
)

type TokenValidator struct {
	validToken string
}

func NewTokenValidator() *TokenValidator {
	token := os.Getenv("MCP_TOKEN")
	if token == "" {
		token = "default-secret-token" // Dev only!
	}
	return &TokenValidator{validToken: token}
}

func (v *TokenValidator) Validate(token string) error {
	if token != v.validToken {
		return fmt.Errorf("invalid token")
	}
	return nil
}

// Middleware wrapper for tool handlers
func WithAuth(validator *TokenValidator, handler func(context.Context, map[string]interface{}) (*mcp.CallToolResult, error)) func(context.Context, map[string]interface{}) (*mcp.CallToolResult, error) {
	return func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
		// Check for auth token (could be passed via arguments or context)
		token := os.Getenv("MCP_TOKEN")
		if t, ok := arguments["_auth_token"].(string); ok && t != "" {
			token = t
		}

		if err := validator.Validate(token); err != nil {
			return mcp.NewToolResultError("Authentication failed"), nil
		}

		// Remove auth token from arguments before passing to handler
		delete(arguments, "_auth_token")

		return handler(ctx, arguments)
	}
}

Usage in tool registration:

func (h *Handler) registerGetBook(s *server.MCPServer) {
	tool := mcp.NewTool("get_book", ...)

	// Wrap handler with auth
	handler := auth.WithAuth(h.validator, func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
		id := int32(arguments["id"].(float64))
		book, err := h.client.GetBook(ctx, id)
		// ...
	})

	s.AddTool(tool, handler)
}

Part 6: Testing and Deployment

Local Testing with MCP Inspector

The MCP Inspector is an official tool for testing MCP servers:

# Terminal 1: Start your MCP server
go run cmd/server/main.go --api-url http://localhost:8080 --port 4000

# Terminal 2: Run inspector
npx @modelcontextprotocol/inspector@latest http://localhost:4000/mcp

Inspector UI opens at http://localhost:5173:

  1. Tools tab: View all available tools
  2. Test tool: Select tool, fill arguments, click “Run”
  3. View response: JSON output from backend

Test cases:

// list_books
{
  "page": 1,
  "limit": 10
}

// get_book
{
  "id": 1
}

// search_books
{
  "query": "architecture",
  "page": 1,
  "limit": 5
}

Testing with Claude Desktop

Add to Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

{
  "mcpServers": {
    "books": {
      "command": "/path/to/book-mcp-server",
      "args": [
        "--api-url", "http://localhost:8080",
        "--port", "4000"
      ],
      "env": {
        "MCP_TOKEN": "your-secret-token"
      }
    }
  }
}

Restart Claude Desktop. You’ll see a tools icon. Test with:

  • “List 5 books from the database”
  • “Search for books about ‘distributed systems’”
  • “Tell me about book ID 3”
  • “Find books by Martin Fowler”

Docker Deployment

# Dockerfile
FROM golang:1.23-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o mcp-server ./cmd/server

FROM alpine:3.19
RUN apk --no-cache add ca-certificates

WORKDIR /app
COPY --from=builder /app/mcp-server .

EXPOSE 4000
ENTRYPOINT ["./mcp-server"]
# docker-compose.yml
version: '3.8'

services:
  mcp-server:
    build: .
    ports:
      - "4000:4000"
    environment:
      - BOOKS_API_URL=http://backend:8080
      - MCP_TOKEN=${MCP_TOKEN}
      - MCP_PORT=4000
    depends_on:
      - backend

  backend:
    image: your-books-api:latest
    ports:
      - "8080:8080"

Deploy:

export MCP_TOKEN="production-secret-token"
docker-compose up -d

# Test
curl http://localhost:4000/mcp

Best Practices

1. Descriptive Tool Names and Descriptions

Good:

mcp.NewTool("search_books",
	mcp.WithDescription("Search books by query (searches in title and author). Returns paginated results with total count."),
)

Bad:

mcp.NewTool("search",  // Too generic
	mcp.WithDescription("Search"),  // Not informative
)

AI needs clear descriptions to decide when to use each tool.

2. Input Validation

Always validate before calling backend:

if page < 1 {
	return mcp.NewToolResultError("page must be >= 1"), nil
}
if limit < 1 || limit > 100 {
	return mcp.NewToolResultError("limit must be between 1 and 100"), nil
}

3. Clear Error Messages

Return descriptive errors for AI:

// ❌ Bad
return mcp.NewToolResultError("error"), nil

// ✅ Good
return mcp.NewToolResultError("Failed to fetch book: book ID 999 not found"), nil

4. Structured JSON Responses

AI processes JSON best. Always return well-formatted JSON:

response := map[string]interface{}{
	"books": books,
	"total": total,
	"page":  page,
}

jsonData, err := json.MarshalIndent(response, "", "  ")
return mcp.NewToolResultText(string(jsonData)), nil

5. Context Propagation

Pass context to support timeouts and cancellation:

handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
	// ctx has timeout from MCP framework
	result, err := h.client.GetBook(ctx, id)  // ✅ Pass ctx
	// ...
}

6. Pagination Defaults

Provide sensible defaults:

page := int32(1)   // Default page
limit := int32(10) // Default limit

if pageVal, ok := arguments["page"]; ok && pageVal != nil {
	page = int32(pageVal.(float64))
}

7. Logging for Debugging

Log tool calls:

import "log"

handler := func(ctx context.Context, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
	log.Printf("[MCP] get_book called with id=%v", arguments["id"])
	
	book, err := h.client.GetBook(ctx, id)
	if err != nil {
		log.Printf("[MCP] get_book failed: %v", err)
		return mcp.NewToolResultError(fmt.Sprintf("Failed: %v", err)), nil
	}
	
	log.Printf("[MCP] get_book success: %s", book.Title)
	return mcp.NewToolResultText(string(jsonData)), nil
}

Common Pitfalls

1. Type Assertions

JSON-RPC sends numbers as float64:

// ❌ Wrong - will panic!
id := arguments["id"].(int32)

// ✅ Correct
id := int32(arguments["id"].(float64))

2. Optional Parameters

Check existence before accessing:

// ❌ Wrong - panics if not provided
page := arguments["page"].(float64)

// ✅ Correct
page := int32(1)  // Default
if pageVal, ok := arguments["page"]; ok && pageVal != nil {
	page = int32(pageVal.(float64))
}

3. Error Returns

Understand the difference:

// ❌ Wrong - framework error (rare, for connection issues)
return nil, fmt.Errorf("book not found")

// ✅ Correct - tool execution error (user-facing)
return mcp.NewToolResultError("Book not found"), nil

Return nil, err only for internal framework errors.

4. Large Responses

MCP has no built-in response pagination. Limit at tool level:

if limit > 100 {
	return mcp.NewToolResultError("Maximum limit is 100 to prevent large responses"), nil
}

5. Context Cancellation

Respect context cancellation for long-running operations:

select {
case <-ctx.Done():
	return mcp.NewToolResultError("Request cancelled or timed out"), nil
default:
	result, err := h.client.LongRunningOperation(ctx)
	// ...
}

Conclusion

You now have a production-ready MCP server that:

  1. Exposes your API to AI: No more prompt engineering with hardcoded data
  2. Type-safe tool definitions: Schema validation with mcp.NewTool()
  3. Proper error handling: Clear messages for AI to understand
  4. Authentication: Token-based auth middleware
  5. Testing tools: Inspector UI and Claude Desktop integration
  6. Production deployment: Docker with health checks

Key takeaways:

  • MCP standardizes how AI models interact with external tools
  • mark3labs/mcp-go SDK handles protocol complexity
  • Tool descriptions must be clear - AI uses them to decide when to call
  • Validate inputs at MCP layer before hitting backend
  • JSON responses work best with AI models
  • Test with Inspector before deploying to Claude Desktop

Next steps:

  • Add write operations (create_book, update_book, delete_book)
  • Implement rate limiting per API key
  • Add caching layer for frequently-accessed data
  • Monitor tool usage with metrics (call count, latency, errors)
  • Build multi-API MCP server (books + users + orders)

Resources:

go golang mcp ai llm claude api-integration
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.