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 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.
If I turn off one of the two ways, 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 send 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
records 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, shown in the code below.
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"},
"instructions": serverInstructions,
}
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: [...]}; see Result formats
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.
Server instructions
A tool description says what one tool does. It does not say which tool to call first, or what to call after it. So a client guessed the order.
The initialize result has an instructions field, in the spec
since 2025-03-26. A client that honors it puts the text in the
model's system prompt. GitHub measured a 25-point gain in workflow
adherence from the same field, all of it on the weaker model. My text
names:
- the entry point, and three call chains from it
- how to page to the end of a set
- how to keep a user id apart from a person id
- when to ask the person rather than pick for them
A test reads every tool name in the text and checks it against the registry. A rename cannot leave the guidance stale.
Read-only hints
tools/list carries a readOnlyHint annotation for each tool. The
hint is advisory, and it did not stop a model. One tool could write a
Google Sheet to the caller's Drive, and it reported
readOnlyHint: false. My instructions told the model to offer a Sheet
for a large result.
Claude called it when nobody asked, and 13 Sheets appeared in one
user's Drive. So no MCP tool writes now, and every tool reports
readOnlyHint: true. An instruction to a model is a suggestion. A
tool that cannot write is a rule.
Result formats
A tool result is a list of content parts, not one JSON blob. A list
tool takes a format of json, csv, or markdown. The CSV writer
puts a ' before a cell that starts with =, +, -, @, a tab,
or a carriage return. So a value from the database cannot run as a
spreadsheet formula. A number such as -12.5 stays as it is.
For a large set, a list tool takes deliver=download. It returns a
link to GET /mcp/download, which returns a CSV of every matching
row. The link opens in the user's browser and uses their web session
rather than the MCP token:
- A download holds at most 50,000 rows. The read asks for one row over the limit, so the server refuses a larger set instead of cutting it. The refusal asks for narrower filters.
- The response tells caches not to store it, because it holds one user's data.
- The path is at most 2,000 bytes, so the login page can keep it in a cookie and return the user to it after sign-in.
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 only the Go standard library:
- 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.