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! β†’ sets revoked_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_INFO from structured logs where possible
  • avoid logging full request paths in application loggers
  • consider moving to Authorization: Bearer headers 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.

Model tests

  1. Mcp::Token.find_by_raw_token returns only active tokens
  2. Revoked tokens fail lookup (returns nil)
  3. permits? returns expected values for each domain
  4. regenerate! changes the digest and prefix, keeps the same id
  5. generate_for returns [token, raw_token] and the raw token hashes to the stored digest

Rack tests

  1. Missing token returns 401
  2. Invalid token returns 401
  3. Revoked token returns 401
  4. Rate limit returns 429 after the configured threshold (default 60/min)
  5. Valid token reaches tools/list and returns only permitted tools
  6. last_used_at is updated on successful requests

Builder tests

  1. Permitted tool classes are included
  2. Forbidden tool classes are excluded from tools/list
  3. Tool classes missing from TOOL_NAMESPACES are not exposed
  4. The builder assigns the current UserContext onto Mcp::BaseTool

Tool tests (per domain)

  1. Each tool only reads or mutates within the current user's scope
  2. List tools include IDs in their output
  3. get returns not_found_response for a foreign user's record id (cross-user scope leak test)
  4. create/update validation failures return a readable text response, not an exception
  5. delete on a foreign user's record id returns "not found", not a successful delete
  6. Successful calls create an Mcp::ActivityLog row with the right tool_name, domain, action_type
  7. Sensitive keys in arguments are sanitized in the activity log

UI tests (if token management is exported)

  1. Authenticated user can create a token
  2. A different user cannot see or edit the first user's tokens (policy scope test)
  3. regenerate invalidates the old raw token on the very next request
  4. revoke removes 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 domain and action_type
  • average duration per tool
  • top failing tools (non-200 status_code in mcp_activity_logs)

If the target project already has structured logging, include at minimum:

  • mcp_token_id
  • user_id
  • tool_name
  • domain
  • action_type
  • status_code
  • duration in ms

Safe Rollout Strategy

  1. Ship read-only tools first (list, get). Most real-world value arrives from read tools alone.
  2. Enable create on one domain before enabling it across all domains.
  3. Default create tools to a safe initial state (draft, archived: false).
  4. Monitor mcp_activity_logs for at least a week before broadening the tool surface.
  5. 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.
  6. Use per-token permission toggles so individual users can opt out of risky domains without affecting others.

What To Avoid In The Target Project

  1. Unscoped model queries inside tools (Item.all, Item.where(...) with a user id from the caller)
  2. Generic catch-all update tools with a field + value argument pair
  3. Storing raw tokens in the database or logs
  4. Using class variables instead of Thread.current for request context
  5. Skipping activity logs for mutation tools
  6. Exposing delete tools before you have audit coverage and a recovery plan
  7. Returning full has_many associations in responses when a summary would do β€” context window matters
  8. Trusting any caller-supplied id to resolve a record; always route through the user-scoped association