Skip to main content

MCP server for llm-nano-vm — run deterministic LLM programs via Model Context Protocol

Project description

CI PyPI Python 3.10+ MIT MCP Deterministic FSM Audit Trail GDPR

Stateful MCP gateway for deterministic LLM workflows.
Governance-first. Replayable. Audit-complete.
Built on llm-nano-vm — the deterministic FSM execution kernel.


What nano-vm-mcp Is

nano-vm-mcp is an MCP gateway that turns the Model Context Protocol into a governance-bound execution environment. It wraps the llm-nano-vm execution kernel and exposes it to any MCP client — Claude Desktop, custom agents, or API callers — through stdio or SSE transport.

Most MCP servers expose stateless tools. nano-vm-mcp exposes stateful, governed, auditable workflows.

Capability Typical MCP Server nano-vm-mcp
Tool execution
Stateful workflows
Deterministic FSM
Replayable traces
Suspend / resume
Capability enforcement
Append-only audit trail
GDPR tombstoning
Inter-session idempotency

Core invariant: the gateway does not own execution logic — the FSM kernel does.

δ(S, E) → S'

  S  — current execution state
  E  — validated event
  S' — next deterministic state

Architecture

MCP Client
  → nano-vm-mcp (Gateway)
      → GovernedRunProgramHandler   ← PolicySnapshot, idempotency_key, CapabilityRef resolution
          → llm-nano-vm (Kernel)    ← deterministic FSM, ASTEngine, ProjectionLayer
      → GovernanceEnvelope store    ← SQLite WAL, append-only audit log
      → idempotency_keys store      ← inter-session exactly-once guarantee

Strict isolation: the gateway never touches execution logic. The kernel never touches persistence or policy. Each layer has a single responsibility and cannot cross the boundary.


Install

pip install nano-vm-mcp

For programs with llm steps:

pip install 'nano-vm-mcp[litellm]'

MCP Tools

Tool Description
run_program Execute a Program dict → returns trace_id, status, step count, cost
get_trace Retrieve full Trace JSON by trace_id
list_programs List saved programs (id, name, created_at)
get_program Retrieve saved Program JSON by program_id
delete_program Delete a program and all its traces

Quick Start

stdio — Claude Desktop / local MCP client

nano-vm-mcp --transport stdio

claude_desktop_config.json:

{
  "mcpServers": {
    "nano-vm-mcp": {
      "command": "nano-vm-mcp",
      "args": ["--transport", "stdio"]
    }
  }
}

SSE — VPS / remote clients

NANO_VM_MCP_API_KEY=your-secret-token nano-vm-mcp --transport sse --port 8080

MCP client URL: http://<host>:8080/sse
Auth header: Authorization: Bearer your-secret-token

Docker Compose

services:
  nano-vm-mcp:
    image: ghcr.io/ale007xd/nano-vm-mcp:latest
    ports:
      - "8080:8080"
    volumes:
      - ./data:/data
    environment:
      NANO_VM_MCP_DB: /data/nano_vm_mcp.db
      NANO_VM_MCP_PORT: 8080
      NANO_VM_MCP_API_KEY: your-secret-token
    command: ["nano-vm-mcp", "--transport", "sse"]

Configuration

Copy .env.example to .env:

cp .env.example .env
Variable Default Description
NANO_VM_MCP_DB nano_vm_mcp.db SQLite WAL database path
NANO_VM_MCP_HOST 0.0.0.0 SSE bind host
NANO_VM_MCP_PORT 8080 SSE bind port
NANO_VM_MCP_API_KEY (unset) Bearer token for SSE auth. If unset, all requests are allowed (warning logged)
NANO_VM_MCP_LLM_MODEL (unset) LiteLLM model string for llm steps (e.g. openrouter/meta-llama/llama-3.3-70b-instruct:free)

Endpoints

Path Auth Description
GET /health none Liveness probe — always returns {"status": "ok"}
GET /sse bearer SSE transport entry point
POST /messages bearer MCP message endpoint

Example: Run a Workflow

Payment pipeline — no LLM

program = {
    "name": "payment_flow",
    "steps": [
        {"id": "reserve",  "type": "tool", "tool": "reserve_funds"},
        {"id": "capture",  "type": "tool", "tool": "capture_payment"},
        {"id": "receipt",  "type": "tool", "tool": "send_receipt"},
    ]
}

No LLM. The gateway still guarantees: deterministic ordering, replayable trace, exactly-once semantics, append-only audit trail.

Async suspend / resume

Return the sentinel "PENDING" from any tool to suspend execution:

async def wait_bank_transfer(**kwargs) -> str:
    await register_webhook(kwargs["order_id"])
    return "PENDING"   # FSM → SUSPENDED, cursor persisted

FSM lifecycle: RUNNING → SUSPENDED → RUNNING → SUCCESS

This enables: payment settlement, courier confirmation, approval workflows, webhook orchestration, human-in-the-loop.

Note: "PENDING" is a reserved FSM sentinel. Use "REQUIRES_ACTION", "AWAITING_3DS", or any other string for domain-specific states.

Through MCP (SSE)

import asyncio
from mcp import ClientSession
from mcp.client.sse import sse_client

program = {
    "name": "demo",
    "steps": [
        {"id": "step1", "type": "tool", "tool": "hello_tool"}
    ]
}

async def main():
    headers = {"Authorization": "Bearer your-secret-token"}
    async with sse_client("http://localhost:8080/sse", headers=headers) as (r, w):
        async with ClientSession(r, w) as session:
            await session.initialize()
            result = await session.call_tool(
                "run_program",
                {
                    "program": program,
                    "save_as": "demo",
                    "idempotency_key": "order-abc-123",   # inter-session exactly-once
                }
            )
            print(result.content[0].text)

asyncio.run(main())

Idempotency — Inter-session Exactly-Once

Pass idempotency_key to run_program to guarantee that a program executes at most once per key, even across process restarts:

# First call — executes normally, result cached as "success"
result = await session.call_tool("run_program", {
    "program": program,
    "idempotency_key": "payment-order-xyz-001",
})

# Second call with same key — returns cached result immediately, no re-execution
result = await session.call_tool("run_program", {
    "program": program,
    "idempotency_key": "payment-order-xyz-001",
})

Crash recovery: if the process crashes after program start but before completion (status=pending), the next call with the same key overwrites the pending entry and re-executes. Once the result is written as status=success, it is immutable for that key.

This closes the inter-session duplicate risk that exists when a process restarts after creating a payment but before confirming it.


Governance Layer

GovernanceEnvelope

Each successful execution step produces an immutable GovernanceEnvelope stored in the governance_envelopes table:

Field Type Description
execution_id str Session / trace identifier
step_id int Step index within the execution
policy_hash str SHA-256 of the active PolicySnapshot
canonical_snapshot_hash str Merkle/delta hash of CanonicalState at this step
payload dict | list Projected (sanitized) step output

Envelopes are written only on error=None — they form a tamper-evident, append-only audit trail of successful transitions only.

CapabilityRef and GDPR Tombstoning

Sensitive values in CanonicalState are stored as CapabilityRef tokens (vault://secret/<id>) rather than raw plaintext.

On a GDPR erasure event (E_gdpr_erase):

  • Target ref is tombstoned (is_tombstone=True)
  • All subsequent projections return [REDACTED_TOMBSTONE]
  • The canonical_snapshot_hash chain remains valid
  • The secret is permanently gone

This preserves forensic auditability without retaining erased personal data.


Security

Condition expressions — ASTEngine

run_program accepts a full Program dict including condition steps with expression strings. These are evaluated by the ASTEngine — a deterministic sandboxed interpreter with no access to Python builtins, attribute access, or callable invocation.

Supported operators: ==, !=, >, <, in, not in, and, or, not, contains, dotted-path $var.field.

eval() is not used anywhere in the production execution path.

Rules for safe use:

  • Condition logic must be authored by you, not generated from untrusted input at runtime.
  • LLM output may appear as a value being tested ('yes' in '$decision'), never as the condition expression itself.
  • If you expose this server to untrusted clients, validate or allowlist condition expressions before passing them to run_program.

Capability enforcement — double gate

Tool execution passes through two independent enforcement layers:

Layer Mechanism
GovernedToolExecutor Verifies tool name against PolicySnapshot.tool_capabilities; raises CapabilityDeniedError on violation
ExecutionVM (kernel) Rejects any tool name not registered in the tool registry with VMError

Neither gate can be bypassed by LLM output. A tool not listed in the policy is never silently executed.

Avoid registering destructive or privileged tools (filesystem writes, shell exec, database mutations) without an explicit access control layer in your tool implementation.

SSE transport and auth

Set NANO_VM_MCP_API_KEY to enable bearer token authentication. The comparison is timing-safe (secrets.compare_digest). If unset, a warning is logged and all requests are allowed — suitable for localhost only.

Do not expose the SSE endpoint to the public internet without NANO_VM_MCP_API_KEY set or behind a reverse proxy with auth (nginx, Cloudflare Access, VPN).


Observability

Every execution exposes:

trace.trace_id          # UUID4 — stable for OTel propagation
trace.status            # SUCCESS | FAILED | SUSPENDED | BUDGET_EXCEEDED | STALLED
trace.final_output
trace.steps             # per-step: step_id, status, duration_ms, usage
trace.state_snapshots   # list[(step_index, sha256_hex)]

Traces are persisted to SQLite and retrievable by trace_id across sessions via get_trace.


Execution State Model

CREATED
  ↓
RUNNING ──── tool returns "PENDING" ──→ SUSPENDED
  │                                          │
  │                                    resume_with_program()
  │                                          │
  └──────────────────────────────────────────┘
  │
  ├── no more steps ──→ SUCCESS
  ├── tool error (on_error=fail) ──→ FAILED
  ├── max_steps / max_tokens exceeded ──→ BUDGET_EXCEEDED
  └── max_stalled_steps exceeded ──→ STALLED

Terminal states: SUCCESS, FAILED, BUDGET_EXCEEDED, STALLED. All are immutable.


Relationship to nano-vm

Layer Responsibility
llm-nano-vm (kernel) Deterministic FSM execution, ASTEngine, ProjectionLayer, step lifecycle
nano-vm-mcp (gateway) MCP transport, persistence, governance, idempotency, capability enforcement

The gateway never owns transition logic. The FSM kernel does.


Roadmap

Status Feature Version
run_program, get_trace, list_programs, get_program, delete_program v0.1.0
stdio + SSE transports v0.1.0
SQLite WAL persistence v0.1.0
Bearer token auth — NANO_VM_MCP_API_KEY, timing-safe v0.1.0
/health liveness endpoint v0.1.0
Structured error responses + logging v0.1.0
GovernanceEnvelope — immutable audit trail per execution step v0.3.0
GovernedRunProgramHandler + GovernedToolExecutor + CapabilityDeniedError v0.3.0
PolicySnapshot CRUD — capability-gated tool execution v0.3.0
CapabilityRef + tombstoning — GDPR erasure with hash-chain preservation v0.3.0
ASTEngine in condition steps — eval() removed from production path v0.3.0
governance_envelopes table — append-only SQLite store v0.3.0
trace_id fix — uses trace.trace_id from ExecutionVM v0.3.1
Trace persistence: FK constraint removed, explicit cascade in delete_program v0.3.1
idempotency_store — inter-session exactly-once guarantee v0.4.0
build_chain()GovernedRunProgramHandler — capability gate always active v0.4.0
TRACE projection logging to SQLite — execution_traces table + save_trace_step/get_trace_steps v0.4.1
PROGRAM_IPN_HANDLER DSL — webhook confirmation path
GovernedToolExecutor circuit breaker — degradation isolation
POST /mcp/session/{execution_id}/step — full RFC step lifecycle
RemoteProjectionProvider — IPC connector to Vault for JIT plaintext access
plan_and_run — intent string → Planner → run
Docker image to GHCR

License

MIT License


Contact

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nano_vm_mcp-0.4.1.tar.gz (41.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nano_vm_mcp-0.4.1-py3-none-any.whl (23.1 kB view details)

Uploaded Python 3

File details

Details for the file nano_vm_mcp-0.4.1.tar.gz.

File metadata

  • Download URL: nano_vm_mcp-0.4.1.tar.gz
  • Upload date:
  • Size: 41.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nano_vm_mcp-0.4.1.tar.gz
Algorithm Hash digest
SHA256 e23d0fd5c88d0b32d271ef77c821df23a8c7d13ecb5f18233b77aa2182a7e77f
MD5 5c1dc15d5b68a7fe6e2d1dfaf52be237
BLAKE2b-256 e1d96dc14c4e9fd565124613abdd0ece8621ab13d7d69a2691574b61055cf337

See more details on using hashes here.

File details

Details for the file nano_vm_mcp-0.4.1-py3-none-any.whl.

File metadata

  • Download URL: nano_vm_mcp-0.4.1-py3-none-any.whl
  • Upload date:
  • Size: 23.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nano_vm_mcp-0.4.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6a84f0dcc6bfe254af8ebaee998f9c100565a5b63509c81104f5ccaa5d48eb90
MD5 d73ceb4e6584f3592c0ec1011d2e5cd2
BLAKE2b-256 0267ed69550d206bb67d9b57ef1ee48faccc93dd99b5e9c89502b326faf14a75

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page