Todas as contribuições
Engenhariamcpgopostgres

MCP do zero: servidor em Go com Postgres, Gmail e Calendar em 30 minutos

Guia prático para implementar um servidor MCP em Go usando mcp-go, com integração a Postgres, Gmail API e Google Calendar.

Numoru EngineeringPublicado em 31 de maio de 202617 min de leitura
Compartilhar
Proposta de implementaçãogithub.com/numoru-ia/mcp-office-assistant

TL;DR

Practical guide to implementing a Model Context Protocol (MCP) server in Go using the mcp-go library. We connect a Postgres database with row-level security, the Gmail API and Google Calendar, all behind OAuth 2.1. The server exposes three resources and eight tools consumable by Claude Desktop, Cursor, Windsurf and any MCP client. Dual deploy: local docker compose and Cloudflare Workers (MCP over streamable-http). Includes e2e tests with a real MCP client, idempotency via operation_id and a project structure designed to scale.

~30 min
First working server
Clone, run, connect
Claude + Cursor + others
Compatible clients
15+ in ecosystem
$4.5-12k
Per-client MCP build
Internal tool integrations
<$30
Monthly Cloudflare Workers cost
Per customer at moderate volume

Why Go for MCP

The official MCP samples live in TypeScript and Python. Go wins in three scenarios:

  1. Natural concurrency — many tools query external APIs in parallel.
  2. Distribution as a binary — a single executable with no runtime required. Ideal for stdio transport.
  3. Type safety with no runtime overhead — less any and more verifiable contracts.

We'll use github.com/mark3labs/mcp-go (currently the most mature Go MCP SDK).

Project structure

mcp-office-assistant/
├── cmd/
│   ├── server/main.go         # stdio/http entrypoint
│   └── worker/main.go         # Cloudflare Workers entrypoint (via workers-sdk-go)
├── internal/
│   ├── auth/                  # OAuth 2.1 + token refresh
│   ├── tools/
│   │   ├── postgres.go
│   │   ├── gmail.go
│   │   └── calendar.go
│   ├── resources/
│   │   └── schema.go
│   ├── storage/
│   │   └── operations.go      # idempotency table
│   └── transport/
│       ├── stdio.go
│       └── http.go
├── migrations/
├── tests/e2e/
├── Dockerfile
├── wrangler.toml
└── go.mod

Server bootstrap

// 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)
    }
}

Authentication: OAuth 2.1 with 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 with 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("Run SELECT against allowlisted tables. Honors RLS by tenant."),
        mcp.WithString("sql", mcp.Required(), mcp.Description("SELECT only")),
        mcp.WithNumber("limit", mcp.Description("Max 500 rows"), mcp.DefaultNumber(100)),
    ), queryHandler(cfg, authSvc))

    s.AddTool(mcp.NewTool(
        "pg.execute",
        mcp.WithDescription("INSERT/UPDATE/DELETE against allowlisted tables with idempotency."),
        mcp.WithString("sql", mcp.Required()),
        mcp.WithObject("params"),
        mcp.WithString("operation_id", mcp.Required(),
            mcp.Description("UUID idempotency key; same key does not re-execute")),
    ), executeHandler(cfg, authSvc))

    s.AddTool(mcp.NewTool(
        "pg.describe_schema",
        mcp.WithDescription("Lists tables and columns visible to this 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 accepts SELECT only")
        }
        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)
    }
}

The RLS policy in Postgres:

ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON contacts
  USING (tenant_id::text = current_setting('app.current_tenant', true));

Even if the LLM injects SELECT * FROM contacts WHERE true OR tenant_id = 'other', the policy filters.

Tools: Gmail

func RegisterGmail(s *server.MCPServer, cfg Config, a *auth.Service) {
    s.AddTool(mcp.NewTool(
        "gmail.search",
        mcp.WithDescription("Search threads with a Gmail-style query."),
        mcp.WithString("query", mcp.Required(), mcp.Description("e.g. 'from:[email protected] is:unread'")),
        mcp.WithNumber("max", mcp.DefaultNumber(20)),
    ), gmailSearchHandler(cfg, a))

    s.AddTool(mcp.NewTool(
        "gmail.send",
        mcp.WithDescription("Send an email from the user's account."),
        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
    }
}

Idempotency table

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)
);

This prevents sending the same email twice when the LLM decides "let me retry just in case."

Tools: Calendar

func RegisterCalendar(s *server.MCPServer, cfg Config, a *auth.Service) {
    s.AddTool(mcp.NewTool(
        "calendar.find_slot",
        mcp.WithDescription("Find 30/45/60-min windows between dates, honoring timezone."),
        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("Create an event with attendees."),
        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 uses the Google Calendar freeBusy.query endpoint and a simple algorithm that searches contiguous free windows in the relevant calendars.

Resources

Resources are readable data with no side effects. We expose:

s.AddResource(mcp.NewResource(
    "pg://schema/public",
    "Postgres schema (visible tables)",
    mcp.WithMIMEType("application/json"),
), schemaResourceHandler(cfg, authSvc))

s.AddResource(mcp.NewResource(
    "gmail://labels",
    "User's Gmail labels",
    mcp.WithMIMEType("application/json"),
), gmailLabelsResourceHandler(cfg, authSvc))

s.AddResource(mcp.NewResource(
    "calendar://calendars",
    "List of accessible calendars",
    mcp.WithMIMEType("application/json"),
), calendarsResourceHandler(cfg, authSvc))

Reusable prompts

s.AddPrompt(mcp.NewPrompt(
    "draft_follow_up",
    mcp.WithPromptDescription("Generate a professional follow-up from a thread"),
    mcp.WithPromptArgument("thread_id", mcp.ArgumentDescription("Gmail thread ID"), mcp.RequiredArgument()),
    mcp.WithPromptArgument("tone", mcp.ArgumentDescription("friendly | formal")),
), draftFollowUpHandler(cfg, authSvc))

Claude Desktop surfaces them as quick actions ("Draft follow-up"). Less friction than "tell me how to write a follow-up."

HTTP transport and Cloudflare Workers

The binary supports two modes:

  • stdio — for local Claude Desktop (default).
  • streamable-http — for server or serverless deploys.

For Cloudflare Workers we use workers-sdk-go + HTTP adapter:

// 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.

Try it from 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": "..."
      }
    }
  }
}

Restart Claude Desktop and the tools show up.

E2e tests

func TestCreateEventIdempotent(t *testing.T) {
    srv := testutil.StartInMemoryServer(t)
    client, _ := stdio.Dial(srv.Cmd())

    args := map[string]any{
        "title": "Numoru demo",
        "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)  // same result, no duplicate
}

The suite runs in CI with GitHub Actions and brings up a test Postgres + mock Gmail/Calendar services.

Observability

Every tool call is traced to 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
    }
}

In Langfuse we see per-tool latency, grouped errors and per-tenant usage.

Operational security

  1. Opaque tokens, never JWTs with claims. A JWT can be read; an opaque token is only resolvable against our DB.
  2. Minimum OAuth scope on Google. Gmail: only gmail.modify; we don't request drive unless the product needs it.
  3. Per-tenant rate limit. Redis + sliding window; default 60 tool calls/min/tenant.
  4. Logs without plaintext PII. Hash phone numbers, emails and names before logging.

Business & commercial impact

Business & commercial impact

Where this sells

Most enterprise AI projects hit a wall when the agent has to actually move data in internal systems. Building one-off MCP adapters is the fastest way to unblock them. Numoru offers this as fixed-price integration work: $4,500 for a single-source MCP (Postgres RLS or one SaaS), $12,000 for a 3-source pack typical of a B2B SaaS (CRM + DB + email).

Illustrative caseB2B SaaS · 25 engineers · $12M ARR · USA + Mexico

Mid-size B2B SaaS internal-tools team integrating Claude Code + their data

Baseline
Engineers spent ~6 h/week each copying data between Postgres, Gmail and Calendar into Claude Code prompts. Errors, inconsistency, no audit trail.
Intervention
Numoru delivered a 3-tool MCP server (Postgres RLS, Gmail, Calendar) deployed on Cloudflare Workers. 6-week engagement. Now agents operate with authorized, auditable access.
Projected outcome (12 mo)
Engineer copy-paste time saved
−145 h / mo
25 × ~6 h
Cost
$12,000
One-time build
Infra (Cloudflare + Postgres)
$48 / mo
Steady state
Incidents
0
RLS prevents cross-tenant leak
Engineer time $ saved / yr
+$160,080
145 h × $92 × 12
Payback
1 month
Conservative
Hours saved anchored to an informal developer productivity survey; costs to Cloudflare + Numoru rate card. Synthetic case.

ROI of a 3-tool MCP build for an engineering team (12 months)

Payback: < 1
Assumptions
Engineers using the MCP25
Hours saved / eng / week5.8
Blended hourly$92
MCP build cost$12,000 one-time
Cloudflare Workers + Postgres$48 / mo
Optional retainer$450 / mo
Build (one-time)−$12,000
Infra + retainer (12 mo × $498)−$5,976
Eng time recaptured (25 × 5.8 × 48 × $92)+$640,320
Net year-1 contribution+$622,344
Single-source build
$4,500one-time
One MCP adapter (Postgres or 1 SaaS).
  • OAuth 2.1 + refresh
  • RLS (if Postgres)
  • Docker + Workers config
  • E2e tests
  • 30-day warranty
3-source pack
$12,000one-time
CRM + DB + email combo.
  • 3 MCP servers
  • Shared auth + idempotency
  • Cloudflare Workers deploy
  • Tests + runbook
  • +1 week support
Enterprise ops
$1,200/ month
Ops + new-source adds.
  • Monitoring + patching
  • 1 new MCP server / quarter
  • SLA 99.5%
  • On-call during launches

FAQ

Is it compatible with the OpenAI Agents SDK or only Claude?MCP is an open protocol. It works with any MCP client (Claude Desktop, Claude Code, Cursor, Windsurf, OpenAI Agents SDK via shim).

Performance with many tools?The Go server handles 500+ declared tools without issues; the typical bottleneck is the LLM's context limit when listing tools (~50-70 well-described tools before it starts getting confused).

Can I use this for a multi-tenant SaaS?Yes, that's exactly the pattern. tenant_id comes from the opaque token; RLS enforces it.

Why mcp-go and not build the protocol myself?MCP has fine-grained rules (streaming, capabilities negotiation, error codes). Re-implementing is weeks of unnecessary work.

Where do I put Google credentials?Standard OAuth 2.1. Create the app in Google Cloud Console, store client_id and client_secret as env. User refresh tokens live in Postgres encrypted with pgcrypto.

Next steps

The full repo is at github.com/numoru-ia/mcp-office-assistant. It's part of the numoru-ia/mcp-templates-es set. The next piece in the series adds MCP for WhatsApp Business + CFDI on top of this base, closing the loop on a Mexican SMB operation.

Quer resultados assim para sua empresa?

Iniciar conversa
Compartilhar