ai / mcp

I built a Model Context Protocol server so our team can query an internal tool from Claude or Perplexity.

MCP gives LLMs access to tools. A server exposes tools over JSON-RPC 2.0 via Streamable HTTP. Users reach it from any client: web, desktop, mobile, or browser extension.

The server is a few handlers in our web app: POST /mcp and public discovery routes.

Deployment

Admins add the MCP server as a Claude connector or a Perplexity connector. Individual users enable it in their own accounts.

Auth

I use WorkOS AuthKit to authenticate MCP clients. It bridges our SSO to the OAuth flow MCP clients expect.

The flow:

  1. The client starts OAuth 2.1 + PKCE with AuthKit
  2. AuthKit redirects to our login page with an external_auth_id
  3. The user authenticates through our existing SSO
  4. The SSO callback calls the AuthKit completion API with user info
  5. AuthKit issues tokens and redirects back to the client
  6. The client sends a Bearer token on each POST /mcp request
  7. My server verifies the JWT (expiry, issuer, audience) through JWKS

AuthKit is the authorization server. My server is the resource server. It verifies the bearer token on every request and maps the sub claim to a user id. Tokens last about 5 minutes. Clients refresh them.

Client registration

Clients register with AuthKit in two ways. I enable both in WorkOS:

With one off, the clients that depend on it fail before the first authenticated request.

Discovery

Clients find AuthKit through OAuth 2.0 Protected Resource Metadata. I serve a public metadata document that points to AuthKit:

// GET /.well-known/oauth-protected-resource[/mcp]
data := map[string]any{
  "resource":                 h.AbsoluteURL(r, "/mcp"),
  "authorization_servers":    []string{authkitDomain},
  "bearer_methods_supported": []string{"header"},
  "scopes_supported":         []string{"openid", "profile", "email"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)

My resource identifier has a path (https://host/mcp), so I serve metadata at three paths:

When POST /mcp returns 401, I set WWW-Authenticate to the §3.1 URL:

WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"

A strict §3.3 client rejects the root document because its resource value does not match the root URL.

Token audience

My verifier accepts two aud values:

Security

The bearer token check is the security boundary. A Cloudflare WAF rule also restricts POST /mcp to the published egress IP addresses of Claude and Perplexity. The /.well-known/* paths stay public.

Two more controls:

Observability

The User-Agent header identifies each client type. I log the client, the resolved user ID, and the JSON-RPC method in one line:

status=200 method=POST path=/mcp ms=589.5 u=DC client="Claude-User" rpc="tools/call"

The request logger runs outside the handler, so the handler writes the user and method onto the request after it verifies the token.

A failed authentication returns a 401 with no detail. The log line carries the reason and the unverified token claims:

detail="invalid audience" kid="..." aud="..." sub="..." iss="..."

The client controls User-Agent and the token fields, so I escape them with %q to prevent log forgery.

Stateless

Each POST /mcp request is independent. The bearer token authenticates it. The client holds the conversation history.

The specification supports stateful servers through Mcp-Session-Id headers and GET /mcp SSE streams. Those need session affinity, and a tool-query server does not. Clients repeat the handshake before each tool call, which costs milliseconds.

Tool changes

Tool definitions live in code and change on deploy:

  1. The old container stops and drops active client connections
  2. The new container starts with updated tool definitions
  3. The client detects the disconnect and reconnects automatically
  4. Reconnection triggers initializetools/list to fetch fresh definitions

The specification defines notifications/tools/list_changed for servers that change tools at runtime. I do not advertise it.

JSON-RPC dispatcher

The server handles four JSON-RPC 2.0 methods:

Transport errors map to HTTP status codes with the JSON-RPC error in the body: 400 on a parse failure, 404 on an unknown method, 500 on an unexpected tool error. An unknown tool name returns a result with isError set.

switch req.Method {
case "initialize":
  res := map[string]any{
    "protocolVersion": ProtocolVersion,
    "capabilities":    map[string]any{"tools": map[string]any{}},
    "serverInfo":      map[string]any{"name": "app", "version": "0.1.0"},
  }
  return http.StatusOK, jsonRPCSuccess(req.ID, res), nil

case "notifications/initialized":
  return http.StatusAccepted, nil, nil

case "tools/list":
  // marshal every registered tool's name, description, inputSchema

case "tools/call":
  t, ok := s.tools[params.Name]
  if !ok {
    return http.StatusOK, jsonRPCSuccess(req.ID, toolCallError("Unknown tool: %s", params.Name)), nil
  }
  result, err := t.Call(ctx, s.db, userID, params.Arguments)
  // wrap result in {content: [{type: "text", text: json}]}

default:
  return http.StatusNotFound, jsonRPCError(req.ID, -32601, "Method not found"), nil
}

Tool pattern

Each tool implements an interface:

type Tool interface {
  Name() string
  Description() string
  InputSchema() map[string]any
  Call(ctx context.Context, db *pgdb.DB, userID int64, args map[string]any) (any, error)
}

I register tools in an ordered slice and index them by name at startup:

var AllTools = []Tool{
  &SearchTool{},
  &DocsTool{},
  &UsersTool{},
  // more tools
}

Example:

type SearchTool struct{}

func (t *SearchTool) Name() string       { return "search" }
func (t *SearchTool) Description() string { return "Full-text search across records." }
func (t *SearchTool) InputSchema() map[string]any {
  return map[string]any{
    "type": "object",
    "properties": map[string]any{
      "query": map[string]any{"type": "string", "description": "Search query."},
      "page":  map[string]any{"type": "integer", "description": "Page number (default 1)."},
    },
    "required": []any{"query"},
  }
}

func (t *SearchTool) Call(ctx context.Context, db *pgdb.DB, userID int64, args map[string]any) (any, error) {
  // query database, return {rows: [...], next_page: 2}
}

To add a tool, I create the type, add it to AllTools, and write tests.

Tool descriptions teach the LLM how to chain tools. The users description says it resolves a name to a user ID that other tools require.

Docs tool

My favorite tool is docs. It serves Markdown files embedded with //go:embed, one per topic. A call without a topic returns the index.

//go:embed content/*.md
var docsContent embed.FS

The LLM calls docs to learn the domain before it answers. This gives it institutional knowledge without fine-tuning or RAG.

JWT verification

I verify JWTs against the AuthKit JWKS endpoint with the Go standard library, without a dependency:

if header.Alg != "RS256" {
  return nil, fmt.Errorf("unsupported algorithm: %s", header.Alg)
}
pubKey, err := v.Cache.GetPublicKey(ctx, header.Kid)
// verify signature, then claims:
if claims.Iss != v.Issuer { ... }
if !audienceAccepted(claims.Aud, v.Audiences) { ... }
if claims.Exp < now { ... }                   // expired
if claims.Nbf > 0 && claims.Nbf > now { ... } // not active yet
if claims.Iat > 0 && claims.Iat > now { ... } // issued in future

RFC 7519 permits aud as a string or an array. A custom UnmarshalJSON normalizes both into a slice:

type audienceClaim []string

func (a *audienceClaim) UnmarshalJSON(b []byte) error {
  var arr []string
  if err := json.Unmarshal(b, &arr); err == nil {
    *a = arr
    return nil
  }
  var s string
  if err := json.Unmarshal(b, &s); err != nil {
    return fmt.Errorf("aud: expected string or array: %w", err)
  }
  *a = []string{s}
  return nil
}

Pagination

A generic helper slices the page and returns the next page number without a separate count query:

const PageSize = 50

func Paginate[T any](rows []T, page int) (slice []T, nextPage *int) {
  if page < 1 {
    page = 1
  }
  // Clamp huge pages so (page-1)*PageSize can't overflow int
  // into a negative offset and panic on the slice bounds.
  if page > len(rows)/PageSize+1 {
    return []T{}, nil
  }
  offset := (page - 1) * PageSize
  if offset >= len(rows) {
    return []T{}, nil
  }
  end := offset + PageSize
  if end < len(rows) {
    next := page + 1
    return rows[offset:end], &next
  }
  return rows[offset:], nil
}

The client passes next_page as the page argument in the next request.

← All articles