ai / mcp

This is how I built a Model Context Protocol server that lets our team query an internal tool from Claude or Perplexity.

MCP is a protocol for giving LLMs access to tools. A server exposes tools over JSON-RPC 2.0 via Streamable HTTP. Once connected, users reach it from any client surface: web, desktop, mobile, browser extension, or chat.

The server is a handful of handlers in our web app, POST /mcp plus a few 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 existing SSO to the OAuth flow that 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 via 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) via JWKS

AuthKit is the authorization server; my server is only the resource server. It never issues tokens or registers clients. It verifies the bearer token on every request and maps the sub claim to a user id.

AuthKit issues short-lived access tokens (~5 min). Clients refresh them automatically. When a user is deactivated, they can't obtain new tokens. An in-flight token stays valid until expiry, but standard offboarding (deactivate IdP + client + app) covers that window.

Client registration

Registration is an authorization-server protocol, so it lives at AuthKit, not on my resource server. Clients register two ways, and I enable both per environment in WorkOS so any client works:

When a mechanism is off, AuthKit omits the matching capability and clients that rely on it fail. For DCR that surfaced to the client as Server does not support automatic registration, and to me as no request past the initial unauthenticated 401.

Discovery

Clients find AuthKit via OAuth 2.0 Protected Resource Metadata. I serve a public metadata document pointing at 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 is a URL with a path (https://host/mcp), so I serve the metadata at three paths for cross-client discovery:

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

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

A §3.3-strict client that fetches the root document rejects it: the returned resource (.../mcp) would not match the identifier derived from the root URL. The path-insert URL makes the document validate.

Token audience

My verifier accepts two aud values, because clients disagree on what they put there:

WorkOS only stamps the resource-indicator aud when the client asks for it, so a single expected audience can't satisfy both clients: pinning to the resource URL breaks Perplexity, pinning to the project id breaks Claude. I trust both, since both are issued by my AuthKit project for my resource server.

I keep the WorkOS MCP resource indicator, the /.well-known metadata resource, and the verifier's resource audience in lockstep.

Security

The per-request OAuth bearer check is my whole security boundary. A valid token is required for every call, and that is the only thing standing between a caller and the tools.

Two smaller controls back up the bearer check:

Observability

MCP clients authenticate with a bearer token, so the User-Agent is what distinguishes one client from another (Claude vs Perplexity). I log it, along with the resolved user and the JSON-RPC method, on a single request line:

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

My request logger runs outside the handler and sees only session cookies, so a bearer-only request would otherwise log an empty user and no method. The handler records both back onto the request after it verifies the token and parses the body.

On auth failures I keep the 401 body generic, but fold the cause (expired, wrong audience, bad signature, unknown kid, invalid subject) into the same log line as a detail= suffix, alongside the token's unverified kid, aud, sub, and iss:

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

User-Agent and every token-derived field are client-controlled, so I %q-escape them when logging to prevent log-line forgery.

Stateless

I keep the server stateless. Each POST /mcp is independent: no session state between requests. Authentication is per-request via Bearer token. The client manages conversation history on its side.

The MCP spec supports stateful servers via Mcp-Session-Id headers and GET /mcp SSE streams for multi-step workflows or server-push notifications. Stateful servers are harder to deploy: session affinity, in-memory state lost on restart, horizontal scaling. None of that is needed for a tool-query server.

Clients re-run the full handshake (initializenotifications/initializedtools/list) before each tool call. This is consistent with stateless usage. The overhead is milliseconds.

Deploying tool changes

Tool definitions are code. They only change when I deploy. The deploy cycle is my notification mechanism:

  1. Old container stops, all client connections drop
  2. New container starts with updated tool definitions
  3. The client detects disconnect, reconnects automatically
  4. Reconnect triggers initializetools/list → fresh definitions

The MCP spec defines notifications/tools/list_changed for servers whose tools change at runtime without a restart. That doesn't apply here, so I don't advertise it.

JSON-RPC dispatcher

The server handles four JSON-RPC 2.0 methods:

I map transport-level problems to distinct HTTP statuses, with the JSON-RPC error still in the body so a compliant client can recover: a parse failure returns 400, an unknown method 404, an unexpected tool error 500. A well-formed tools/call naming an unknown tool or carrying invalid params comes back as a tools/call result with isError set, not a top-level error, since the request itself was valid.

switch req.Method {
case "initialize":
  res := map[string]any{
    "protocolVersion": ProtocolVersion,
    "capabilities":    map[string]any{"tools": map[string]any{}},
    "serverInfo":      map[string]any{"name": "eds", "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 is a type implementing a small 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 it by name at startup:

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

A tool is a struct with those four methods:

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 are where I teach the LLM how to chain tools together. For example, the users tool description says "Use this to resolve a person's name or initials to their user ID, which is required by tools like network_by_user." The LLM reads these descriptions and learns the composition order.

Docs tool

My favorite tool is docs. It has no database queries; just markdown files embedded at build time with //go:embed, one per topic, served verbatim. Calling it with no topic returns an index of all available topics so the LLM can discover what documentation exists before fetching one.

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

The LLM calls docs when it needs to understand the domain before answering a question. A lightweight way to embed institutional knowledge into the LLM without fine-tuning or RAG.

JWT verification

I verify JWTs against the AuthKit JWKS endpoint. The standard library has everything needed, so I decode and check by hand rather than pull 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

I check nbf and iat explicitly. A token whose nbf or iat is in the future should be rejected even when exp has not passed.

The aud claim is awkward: RFC 7519 permits either a single string or a JSON array of strings. I decode it with a custom UnmarshalJSON that normalizes both to a slice, then accept the token if any of its audiences is in the allow-list:

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

Tools that return lists page through an already-fetched result set. I wrote a generic helper that slices the page and reports the next page 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 sends next_page from one response as the page argument in the next request to paginate through results.

← All articles