This document describes the security model for the RCAC MCP Server, including how command execution is isolated across different deployment modes.
The server supports three execution modes, each with different security characteristics:
| Mode | Transport | Auth | Use Case |
|---|---|---|---|
ssh |
stdio | N/A | Local MCP client → remote HPC cluster |
local |
http/sse | none | Development/testing only |
delegate |
http/sse | jwt/oidc | Multi-user production deployment |
Intended use: Desktop MCP clients (Claude Desktop, Cursor, etc.) connecting to remote HPC clusters.
┌─────────────────┐ stdio ┌─────────────────┐ SSH ┌─────────────┐
│ MCP Client │ ◄──────────────► │ rcac-mcp │ ◄────────────► │ HPC Cluster │
│ (Claude, etc.) │ │ (local) │ │ │
└─────────────────┘ └─────────────────┘ └─────────────┘
Security model:
- The MCP server runs locally on the user's machine
- SSH connection uses the user's existing
~/.ssh/configand keys - No authentication at the MCP layer (stdio is inherently single-user)
- All commands execute as the SSH user on the remote system
- Security boundary is the SSH connection itself
Configuration:
rcac-mcp --ssh-host cluster.example.edu
# or
export RCAC_SSH_HOST=cluster.example.edu
rcac-mcpTrust assumptions:
- The local user is trusted (they control the MCP client)
- The SSH connection is authenticated via standard SSH mechanisms
- The remote system enforces permissions via standard Unix file/process ownership
Intended use: Development and testing only. NOT FOR PRODUCTION.
┌─────────────────┐ HTTP ┌─────────────────┐
│ MCP Client │ ◄──────────────► │ rcac-mcp │ ──► local bash
│ │ │ (http server) │
└─────────────────┘ └─────────────────┘
Security model:
- Commands execute directly on the server as the process owner
- No authentication - anyone who can reach the HTTP endpoint can execute commands
- Single shared executor for all requests
Configuration:
rcac-mcp -t http -e localIntended use: Multi-user production deployment where the server runs as root and delegates commands to authenticated users.
┌─────────────────┐ ┌─────────────────┐
│ User A │ ──► JWT/OIDC ──► │ │ ──► sudo -u alice ...
│ (sub: alice) │ │ │
└─────────────────┘ │ rcac-mcp │
│ (runs as root) │
┌─────────────────┐ │ │
│ User B │ ──► JWT/OIDC ──► │ │ ──► sudo -u bob ...
│ (sub: bob) │ │ │
└─────────────────┘ └─────────────────┘
Security model:
- Server runs as root (or a privileged user with sudo rights)
- Each request is authenticated via JWT or OIDC
- User identity is extracted from token claims (
sub,email, orpreferred_username) - Identity is mapped to a local Unix user via the user map file
- Commands are wrapped with
sudo -n -H -u <local_user> ..., and start in that user's home - File transfers (
upload_file,download_file) also run as the mapped user, through the samesudolaunch; the server process reads and writes no file on a user's behalf - OS-level controls (sudoers, PAM, SELinux, etc.) enforce what each user can do
Configuration:
export JWT_SECRET="your-secret-key-at-least-32-characters"
export RCAC_USER_MAP=/etc/rcac-mcp/users.map
rcac-mcp -t http -a jwt -e delegateUser map file format (/etc/rcac-mcp/users.map):
# Identity claim → local Unix user
# Lines starting with # are comments
alice@purdue.edu alice
bob@purdue.edu bob
service-account-1 svc_batch
-a oidc puts FastMCP's OIDC proxy in front of the server. It needs all four of
OIDC_CONFIG_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and MCP_BASE_URL; with any one unset or
empty the server refuses to start, naming all four, before a provider is built or a request is
made. FastMCP 4 treats the client secret as optional; this server requires it.
The proxy is held at the posture it had on FastMCP 2.x, pending a deliberate decision: it does
not fetch client-supplied metadata documents (CIMD) and does not forward the resource
parameter to the upstream provider. FastMCP 4 turns both on by default; rcac-mcp passes both off.
Upgrading from a release on FastMCP 2 drops the proxy's client registrations. FastMCP 2 kept them in
an encrypted DiskStore at <home>/oauth-proxy/cache.db; FastMCP 4 keeps them in an encrypted
FileTreeStore under <home>/oauth-proxy/<key-fingerprint>/ (<home> is FastMCP's home
directory) and does not read the old store. Every client registered before the upgrade must register
again.
A critical security property of delegate mode is that concurrent requests from
different users are properly isolated. This is achieved using Python's
contextvars.ContextVar mechanism.
How it works:
from contextvars import ContextVar
# This is NOT a global variable - it's a context-local variable
executor_var: ContextVar[Executor] = ContextVar('executor', default=None)Context variables are task-local in async Python. Each async task (HTTP request) gets its own isolated copy of the context:
Request 1 (alice) Request 2 (bob)
───────────────────────── ─────────────────────────
on_call_tool / on_read_resource on_call_tool / on_read_resource
│ │
▼ ▼
set_executor( set_executor(
DelegatingExecutor(alice) DelegatingExecutor(bob)
) )
│ │
▼ ▼
get_executor() → alice's executor get_executor() → bob's executor
│ │
▼ ▼
sudo -u alice <command> sudo -u bob <command>
The middleware sets the executor in the same way for a tool call (on_call_tool) and for a
resource read (on_read_resource), through one code path. A read that runs commands
(rcac://storage) therefore runs as the mapped user of the request that read it, and a request
whose token names no identity, or an identity absent from the user map, is refused the read before
its handler runs, with the reason its tool call would get. Reads of the resources that run nothing
(OPEN_RESOURCES, today rcac://context, matched as the exact URI string sent) are let through
without resolving an identity: they serve the same text, readable by every account, to every
client.
Why this is safe:
-
No shared state: Each request's executor is stored in its own context, not in a module-level global variable.
-
Automatic isolation: FastMCP/Starlette creates a new async task per request, which automatically gets a fresh context copy.
-
No cross-request leakage: When request 1 calls
set_executor(), it only affects request 1's context. Request 2 sees its own value. -
Clean teardown: When a request completes, its context is discarded.
Contrast with an unsafe approach:
# ❌ WRONG - This would be a security vulnerability!
_current_executor = None # Shared across ALL requests
def set_executor(executor):
global _current_executor
_current_executor = executor # Race condition! User A could get User B's executorThe ContextVar approach is the same pattern used by:
- FastMCP's
get_access_token()for per-request auth context - Starlette's request state
- Python's
decimalmodule for per-context precision settings
For delegate mode to work, the server process must be able to run commands as mapped users without a password prompt. Example sudoers configuration:
# /etc/sudoers.d/rcac-mcp
# Allow rcac-mcp (running as root or rcac-mcp user) to run commands as any user
# NOPASSWD is required since there's no TTY for password prompts
rcac-mcp ALL=(ALL) NOPASSWD: ALL
# Or more restrictively, only allow specific users:
rcac-mcp ALL=(alice,bob,svc_batch) NOPASSWD: ALL
Recommended restrictions:
- Limit which users can be delegated to
- Use
NOEXECto prevent execution of binaries from writable directories - Consider using SELinux/AppArmor for additional confinement
In delegate mode, the middleware extracts user identity from JWT/OIDC token claims in the following order of preference:
sub- Standard JWT subject claim (most common)email- Common in OIDC providerspreferred_username- Used by some OIDC providers (Keycloak, etc.)
The first non-empty claim found is used as the key to look up the local user in the user map file.
Example JWT payload:
{
"sub": "alice@purdue.edu",
"iss": "rcac-mcp",
"aud": "rcac-mcp",
"exp": 1706650000
}This would be mapped using the alice@purdue.edu key from the user map.
- Use
localmode withauth=none - Never expose to network
- Use
sshmode via stdio - Leverage existing SSH key infrastructure
- Use
delegatemode withjwtoroidcauth - Run server as dedicated service account with minimal privileges
- Configure restrictive sudoers rules
- Enable TLS (use reverse proxy like nginx)
- Audit log all commands (future enhancement)
- Regularly rotate JWT secrets / OIDC credentials
If you discover a security vulnerability, please report it privately to rcac-help@purdue.edu rather than opening a public issue.