Todas las contribuciones
Ingenieríamcpgopostgres

MCP desde cero: servidor en Go con Postgres, Gmail y Calendar en 30 minutos

Guía práctica para implementar un servidor Model Context Protocol en Go usando mcp-go, con integración a Postgres (row-level security), Gmail API y Google Calendar. Incluye OAuth, tests y despliegue a Cloudflare Workers.

Numoru EngineeringPublicado el 31 de mayo de 202617 min de lectura
Compartir
Propuesta de implementacióngithub.com/numoru-ia/mcp-office-assistant

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.

~30 min
Primer servidor funcionando
Clonar, correr, conectar
Claude + Cursor + más
Clientes compatibles
15+ en ecosistema
$4.5-12k
Build MCP por cliente
Integraciones de tools internas
<$30
Costo Cloudflare Workers / mes
Por cliente volumen moderado

Por qué Go para MCP

Los ejemplos oficiales de MCP están en TypeScript y Python. Go gana en tres escenarios:

  1. Concurrencia natural — muchos tools consultan APIs externas en paralelo.
  2. Distribución como binario — un solo ejecutable que no requiere runtime. Ideal para stdio transport.
  3. Type safety sin runtime overhead — menos any y 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

  1. Tokens opacos, nunca JWTs con claims. Un JWT se puede leer; un token opaco sólo es resolvible contra nuestra DB.
  2. Scope mínimo en OAuth Google. Gmail: sólo gmail.modify; no pedimos drive salvo que el producto lo necesite.
  3. Rate limit por tenant. Redis + sliding window; por defecto 60 tool calls/min/tenant.
  4. Logs sin PII en claro. Hash phone numbers, emails y nombres antes de loggear.

Impacto de negocio

Business & commercial impact

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.

Illustrative caseSaaS B2B · 25 ingenieros · $12M ARR · EE.UU. + México

Equipo internal-tools SaaS B2B mid-size integrando Claude Code + su data

Baseline
Ingenieros gastaban ~6 h / semana cada uno copiando data entre Postgres, Gmail y Calendar hacia prompts de Claude Code. Errores, inconsistencia, sin audit trail.
Intervention
Numoru entregó un servidor MCP de 3 tools (Postgres RLS, Gmail, Calendar) desplegado en Cloudflare Workers. Engagement de 6 semanas. Agentes operan con acceso autorizado y auditable.
Projected outcome (12 mo)
Tiempo copy-paste ahorrado
−145 h / mes
25 × ~6 h
Costo
$12,000
Build one-time
Infra (Cloudflare + Postgres)
$48 / mes
Estado estable
Incidentes
0
RLS previene leak cross-tenant
$ eng ahorrado / año
+$160,080
145 h × $92 × 12
Payback
1 mes
Conservador
Horas ahorradas calibradas a una encuesta informal de productividad; costos a rate cards Cloudflare + Numoru. Caso sintético.

ROI de un MCP de 3 tools para un equipo (12 meses)

Payback: < 1
Assumptions
Ingenieros usando el MCP25
Horas ahorradas / ing / sem5.8
Hourly blended$92
Costo build MCP$12,000 one-time
Cloudflare Workers + Postgres$48 / mes
Retainer opcional$450 / mes
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
Build single-source
$4,500one-time
Un adapter MCP (Postgres o 1 SaaS).
  • OAuth 2.1 + refresh
  • RLS (si Postgres)
  • Config Docker + Workers
  • Tests e2e
  • Warranty 30 días
Pack 3 fuentes
$12,000one-time
Combo CRM + DB + email.
  • 3 servidores MCP
  • Auth + idempotencia compartidas
  • Deploy Cloudflare Workers
  • Tests + runbook
  • +1 semana soporte
Ops enterprise
$1,200/ mes
Ops + adds de nuevas fuentes.
  • 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.

¿Quieres resultados así para tu empresa?

Iniciar conversación
Compartir