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:
- The client starts OAuth 2.1 + PKCE with AuthKit
- AuthKit redirects to our login page with an
external_auth_id - The user authenticates through 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) 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:
- 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.
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:
/.well-known/oauth-protected-resource/mcp: the RFC 9728 §3.1 form./.well-known/oauth-protected-resource: the root form that clients probe as a fallback./mcp/.well-known/oauth-protected-resource: not in RFC 9728, but Perplexity probes it.
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:
- The resource identifier (
https://host/mcp), when the client sends an RFC 8707resourceparameter. Claude does. - The WorkOS project ID, the default when a client omits that parameter. Perplexity does.
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:
- DNS-rebinding guard. I reject a request whose
Originhost does not match the canonical host, and permit a request with noOrigin. I compare the host only, because a proxy that dropsX-Forwarded-Protochanges the scheme. - Protocol-version check. Every
POSTother thaninitializemust carry a matchingMCP-Protocol-Versionheader.initializenegotiates the version in its body.
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:
- The old container stops and drops active client connections
- The new container starts with updated tool definitions
- The client detects the disconnect and reconnects automatically
- Reconnection triggers
initialize→tools/listto 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:
initialize: returns protocol version and capabilitiesnotifications/initialized: acknowledged with202, no bodytools/list: returns definitions for all registered toolstools/call: dispatches to the named tool
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:
- Algorithm: RS256 (
rsa.VerifyPKCS1v15over a SHA-256 hash). - Claims: issuer, audience, expiration, not-before, and issued-at.
- The verifier fetches JWKS on first use and caches them.
- On an unknown
kid, it re-fetches at most once every 5 minutes, so a randomkidcannot bust the cache.
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.