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:
- The client starts OAuth 2.1 + PKCE with AuthKit
- AuthKit redirects to our login page with an
external_auth_id - The user authenticates via our existing SSO
- The SSO callback calls the AuthKit completion API with user info
- AuthKit issues tokens and redirects back to the client
- The client sends a Bearer token on each
POST /mcprequest - 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:
- Client ID Metadata Document (CIMD): the client presents an HTTPS URL
as its
client_id. Claude uses this. - Dynamic Client Registration (RFC 7591): the client POSTs to AuthKit's
/oauth2/register. Perplexity uses this.
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:
/.well-known/oauth-protected-resource/mcp: the RFC 9728 §3.1 form, the well-known suffix inserted between host and resource path./.well-known/oauth-protected-resource: the root form clients probe as a fallback./mcp/.well-known/oauth-protected-resource: path-append rather than path-insert. Not RFC 9728, but Perplexity probes it, so I serve it too.
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:
- the resource identifier (
https://host/mcp). AuthKit stamps this when the client sends an RFC 8707resourceparameter. Claude does. - the WorkOS project id. AuthKit's default
audfor a client that omits theresourceparameter. Perplexity does.
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:
- DNS-rebinding guard. I reject a request whose
Originhost doesn't match the canonical host. A missingOrigin(non-browser clients) is allowed; a present one is validated. I compare host only, not scheme: behind a TLS-terminating proxy that dropsX-Forwarded-Proto, the server seeshttpwhile the browser sends anhttpsOrigin, and requiring equality would 403 legitimate clients. - Protocol-version pin. Every non-
initializePOST must carry a matchingMCP-Protocol-Versionheader.initializeis exempt because the transport negotiates the version in that request body.
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
(initialize → notifications/initialized → tools/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:
- Old container stops, all client connections drop
- New container starts with updated tool definitions
- The client detects disconnect, reconnects automatically
- Reconnect triggers
initialize→tools/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:
initialize: returns protocol version and capabilitiesnotifications/initialized: acknowledged with202, no bodytools/list: returns definitions for all registered toolstools/call: dispatches to the named tool
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:
- Algorithm: RS256 (
rsa.VerifyPKCS1v15over a SHA-256 hash) - Validates: issuer, audience, expiration, not-before, issued-at
- JWKS are fetched lazily and cached
- On an unknown
kid(key rotation), I re-fetch JWKS but at most once per 5 minutes to prevent cache-busting attacks from tokens with randomkidvalues
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.