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.
Why Go for MCP
The official MCP samples live in TypeScript and Python. Go wins in three scenarios:
- Natural concurrency — many tools query external APIs in parallel.
- Distribution as a binary — a single executable with no runtime required. Ideal for stdio transport.
- Type safety with no runtime overhead — less
anyand 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
- Opaque tokens, never JWTs with claims. A JWT can be read; an opaque token is only resolvable against our DB.
- Minimum OAuth scope on Google. Gmail: only
gmail.modify; we don't requestdriveunless the product needs it. - Per-tenant rate limit. Redis + sliding window; default 60 tool calls/min/tenant.
- Logs without plaintext PII. Hash phone numbers, emails and names before logging.
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).
Mid-size B2B SaaS internal-tools team integrating Claude Code + their data
ROI of a 3-tool MCP build for an engineering team (12 months)
| 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 |
- OAuth 2.1 + refresh
- RLS (if Postgres)
- Docker + Workers config
- E2e tests
- 30-day warranty
- 3 MCP servers
- Shared auth + idempotency
- Cloudflare Workers deploy
- Tests + runbook
- +1 week support
- 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.