Security, Operations, And Testing
Security Model
Token storage
Only the SHA256 digest of the raw token is stored in mcp_tokens.token_digest. The raw token is emitted exactly once — at creation or regeneration — and must be captured by the caller. There is no "view token" operation.
The token_prefix column stores the first 6–8 characters of the raw token for UI identification only. It is not sensitive.
Token lifecycle
Mcp::Token supports:
- creation via
Mcp::Token.generate_for(user, name:, permissions:)→ returns[token, raw_token] - regeneration via
token.regenerate!→ invalidates the old digest, returns a new raw token - revocation via
token.revoke!→ setsrevoked_at, immediately blocks the endpoint - last-used tracking via
token.update_column(:last_used_at, Time.current)on every request - permission check via
token.permits?(domain)returning a boolean
Permission filtering happens before discovery
This is load-bearing. The client does not merely get blocked from calling forbidden tools — it does not see those tools at all if the token lacks the matching permission key. That means an AI model cannot "discover" a tool it is not allowed to use and then argue its way into calling it.
User isolation is structural
The user is resolved before the tool executes and stored in request-local state (Thread.current[:mcp_user_context]). Tools never receive a user id from the caller. The only way for a tool to touch another user's data is if the tool author explicitly writes an unscoped query (Item.where(user_id: other_id)) — which your code review should catch.
Always start tool queries from user.<association>. Never from the unscoped model class.
Token in URL trade-off
Putting the token in the URL path is practical for MCP hosts that only accept a URL (Claude Desktop, Claude Code). It means tokens may appear in:
- server access logs
- proxy and CDN logs
- browser history if tokens are ever pasted into browsers
Mitigations built into this package:
- HTTPS only in production
- regeneration flow in the token UI
- revoke removes runtime access immediately
- short-lived tokens for sensitive use cases (create a new one per day/week, revoke the old one)
Mitigations you should add at the infrastructure layer:
- strip
PATH_INFOfrom structured logs where possible - avoid logging full request paths in application loggers
- consider moving to
Authorization: Bearerheaders if the MCP host supports them in future transports
Operational Behaviors Worth Preserving
Rate limiting
Mcp::BaseRackApp enforces 60 requests per minute per token, in-process. That is a good default for a single-node Rails deployment.
If the target app runs multiple Puma workers or multiple replicas:
- move the counter to Redis or another shared store
- or move the limit to an API gateway / load balancer
- the in-memory limit still helps per-worker, so you can leave it in place as a floor
Thread-local cleanup
The Rack app clears thread-local state in after_dispatch, which runs in an ensure block:
def after_dispatch
Mcp::BaseTool.user_context = nil
Current.user = nil
end
This is not optional under a threaded server like Puma. A missed cleanup leaks the previous request's user into the next request on the same thread.
Transport reuse
The implementation reuses a StreamableHTTPTransport per token and swaps its inner @server object on each request. Keep this pattern — it is the cheapest way to get stateless-per-request permission filtering with a stateful transport.
Audit logging
The logging hook lives in Mcp::BaseTool::CallLogging, which is prepended onto every subclass via inherited. That is a good pattern because:
- every tool gets logging automatically
- individual tool classes stay clean
- arguments and result previews are sanitized in one place (
Mcp::ActivityLog.log_tool_call)
Sanitize any key matching /password|token|secret/i before writing to mcp_activity_logs.arguments.
Recommended Tests For A Fresh Export
Model tests
Mcp::Token.find_by_raw_tokenreturns only active tokens- Revoked tokens fail lookup (returns
nil) permits?returns expected values for each domainregenerate!changes the digest and prefix, keeps the same idgenerate_forreturns[token, raw_token]and the raw token hashes to the stored digest
Rack tests
- Missing token returns 401
- Invalid token returns 401
- Revoked token returns 401
- Rate limit returns 429 after the configured threshold (default 60/min)
- Valid token reaches
tools/listand returns only permitted tools last_used_atis updated on successful requests
Builder tests
- Permitted tool classes are included
- Forbidden tool classes are excluded from
tools/list - Tool classes missing from
TOOL_NAMESPACESare not exposed - The builder assigns the current
UserContextontoMcp::BaseTool
Tool tests (per domain)
- Each tool only reads or mutates within the current user's scope
- List tools include IDs in their output
getreturnsnot_found_responsefor a foreign user's record id (cross-user scope leak test)create/updatevalidation failures return a readable text response, not an exceptiondeleteon a foreign user's record id returns "not found", not a successful delete- Successful calls create an
Mcp::ActivityLogrow with the righttool_name,domain,action_type - Sensitive keys in arguments are sanitized in the activity log
UI tests (if token management is exported)
- Authenticated user can create a token
- A different user cannot see or edit the first user's tokens (policy scope test)
regenerateinvalidates the old raw token on the very next requestrevokeremoves runtime access immediately (next request returns 401)
Observability Suggestions
Add metrics or structured logs for:
- request count by token id
- 401 and 429 rate
- tool calls by
domainandaction_type - average duration per tool
- top failing tools (non-200
status_codeinmcp_activity_logs)
If the target project already has structured logging, include at minimum:
mcp_token_iduser_idtool_namedomainaction_typestatus_code- duration in ms
Safe Rollout Strategy
- Ship read-only tools first (
list,get). Most real-world value arrives from read tools alone. - Enable create on one domain before enabling it across all domains.
- Default
createtools to a safe initial state (draft,archived: false). - Monitor
mcp_activity_logsfor at least a week before broadening the tool surface. - Enable delete last, and only if you have a strong operational reason. Deletes are irreversible and hard to audit after the fact — soft-delete libraries are a reasonable alternative.
- Use per-token permission toggles so individual users can opt out of risky domains without affecting others.
What To Avoid In The Target Project
- Unscoped model queries inside tools (
Item.all,Item.where(...)with a user id from the caller) - Generic catch-all update tools with a
field+valueargument pair - Storing raw tokens in the database or logs
- Using class variables instead of
Thread.currentfor request context - Skipping activity logs for mutation tools
- Exposing
deletetools before you have audit coverage and a recovery plan - Returning full
has_manyassociations in responses when a summary would do — context window matters - Trusting any caller-supplied id to resolve a record; always route through the user-scoped association