ai / mcp
I built a Model Context Protocol server on our web framework that lets our team query our internal tool at work from Claude.
MCP is a protocol for giving LLMs access to tools. Our server exposes ~20 tools over JSON-RPC 2.0 via Streamable HTTP. Once connected, users access it from any Claude surface: web, desktop, mobile, Chrome extension, or Slack.
Deployment
IT admins add the MCP server as a Claude connector. Individual users enable it in their Claude accounts. One integration, many surfaces. Users love it.
Auth
We use WorkOS AuthKit with Standalone Connect so users see our familiar login page when authenticating from Claude.
The flow:
- Claude starts OAuth 2.1 + PKCE with AuthKit
- AuthKit redirects to our login page with an
external_auth_id - User authenticates via our existing SSO
- SSO callback calls the AuthKit completion API with user info
- AuthKit issues tokens and redirects back to Claude
- Claude sends a Bearer token on each
POST /mcprequest - Our server verifies the JWT (expiry, issuer, audience) via JWKS
If the user is already logged in when Claude starts the flow, we skip re-authentication and complete immediately.
Tokens
AuthKit issues short-lived access tokens (~5 min). Claude refreshes 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 + Claude + app) covers that window.
Security
POST /mcp is restricted to
Anthropic's outbound IPs
via a Cloudflare WAF rule.
No other traffic reaches the endpoint.
The SSO callback validates the AuthKit redirect_uri;
only *.workos.com and *.authkit.app domains are allowed.
Defense-in-depth against open redirect.
JSON-RPC dispatcher
The server handles four JSON-RPC 2.0 methods:
initialize: returns protocol version and capabilitiesnotifications/initialized: acknowledged, no responsetools/list: returns definitions for all registered toolstools/call: dispatches to the named tool
Tools are registered in a hash:
TOOLS = {
"search" => Tools::Search,
"docs" => Tools::Docs,
# ~20 tools
}.freeze
Each tools/call looks up the class, instantiates with db and user_id,
and calls it:
def call_tool(id, params, user_id: nil)
name = params["name"]
args = params["arguments"] || {}
klass = TOOLS[name]
result = klass.new(@db, user_id).call(args)
success(id, {
content: [{type: "text", text: JSON.generate(result)}]
})
end
Tool pattern
Each tool is a class with two methods:
self.definition: returns the MCP tool schema (name, description, input JSON Schema)call(args): runs the tool and returns a result hash
class Search
def self.definition
{
name: "search",
description: "Full-text search across records.",
inputSchema: {
type: "object",
properties: {
query: {type: "string", description: "Search query."},
page: {type: "integer", description: "Page number (default 1)."}
},
required: %w(query)
}
}
end
def initialize(db, user_id = nil)
@db = db
end
def call(args)
# query database, return {rows: [...], next_page: 2}
end
end
Adding a tool: create the class, add to the TOOLS hash, write tests.
Docs tool
One tool we like is docs.
It has no database queries;
just a hash of topic names to markdown strings
that describe how the app works.
Claude 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
JWTs are verified against the AuthKit JWKS endpoint:
- Algorithm: RS256
- Validates: issuer, audience, expiration
- JWKS are fetched lazily and cached
- On
kid_not_found(key rotation), re-fetches JWKS but at most once per 5 minutes to prevent cache-busting attacks from tokens with randomkidvalues
JWT.decode(
token, nil, true,
algorithms: ["RS256"],
jwks: jwks_resolver,
iss: @issuer, verify_iss: true,
aud: @audience, verify_aud: true,
verify_expiration: true
)
Pagination
Tools that return lists paginate with a PAGE_SIZE + 1 trick:
fetch one extra row to know if there's a next page
without a separate count query.
module Paginate
PAGE_SIZE = 50
def self.call(rows, page)
offset = (page - 1) * PAGE_SIZE
sliced = rows[offset, PAGE_SIZE + 1] || []
has_next = sliced.size > PAGE_SIZE
{rows: sliced.first(PAGE_SIZE), next_page: has_next ? page + 1 : nil}
end
end
Claude sends next_page from one response as the page argument
in the next request to paginate through results.