TL;DR
Guía práctica para implementar un servidor Model Context Protocol (MCP) en Go usando la librería mcp-go. Conectamos una base de datos Postgres con row-level security, la API de Gmail y Google Calendar, todo tras OAuth 2.1. El servidor expone tres resources y ocho tools consumibles por Claude Desktop, Cursor, Windsurf y cualquier cliente MCP. Deploy doble: docker compose local y Cloudflare Workers (MCP sobre streamable-http). Incluye tests e2e con cliente MCP real, idempotencia por operation_id y estructura de proyecto pensada para escalar.
Por qué Go para MCP
Los ejemplos oficiales de MCP están en TypeScript y Python. Go gana en tres escenarios:
- Concurrencia natural — muchos tools consultan APIs externas en paralelo.
- Distribución como binario — un solo ejecutable que no requiere runtime. Ideal para stdio transport.
- Type safety sin runtime overhead — menos
anyy más contratos verificables.
Vamos a usar github.com/mark3labs/mcp-go (actualmente el MCP SDK para Go más maduro).
Estructura del proyecto
mcp-office-assistant/
├── cmd/
│ ├── server/main.go # entrypoint stdio/http
│ └── worker/main.go # entrypoint Cloudflare Workers (via workers-sdk-go)
├── internal/
│ ├── auth/ # OAuth 2.1 + token refresh
│ ├── tools/
│ │ ├── postgres.go
│ │ ├── gmail.go
│ │ └── calendar.go
│ ├── resources/
│ │ └── schema.go
│ ├── storage/
│ │ └── operations.go # tabla idempotencia
│ └── transport/
│ ├── stdio.go
│ └── http.go
├── migrations/
├── tests/e2e/
├── Dockerfile
├── wrangler.toml
└── go.mod
Bootstrap del servidor
// cmd/server/main.go
package main
import (
"context"
"log/slog"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/numoru-ia/mcp-office/internal/auth"
"github.com/numoru-ia/mcp-office/internal/tools"
)
func main() {
cfg := loadConfig()
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
authSvc := auth.New(cfg.DatabaseURL, cfg.OAuthClientID, cfg.OAuthClientSecret)
mcpServer := server.NewMCPServer(
"numoru-office-assistant",
"0.2.0",
server.WithToolCapabilities(true),
server.WithResourceCapabilities(true),
server.WithPromptCapabilities(true),
server.WithLogging(),
)
tools.RegisterPostgres(mcpServer, cfg, authSvc)
tools.RegisterGmail(mcpServer, cfg, authSvc)
tools.RegisterCalendar(mcpServer, cfg, authSvc)
transport := os.Getenv("MCP_TRANSPORT")
switch transport {
case "http":
addr := ":8080"
logger.Info("serving mcp over http", "addr", addr)
server.NewStreamableHTTPServer(mcpServer).Start(addr)
default:
logger.Info("serving mcp over stdio")
server.ServeStdio(mcpServer)
}
}
Autenticación: OAuth 2.1 con refresh
internal/auth/auth.go:
package auth
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
type Service struct {
pool *pgxpool.Pool
oauthConfig *oauth2.Config
}
type Principal struct {
UserID string
TenantID string
GoogleEmail string
Token *oauth2.Token
}
func New(dbURL, clientID, clientSecret string) *Service {
pool, _ := pgxpool.New(context.Background(), dbURL)
return &Service{
pool: pool,
oauthConfig: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: google.Endpoint,
Scopes: []string{
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
},
},
}
}
func (s *Service) ValidateToken(ctx context.Context, opaqueToken string) (*Principal, error) {
row := s.pool.QueryRow(ctx, `
SELECT user_id, tenant_id, google_email, oauth_access_token, oauth_refresh_token, oauth_expires_at
FROM mcp_principals
WHERE opaque_token_hash = digest($1, 'sha256')
AND revoked_at IS NULL
`, opaqueToken)
var p Principal
var accessToken, refreshToken string
var expiresAt time.Time
if err := row.Scan(&p.UserID, &p.TenantID, &p.GoogleEmail, &accessToken, &refreshToken, &expiresAt); err != nil {
return nil, err
}
p.Token = &oauth2.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
Expiry: expiresAt,
}
if time.Until(p.Token.Expiry) < 5*time.Minute {
newTok, err := s.oauthConfig.TokenSource(ctx, p.Token).Token()
if err != nil {
return nil, err
}
s.persistToken(ctx, p.UserID, newTok)
p.Token = newTok
}
return &p, nil
}
Tools: Postgres con row-level security
internal/tools/postgres.go:
package tools
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func RegisterPostgres(s *server.MCPServer, cfg Config, authSvc *auth.Service) {
s.AddTool(mcp.NewTool(
"pg.query",
mcp.WithDescription("Ejecuta SELECT sobre tablas permitidas. Respeta RLS por tenant."),
mcp.WithString("sql", mcp.Required(), mcp.Description("Sólo SELECT")),
mcp.WithNumber("limit", mcp.Description("Máximo 500 filas"), mcp.DefaultNumber(100)),
), queryHandler(cfg, authSvc))
s.AddTool(mcp.NewTool(
"pg.execute",
mcp.WithDescription("INSERT/UPDATE/DELETE sobre tablas permitidas con idempotencia."),
mcp.WithString("sql", mcp.Required()),
mcp.WithObject("params"),
mcp.WithString("operation_id", mcp.Required(),
mcp.Description("Clave idempotente UUID; misma llave no re-ejecuta")),
), executeHandler(cfg, authSvc))
s.AddTool(mcp.NewTool(
"pg.describe_schema",
mcp.WithDescription("Lista tablas y columnas visibles para este tenant."),
), describeSchemaHandler(cfg, authSvc))
}
var selectOnly = regexp.MustCompile(`(?is)^\s*SELECT\b`)
var allowedTables = map[string]bool{
"contacts": true, "deals": true, "invoices": true, "notes": true,
}
func queryHandler(cfg Config, a *auth.Service) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
principal, err := authFromCtx(ctx, a)
if err != nil {
return nil, err
}
sql := req.Params.Arguments["sql"].(string)
if !selectOnly.MatchString(sql) {
return nil, errors.New("pg.query sólo acepta SELECT")
}
if err := ensureAllowedTables(sql, allowedTables); err != nil {
return nil, err
}
tx, err := cfg.Pool.Begin(ctx)
if err != nil { return nil, err }
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SET LOCAL app.current_tenant = $1", principal.TenantID); err != nil {
return nil, err
}
rows, err := tx.Query(ctx, sql)
if err != nil { return nil, err }
result := collectRows(rows, 500)
b, _ := json.Marshal(result)
return mcp.NewToolResultText(string(b)), tx.Commit(ctx)
}
}
La política de RLS en Postgres:
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON contacts
USING (tenant_id::text = current_setting('app.current_tenant', true));
Incluso si el LLM inyecta SELECT * FROM contacts WHERE true OR tenant_id = 'otro', la policy filtra.
Tools: Gmail
func RegisterGmail(s *server.MCPServer, cfg Config, a *auth.Service) {
s.AddTool(mcp.NewTool(
"gmail.search",
mcp.WithDescription("Busca hilos de correo con query tipo Gmail."),
mcp.WithString("query", mcp.Required(), mcp.Description("Ej: 'from:[email protected] is:unread'")),
mcp.WithNumber("max", mcp.DefaultNumber(20)),
), gmailSearchHandler(cfg, a))
s.AddTool(mcp.NewTool(
"gmail.send",
mcp.WithDescription("Envía un correo desde la cuenta del usuario."),
mcp.WithString("to", mcp.Required()),
mcp.WithString("subject", mcp.Required()),
mcp.WithString("body", mcp.Required()),
mcp.WithString("operation_id", mcp.Required()),
), gmailSendHandler(cfg, a))
}
func gmailSendHandler(cfg Config, a *auth.Service) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
principal, err := authFromCtx(ctx, a)
if err != nil { return nil, err }
args := req.Params.Arguments
opID := args["operation_id"].(string)
if existing, _ := cfg.Ops.Get(ctx, principal.TenantID, opID); existing != nil {
return mcp.NewToolResultText(existing.ResultJSON), nil
}
svc, _ := gmail.NewService(ctx, option.WithTokenSource(cfg.OAuth.TokenSource(ctx, principal.Token)))
msg := buildMimeMessage(args["to"].(string), args["subject"].(string), args["body"].(string), principal.GoogleEmail)
sent, err := svc.Users.Messages.Send("me", msg).Do()
if err != nil { return nil, err }
result := map[string]any{"id": sent.Id, "thread_id": sent.ThreadId}
b, _ := json.Marshal(result)
cfg.Ops.Save(ctx, principal.TenantID, opID, "gmail.send", string(b))
return mcp.NewToolResultText(string(b)), nil
}
}
Tabla de operaciones idempotentes
CREATE TABLE mcp_operations (
tenant_id uuid NOT NULL,
operation_id text NOT NULL,
tool text NOT NULL,
result_json jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, operation_id)
);
Esto evita enviar dos veces el mismo correo cuando el LLM decide "reintentar por si acaso".
Tools: Calendar
func RegisterCalendar(s *server.MCPServer, cfg Config, a *auth.Service) {
s.AddTool(mcp.NewTool(
"calendar.find_slot",
mcp.WithDescription("Encuentra huecos de 30/45/60 min entre fechas, respetando zona horaria."),
mcp.WithString("timezone", mcp.DefaultString("America/Mexico_City")),
mcp.WithString("from"), mcp.WithString("to"),
mcp.WithNumber("duration_min", mcp.DefaultNumber(30)),
), findSlotHandler(cfg, a))
s.AddTool(mcp.NewTool(
"calendar.create_event",
mcp.WithDescription("Crea evento con invitados."),
mcp.WithString("title", mcp.Required()),
mcp.WithString("start_iso", mcp.Required()),
mcp.WithString("end_iso", mcp.Required()),
mcp.WithArray("attendees"),
mcp.WithString("operation_id", mcp.Required()),
), createEventHandler(cfg, a))
}
find_slot usa el endpoint freeBusy.query de Google Calendar y un algoritmo simple que busca ventanas contiguas libres en los calendarios relevantes.
Resources
Resources son datos leíbles sin efectos colaterales. Exponemos:
s.AddResource(mcp.NewResource(
"pg://schema/public",
"Esquema Postgres (tablas visibles)",
mcp.WithMIMEType("application/json"),
), schemaResourceHandler(cfg, authSvc))
s.AddResource(mcp.NewResource(
"gmail://labels",
"Labels de Gmail del usuario",
mcp.WithMIMEType("application/json"),
), gmailLabelsResourceHandler(cfg, authSvc))
s.AddResource(mcp.NewResource(
"calendar://calendars",
"Lista de calendarios accesibles",
mcp.WithMIMEType("application/json"),
), calendarsResourceHandler(cfg, authSvc))
Prompts reutilizables
s.AddPrompt(mcp.NewPrompt(
"draft_follow_up",
mcp.WithPromptDescription("Genera un follow-up profesional a partir de un hilo"),
mcp.WithPromptArgument("thread_id", mcp.ArgumentDescription("ID del hilo Gmail"), mcp.RequiredArgument()),
mcp.WithPromptArgument("tone", mcp.ArgumentDescription("friendly | formal")),
), draftFollowUpHandler(cfg, authSvc))
Claude Desktop los muestra como acciones rápidas ("Redactar follow-up"). Menos fricción que "dime cómo redactar".
Transporte HTTP y Cloudflare Workers
El binario soporta dos modos:
- stdio — para Claude Desktop local (default).
- streamable-http — para deploys en servidor o serverless.
Para Cloudflare Workers usamos workers-sdk-go + adaptador HTTP:
// cmd/worker/main.go
//go:build wasm
package main
import (
"github.com/syumai/workers"
"github.com/mark3labs/mcp-go/server"
)
func main() {
mcpServer := buildServer()
workers.Serve(server.NewStreamableHTTPServer(mcpServer))
}
wrangler.toml:
name = "mcp-office-numoru"
main = "./build/worker.wasm"
compatibility_date = "2026-03-01"
[[d1_databases]]
binding = "DB"
database_name = "mcp-office"
database_id = "..."
[vars]
OAUTH_CLIENT_ID = "..."
Deploy: wrangler deploy. Endpoint: https://mcp-office-numoru.workers.dev/mcp.
Probarlo desde Claude Desktop
~/.config/Claude/claude_desktop_config.json:
{
"mcpServers": {
"numoru-office": {
"command": "/usr/local/bin/mcp-office-assistant",
"env": {
"DATABASE_URL": "postgres://...",
"GOOGLE_CLIENT_ID": "...",
"GOOGLE_CLIENT_SECRET": "...",
"MCP_OPAQUE_TOKEN": "..."
}
}
}
}
Reinicias Claude Desktop y ves los tools listos.
Tests e2e
func TestCreateEventIdempotent(t *testing.T) {
srv := testutil.StartInMemoryServer(t)
client, _ := stdio.Dial(srv.Cmd())
args := map[string]any{
"title": "Demo Numoru",
"start_iso": "2026-04-22T15:00:00-06:00",
"end_iso": "2026-04-22T15:30:00-06:00",
"attendees": []string{"[email protected]"},
"operation_id": "op-demo-1",
}
r1, _ := client.CallTool(ctx, "calendar.create_event", args)
r2, _ := client.CallTool(ctx, "calendar.create_event", args)
assert.Equal(t, r1.Text, r2.Text) // mismo resultado, no duplicó
}
La suite corre en CI con GitHub Actions y levanta un Postgres de pruebas + servicios mock de Gmail/Calendar.
Observabilidad
Cada tool call se traza a Langfuse. Middleware:
func traceMiddleware(next server.ToolHandlerFunc, name string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
span := langfuse.StartSpan(ctx, name)
res, err := next(ctx, req)
span.End(time.Since(start), err)
return res, err
}
}
En Langfuse vemos latencia por tool, errores agrupados y usage por tenant.
Seguridad operativa
- Tokens opacos, nunca JWTs con claims. Un JWT se puede leer; un token opaco sólo es resolvible contra nuestra DB.
- Scope mínimo en OAuth Google. Gmail: sólo
gmail.modify; no pedimosdrivesalvo que el producto lo necesite. - Rate limit por tenant. Redis + sliding window; por defecto 60 tool calls/min/tenant.
- Logs sin PII en claro. Hash phone numbers, emails y nombres antes de loggear.
Impacto de negocio
Dónde se vende esto
La mayoría de proyectos IA enterprise se frena cuando el agente tiene que mover data en sistemas internos. Construir adapters MCP puntuales es la forma más rápida de desbloquearlos. Numoru lo vende como trabajo de integración fixed-price: $4,500 por MCP de una fuente (Postgres RLS o un SaaS), $12,000 por pack de 3 fuentes (CRM + DB + email) típico de SaaS B2B.
Equipo internal-tools SaaS B2B mid-size integrando Claude Code + su data
ROI de un MCP de 3 tools para un equipo (12 meses)
| Build (one-time) | −$12,000 |
| Infra + retainer (12 mo × $498) | −$5,976 |
| Tiempo eng recapturado (25 × 5.8 × 48 × $92) | +$640,320 |
| Contribución neta año 1 | +$622,344 |
- OAuth 2.1 + refresh
- RLS (si Postgres)
- Config Docker + Workers
- Tests e2e
- Warranty 30 días
- 3 servidores MCP
- Auth + idempotencia compartidas
- Deploy Cloudflare Workers
- Tests + runbook
- +1 semana soporte
- Monitoreo + patching
- 1 nuevo MCP / trimestre
- SLA 99.5%
- On-call en lanzamientos
FAQ
¿Es compatible con OpenAI Agents SDK o sólo con Claude?MCP es protocolo abierto. Funciona con cualquier cliente MCP (Claude Desktop, Claude Code, Cursor, Windsurf, OpenAI Agents SDK vía shim).
¿Rendimiento con muchos tools?El servidor Go aguanta 500+ tools declarados sin problema; el cuello típico es el límite de contexto del LLM al listar tools (~50-70 tools bien descritos antes de que empiece a confundirse).
¿Puedo usar esto para un SaaS multi-tenant?Sí, es exactamente el patrón. tenant_id viene del token opaco; RLS lo enforce.
¿Por qué mcp-go y no construir el protocolo yo mismo?MCP tiene reglas finas (streaming, capabilities negotiation, error codes). Reimplementar es semanas de trabajo innecesario.
¿Dónde pongo las credenciales Google?OAuth 2.1 estándar. Creas app en Google Cloud Console, guardas client_id y client_secret como env. Los refresh tokens de usuarios viven en Postgres cifrados con pgcrypto.
Próximos pasos
El repo completo está en github.com/numoru-ia/mcp-office-assistant. Forma parte del conjunto de MCP templates numoru/mcp-templates-es. La siguiente pieza de la serie agrega un MCP para WhatsApp Business + CFDI a esta base, cerrando el loop de operación de una PyME mexicana.