Page MenuHomeVyOS Platform

T9030-mcp-security.md

Authored By
mihakralj
Jul 1 2026, 2:50 AM
Size
10 KB
Referenced Files
None
Subscribers
None

T9030-mcp-security.md

# T9030: Security, CLI Integration & Concurrency
| Field | Value |
|-------|-------|
| Task | [T9030](https://vyos.dev/T9030) |
| Status | Draft / design |
| Related specs | [Architecture & Transport](T9030-mcp-architecture.md) · [Dynamic Schema](T9030-mcp-dynamic-schema.md) · [MCP Tools](T9030-mcp-tools.md) |
## Authentication
The MCP endpoint inherits the existing VyOS HTTPS API authentication layer.
No separate OAuth 2.1 flow is required.
### Mechanism
Credentials are validated at the `GET /mcp` handshake **before** any MCP
logic executes. The HTTP API server already loads the configured keys from
`api_config_state` (`/run/http-api-state`) and flattens them into `{id, key}`
records via `flatten_keys()`; MCP reuses that key list and the `check_auth()`
matcher to resolve a request to a `key_id`.
> **Note — extraction differs from REST/GraphQL.** The existing REST and GraphQL
> endpoints read the API key from the **request body** (`auth_required(data:
> ApiModel)` consults `data.key`). A `GET` handshake has no body, so MCP
> must extract the credential from the request itself:
>
> - `apikey` HTTP header (or, where the client cannot set custom headers, a
> query parameter) → matched against configured API keys via `check_auth()`.
> - `Authorization: Bearer <token>` → JWT validated with `HS256` against the
> generated shared secret (reusing `api/graphql/libs/token_auth.py`; handles
> `ExpiredSignatureError` / `PyJWTError`).
>
> Only the **extraction surface** is new; the key-matching and JWT-verification
> logic is shared with the existing endpoints. This new surface lives in
> `api/mcp/auth.py` and must be covered by tests.
On failure, HTTP 401 is returned immediately at the `GET /mcp` handshake —
no MCP handshake, no tool enumeration, no resource access, and the SSE stream is
never opened.
### Resolved identity
A successful auth sets the session identity (`key_id` for key auth, or the JWT
`user` for token auth). This identity is attached to every subsequent
`tools/call` on the session and is used for audit logging (see below).
### Shared Threat Model
Identical cryptographic boundary as the REST and GraphQL endpoints. IP filtering
(`set service https allow-client address`) applies uniformly because MCP is
mounted on the same FastAPI app behind the same TLS termination and unix socket
(`/run/api.sock`).
| Threat | Mitigation |
|--------|------------|
| Credential theft | Same key/JWT model as REST; TLS-only; IP allow-list. |
| Command injection / hallucinated CLI | Every path validated against `vyos.xml_ref`; no shell string is ever built from LLM input. |
| Privilege escalation via writes | `read-only` mode omits all write tools from the catalog (structural, not advisory). |
| Resource path traversal | `{path}` sanitized (reject `..` and shell metacharacters) before backend dispatch. |
| Denial of service via long commits | Blocking work offloaded to threads; event loop and keep-alives stay responsive. |
| Config corruption via concurrent commits | All writes serialized through the shared REST `lock`/`asynclock`; MCP and REST commits are mutually exclusive. |
| Information disclosure | Schema resources exposed only when the `introspection` flag is set (off by default). |
## CLI Configuration
Commands nested within `service https api mcp`:
```
set service https api mcp # Enable MCP endpoint (default: disabled)
set service https api mcp mode <read-only | read-write> # Tool access level (default: read-only)
set service https api mcp introspection # Expose schema resources (valueless flag)
```
> A dedicated MCP listen port is intentionally omitted from the initial CLI: MCP
> shares the single Uvicorn server bound to `/run/api.sock` and is reverse-proxied
> on the existing HTTPS port. Shipping a `port` node that the daemon ignores would
> be misleading; port decoupling is tracked as future work (see the
> [Architecture open questions](T9030-mcp-architecture.md#open-questions)).
### Node semantics
| Path | Type | Default | Effect |
|------|------|---------|--------|
| `mcp` | node (presence) | absent → disabled | Enables MCP routing when present. |
| `mcp/mode` | enum `read-only`/`read-write` | `read-only` | Controls which tools are registered. |
| `mcp/introspection` | valueless flag | absent | When set, schema resources are exposed. |
`read-only` is the secure default: even if credentials leak, an attacker cannot
mutate configuration or reboot the device through MCP.
### Reconfiguration Behavior
On commit:
1. `service_https.py` rebuilds the API state via `get_config_dict()` and rewrites
`/run/http-api-state`.
2. The running Uvicorn server reconciles routers (no process restart).
3. `api.mcp.routers.mcp_clear(app)` cancels the session manager lifespan,
terminating all active sessions.
4. `mcp_init(app)` re-mounts the transport with the new mode/introspection
constraints (or stays cleared if `mcp` was removed).
5. REST/GraphQL connections are unaffected.
## XML Interface Definitions
New CLI nodes are added under the existing `service https api` subtree in
`interface-definitions/service_https.xml.in`, reusing the conventions of the
sibling `rest`/`graphql` nodes: a valueless flag for the boolean toggle and an
**unanchored** `<regex>` constraint (VyOS anchors constraint regexes implicitly;
do not wrap them in `^...$`). Sketch:
```xml
<node name="mcp">
<properties>
<help>Model Context Protocol (MCP) API</help>
</properties>
<children>
<leafNode name="mode">
<properties>
<help>MCP tool access level</help>
<completionHelp>
<list>read-only read-write</list>
</completionHelp>
<constraint>
<regex>(read-only|read-write)</regex>
</constraint>
</properties>
<defaultValue>read-only</defaultValue>
</leafNode>
<leafNode name="introspection">
<properties>
<help>Expose schema introspection resources</help>
<valueless/>
</properties>
</leafNode>
</children>
</node>
```
These nodes render `api_config_state` entries consumed by `vyos-http-api-server`,
where they are read as `session.mcp`, `session.mcp_mode`, and
`session.mcp_introspection`.
## Audit Logging
- Each `modify_configuration` invocation runs under a `ConfigSession(uid,
app="vyos-mcp-agent")`; the `app` label tags the commit in the commit log so
MCP-originated changes are attributable.
- The resolved auth identity (`key_id` / JWT user) plus the session UID (derived
from the ASGI worker PID) are logged with each tool call.
- Failed validations (invalid paths, rejected commands) are logged at the
transport boundary before any backend dispatch.
### Observability and Metrics
- MCP-specific metrics will be exposed if `service monitoring prometheus` is enabled.
- Metrics include:
- Active Streamable HTTP sessions
- Tool invocation counts (categorized by success/failure and tool type)
- Latency of tool execution (to monitor for slow configurations impacting the event loop)
## Concurrency & Event Loop
### Problem
`ConfigSession.commit()` executes **synchronous** subprocess calls (the CLI
shell API, iproute2, Jinja2 template rendering, systemd daemon reloads). If run
directly in the FastAPI event loop, the entire ASGI server stalls: session
keep-alive pings stop and the LLM connection times out.
### Solution
All operational and configuration tool bodies are wrapped in
`asyncio.to_thread()` (or Starlette's `run_in_threadpool()`):
1. The FastAPI/MCP handler receives the tool invocation on the event loop.
2. It immediately offloads `ConfigSession` instantiation, the `set`/`delete`
mapping, and `commit()` to a worker thread.
3. The primary Uvicorn event loop continues processing `POST /mcp/messages`
and transmitting keep-alive frames.
4. The thread result (success/error) is returned to the event loop for JSON-RPC
serialization over the Streamable HTTP response.
Resource reads (config/state/schema) are likewise offloaded when they shell out
to op-mode scripts; pure in-memory schema projection stays on the loop.
### Serializing commits
Offloading to a worker thread keeps the event loop responsive but does **not**
make concurrent configuration changes safe. `ConfigSession.commit()` mutates a
shared candidate/running configuration; two overlapping `modify_configuration`
calls (or an MCP commit racing a REST `POST /configure`) would corrupt that
shared state. The REST router already guards this with a module-level
`threading.Lock` (`lock`) and an `asyncio.Lock` (`asynclock`) in
`src/services/api/rest/routers.py`.
MCP **must** serialize writes through the *same* locks, not new ones, so that
MCP-originated and REST-originated commits are mutually exclusive across the
whole API process:
1. Acquire `asynclock` on the event loop.
2. Offload the `ConfigSession` staging + `commit()` to a worker thread that
holds the blocking `lock` for the duration of the commit.
3. Release both once the commit (or error) returns.
Read-only operational commands and resource reads do not take the write lock and
remain concurrent.
### Error Serialization
- The MCP tool dispatcher wraps every tool body in a `try/except` that catches
`ConfigError`, `ConfigSessionError`, and the `vyos.opmode` error classes and
converts them into JSON-RPC `error` objects (rather than letting a bare
exception escape as a generic transport error).
- The exact semantic error string (e.g., "Cannot assign IPv6 address to bridge
interface without enabling IPv6 forwarding") is serialized into the JSON-RPC
error `message`/`data`.
- The LLM receives the precise failure rationale, enabling self-correction (it
re-reads the schema resource and reissues a corrected `modify_configuration`).
## Process Model
- Single Uvicorn worker process (consistent with the current VyOS API), binding
the existing unix socket `/run/api.sock`.
- Each MCP connection is handled as an ASGI scope.
- `ConfigSession` instances are ephemeral — created per `modify_configuration`
invocation and discarded after commit/error (no long-lived staged state shared
between unrelated tool calls).
- The session UID is derived from the ASGI worker PID for audit logging, matching
how `vyos-http-api-server` seeds `ConfigSession(os.getpid())`.

File Metadata

Mime Type
text/plain
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3820424
Default Alt Text
T9030-mcp-security.md (10 KB)

Event Timeline