Initial commit
This commit is contained in:
BIN
docs/HLD.docx
Normal file
BIN
docs/HLD.docx
Normal file
Binary file not shown.
529
docs/HLD.md
Normal file
529
docs/HLD.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# High-Level Design
|
||||
# MCP Privileged Access Service
|
||||
|
||||
**Version:** 1.1
|
||||
**Date:** 2026-03-28
|
||||
**Status:** Production-ready
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
0. [What is MCP? A Primer](#0-what-is-mcp-a-primer)
|
||||
1. [Purpose & Scope](#1-purpose--scope)
|
||||
2. [System Context](#2-system-context)
|
||||
3. [Architecture Principles](#3-architecture-principles)
|
||||
4. [Component Overview](#4-component-overview)
|
||||
5. [Authentication & Authorization Model](#5-authentication--authorization-model)
|
||||
6. [The Secret Handle Pattern](#6-the-secret-handle-pattern)
|
||||
7. [Data Flow — Key Use Cases](#7-data-flow--key-use-cases)
|
||||
8. [Deployment Architecture](#8-deployment-architecture)
|
||||
9. [Technology Choices](#9-technology-choices)
|
||||
10. [Security Architecture Summary](#10-security-architecture-summary)
|
||||
11. [Future Roadmap](#11-future-roadmap)
|
||||
|
||||
---
|
||||
|
||||
## 0. What is MCP? A Primer
|
||||
|
||||
> **Learning note:** This section explains the MCP concept from first principles before we dive into this specific service. Skip to Section 1 if you are already familiar with the protocol.
|
||||
|
||||
### The core problem MCP solves
|
||||
|
||||
A large language model (LLM) like Claude is, at its heart, a text-in / text-out system. On its own it cannot *do* anything in the world — it can only describe what it would do. The challenge is: how do you give an AI assistant the ability to take real actions (read a file, query a database, run a command) in a controlled, auditable, and standardised way?
|
||||
|
||||
Before MCP, every team solved this differently. Some embedded shell calls directly in prompts; others built bespoke REST wrappers. There was no common contract between the AI and the tools it called.
|
||||
|
||||
**MCP (Model Context Protocol)** is Anthropic's open standard that defines exactly that contract.
|
||||
|
||||
---
|
||||
|
||||
### The three primitives
|
||||
|
||||
MCP defines three building blocks that a server can expose to a model:
|
||||
|
||||
| Primitive | What it is | Analogy |
|
||||
|-----------|-----------|---------|
|
||||
| **Tool** | A callable function the model can invoke | An API endpoint / RPC call |
|
||||
| **Resource** | A piece of data the model can read | A file or database row |
|
||||
| **Prompt** | A reusable prompt template | A macro or named query |
|
||||
|
||||
> **Learning note:** This service uses only **Tools**. Tools are the most important primitive for *agentic* use cases — cases where the model takes actions, not just answers questions. Resources and Prompts are useful but less common in automation pipelines.
|
||||
|
||||
---
|
||||
|
||||
### How a tool call works end-to-end
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ USER "Check disk usage on web01" │
|
||||
└────────────────────────────┬─────────────────────────────────────────┘
|
||||
│ user message
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ CLAUDE (the model) │
|
||||
│ Reads the list of available tools (JSON Schema descriptions). │
|
||||
│ Decides: "I need ssh_execute to answer this." │
|
||||
│ Emits a tool_use block in its response: │
|
||||
│ { "name": "ssh_execute", │
|
||||
│ "input": { "host": "web01", "command": "df -h", ... } } │
|
||||
└────────────────────────────┬─────────────────────────────────────────┘
|
||||
│ tool_use request (JSON-RPC over HTTP/SSE)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ MCP SERVER (this service) │
|
||||
│ Receives the JSON-RPC call. │
|
||||
│ Executes the Python function ssh_execute(...). │
|
||||
│ Returns a tool_result: { "content": "Filesystem Size Used…" } │
|
||||
└────────────────────────────┬─────────────────────────────────────────┘
|
||||
│ tool_result (text)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ CLAUDE (the model) │
|
||||
│ Incorporates the tool result into its context. │
|
||||
│ Generates a final human-readable answer. │
|
||||
└────────────────────────────┬─────────────────────────────────────────┘
|
||||
│ assistant message
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ USER "web01: 18G used of 50G (36%)" │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **Learning note:** The model **never executes code itself**. It only emits a structured request saying "please call this tool with these arguments." The MCP server is the only thing that touches real infrastructure. This separation is fundamental to safety — you can audit, rate-limit, and authorise every action at the server layer without modifying the model.
|
||||
|
||||
---
|
||||
|
||||
### Transport: SSE over HTTP
|
||||
|
||||
MCP uses **Server-Sent Events (SSE)** as its default transport. The client (Claude Code) opens a persistent HTTP connection to the server. The server streams JSON-RPC messages back as SSE events.
|
||||
|
||||
> **Learning note:** Why SSE and not WebSockets? SSE is unidirectional (server → client) and works over plain HTTP/1.1 with no protocol upgrade. This makes it firewall-friendly and easy to put behind standard reverse proxies like nginx. The request direction (client → server) still uses normal HTTP POST.
|
||||
|
||||
---
|
||||
|
||||
### FastMCP: the Python framework
|
||||
|
||||
Raw MCP requires implementing a JSON-RPC server, describing tools in JSON Schema, and handling SSE streams. **FastMCP** (Anthropic's Python library) removes all of that boilerplate:
|
||||
|
||||
```python
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
|
||||
@mcp.tool(description="Add two numbers")
|
||||
async def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
```
|
||||
|
||||
FastMCP introspects the Python type annotations and generates the JSON Schema automatically. The `@mcp.tool()` decorator registers the function — the function itself is just a normal `async def`.
|
||||
|
||||
> **Learning note:** This is exactly how all four MCP servers in this service are built. The tool functions (`get_credential`, `ssh_execute`, `ps_execute`, `db_query`) are plain Python async functions. The MCP protocol wrapping is invisible to the implementation code. You can call them directly in unit tests without any MCP machinery at all — which is why the test suite can be so simple.
|
||||
|
||||
---
|
||||
|
||||
### Context injection
|
||||
|
||||
FastMCP injects a `Context` object as the `ctx` parameter of every tool. You do not pass it yourself — the framework supplies it automatically when the tool is called over the MCP protocol.
|
||||
|
||||
```python
|
||||
async def ssh_execute(host: str, command: str, ctx: Context, ...) -> str:
|
||||
await ctx.info(f"Connecting to {host}") # progress notification to the caller
|
||||
await ctx.error("Something went wrong") # error notification
|
||||
```
|
||||
|
||||
`ctx.info()` and `ctx.error()` send notifications back to the client *during* tool execution, before the final result is returned. This is how Claude Code shows "Connecting to web01..." in its status bar while a long-running command is in progress.
|
||||
|
||||
> **Learning note:** In unit tests, `ctx` is a `MagicMock`. The tests assert on `ctx.info.call_args` and `ctx.error.call_args` to verify the right status messages were emitted — without any real MCP transport being involved.
|
||||
|
||||
---
|
||||
|
||||
### Multi-server composition
|
||||
|
||||
A single Python process can host **multiple independent MCP servers**, each mounted at a different URL path on a shared FastAPI application:
|
||||
|
||||
```
|
||||
FastAPI app
|
||||
├── /mcp/cyberark ← FastMCP("cyberark") — get_credential, list_safes
|
||||
├── /mcp/ssh ← FastMCP("ssh") — ssh_execute
|
||||
├── /mcp/powershell ← FastMCP("powershell")— ps_execute
|
||||
└── /mcp/database ← FastMCP("database") — db_query
|
||||
```
|
||||
|
||||
Claude Code is configured with four separate MCP server entries, each pointing to one of these paths. From Claude's perspective they appear as four separate servers, but they share a single process, a single secret store, and a single audit log stream.
|
||||
|
||||
> **Learning note:** Mounting multiple FastMCP instances on one FastAPI app via `app.mount(path, mcp.sse_app())` is the standard pattern for building multi-capability MCP services. The alternative — one process per server — would require inter-process communication to share the secret store, which adds complexity with no security benefit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose & Scope
|
||||
|
||||
The MCP Privileged Access Service enables Claude (Anthropic's AI assistant) to execute privileged operations on enterprise infrastructure — Linux servers via SSH, Windows servers via PowerShell/WinRM, and databases — using credentials managed by CyberArk Privileged Access Management (PAM).
|
||||
|
||||
**The fundamental security guarantee:**
|
||||
|
||||
> The AI model (Claude) **never sees the actual password** at any point in the workflow. Credentials are fetched from CyberArk, held in RAM behind an opaque token, and used directly for the target connection — all within the service boundary.
|
||||
|
||||
**Scope includes:**
|
||||
- Retrieving credentials from CyberArk Central Credential Provider (CCP)
|
||||
- Executing shell commands on Linux/Unix hosts via SSH
|
||||
- Executing PowerShell scripts on Windows hosts via WinRM
|
||||
- Running SQL queries on PostgreSQL, MySQL, and SQL Server databases
|
||||
- Structured audit logging of all privileged operations
|
||||
- API key authentication for Claude Code clients
|
||||
|
||||
**Scope excludes:**
|
||||
- User interface or dashboard
|
||||
- Credential rotation or lifecycle management (handled by CyberArk)
|
||||
- Session recording (handled by CyberArk PSM if required)
|
||||
- Multi-tenancy (single-tenant service per deployment)
|
||||
|
||||
---
|
||||
|
||||
## 2. System Context
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ OPERATOR / SECURITY TEAM │
|
||||
│ • Provisions CyberArk safes & AppID │
|
||||
│ • Issues MCP API keys to Claude Code clients │
|
||||
│ • Reviews structured audit logs │
|
||||
└──────────────────────┬───────────────────────────────────────────────┘
|
||||
│ configure
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ CLAUDE CODE (client) │
|
||||
│ Claude Desktop / VS Code / CLI │
|
||||
│ - Sends MCP tool calls over HTTPS with X-API-Key header │
|
||||
│ - Receives tool results (output, exit codes, query rows) │
|
||||
│ - NEVER receives actual passwords │
|
||||
└──────────────────────┬───────────────────────────────────────────────┘
|
||||
│ HTTPS + API Key (JSON-RPC / MCP protocol)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ MCP PRIVILEGED ACCESS SERVICE (this system) │
|
||||
│ ┌─────────────┐ ┌──────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ │
|
||||
│ │ CyberArk │ │ SSH │ │PowerShell│ │Database│ │ Auth + │ │
|
||||
│ │ MCP │ │ MCP │ │ MCP │ │ MCP │ │ Audit │ │
|
||||
│ └──────┬──────┘ └──┬───┘ └────┬─────┘ └───┬────┘ └─────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └────────────┴────────────┴─────────────┘ │
|
||||
│ Secret Store (RAM) │
|
||||
└───┬──────────────┬──────────────┬──────────────┬─────────────────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌───────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│CyberArk │Linux/Unix│ │ Windows │ │PostgreSQL│
|
||||
│ CCP │ │ Hosts │ │ Hosts │ │ MySQL │
|
||||
│(HTTPS)│ │ (SSH) │ │ (WinRM) │ │SQL Server│
|
||||
└───────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture Principles
|
||||
|
||||
### P1 — Zero password exposure to the LLM
|
||||
Passwords flow from CyberArk → RAM → target connection. At no stage does a password appear in an MCP tool response, log message, or error message. This is enforced in code, not by policy alone.
|
||||
|
||||
### P2 — Short-lived, single-use credential handles
|
||||
A credential fetched from CyberArk is wrapped in a cryptographically random handle (`secret://` + 32-char hex). The handle:
|
||||
- Expires after a configurable TTL (default 5 minutes)
|
||||
- Is invalidated on first use (default `HANDLE_SINGLE_USE=true`)
|
||||
- Lives only in process RAM — never written to disk or network
|
||||
|
||||
### P3 — Full audit trail
|
||||
Every credential fetch, handle resolution, SSH execution, PowerShell execution, and database query is recorded in structured JSON logs. Passwords and output data are **never** included in audit events.
|
||||
|
||||
### P4 — Defence in depth
|
||||
Multiple independent security layers:
|
||||
1. Network: service only reachable from permitted IP ranges (firewall / VPC)
|
||||
2. Transport: HTTPS with valid TLS certificate
|
||||
3. Application: API key authentication on every request
|
||||
4. Credential: CyberArk AppID + IP allowlist (or mTLS)
|
||||
5. Handle: short TTL + single use
|
||||
6. Code: `SecretStr` wrapper prevents accidental password serialisation
|
||||
|
||||
### P5 — Stateless compute, stateful secrets in RAM only
|
||||
No database, no disk state. The secret store is an in-memory dict with an asyncio lock. Service restart invalidates all handles (safe failure mode — operators must re-fetch).
|
||||
|
||||
### P6 — Explicit over implicit
|
||||
Every configuration value is explicit (`settings.*`), every dependency is injected at startup (lifespan), and every module imports only what it needs. No global mutable state except the two intentional singletons (`secret_store`, `cyberark_client`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Overview
|
||||
|
||||
### 4.1 Foundation Layer
|
||||
|
||||
| Component | File | Role |
|
||||
|-----------|------|------|
|
||||
| Configuration | `config.py` | Single pydantic-settings model; reads from env / `.env` file |
|
||||
| Secret Store | `secret_store.py` | In-RAM handle store with TTL, single-use, and background sweeper |
|
||||
| Auth Middleware | `auth.py` | Starlette middleware; validates API key on all `/mcp/*` routes |
|
||||
| Audit Logger | `audit.py` | Structured structlog events; one function per audit event type |
|
||||
| Service Entry Point | `main.py` | FastAPI app assembly, lifespan wiring, MCP server mounting |
|
||||
|
||||
### 4.2 MCP Servers
|
||||
|
||||
| Server | Mount Path | Tool(s) | Protocol | Auth to target |
|
||||
|--------|-----------|---------|----------|----------------|
|
||||
| CyberArk | `/mcp/cyberark` | `get_credential`, `list_safes` | HTTPS REST | IP allowlist / mTLS |
|
||||
| SSH | `/mcp/ssh` | `ssh_execute` | SSH (asyncssh) | Password from handle |
|
||||
| PowerShell | `/mcp/powershell` | `ps_execute` | WinRM (pypsrp) | Password from handle |
|
||||
| Database | `/mcp/database` | `db_query` | asyncpg / aiomysql / pyodbc | Password from handle |
|
||||
|
||||
Each MCP server is an independent `FastMCP` instance mounted as a sub-application on the shared FastAPI app. They share only two objects: `secret_store` (to resolve handles) and `settings` (configuration).
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication & Authorization Model
|
||||
|
||||
### Client → Service (inbound)
|
||||
|
||||
```
|
||||
Claude Code client
|
||||
│
|
||||
│ HTTP request to /mcp/<server>/...
|
||||
│ Header: X-API-Key: <key>
|
||||
│ OR
|
||||
│ Header: Authorization: Bearer <key>
|
||||
│
|
||||
▼
|
||||
ApiKeyMiddleware
|
||||
│
|
||||
├── Path starts with /mcp/ ?
|
||||
│ NO → pass through (health check, etc.)
|
||||
│ YES → validate key against settings.mcp_api_keys
|
||||
│ INVALID → 401 + audit log
|
||||
│ VALID → continue to MCP handler
|
||||
```
|
||||
|
||||
Multiple API keys are supported (comma-separated `MCP_API_KEYS`). Keys can be rotated by removing old keys and adding new ones, with no restart required if using a future key-reload mechanism.
|
||||
|
||||
### Service → CyberArk (outbound)
|
||||
|
||||
**Mode 1: IP Allowlist (current default)**
|
||||
- The service makes HTTPS GET requests to the CCP REST API
|
||||
- CyberArk trusts the caller based on source IP
|
||||
- The AppID (`CYBERARK_APP_ID`) identifies the application in CyberArk policy
|
||||
|
||||
**Mode 2: mTLS (future)**
|
||||
- A PFX certificate file is loaded at startup
|
||||
- The TLS client certificate is attached to every CCP request
|
||||
- CyberArk validates the certificate in addition to (or instead of) IP
|
||||
|
||||
### Service → Target Systems (outbound)
|
||||
|
||||
| Target | Auth method | Credentials from |
|
||||
|--------|-------------|-----------------|
|
||||
| SSH hosts | Password or key | Secret handle → `asyncssh.connect(password=...)` |
|
||||
| WinRM hosts | NTLM / Basic | Secret handle → `WSMan(password=...)` |
|
||||
| Databases | Native DB auth | Secret handle → driver connect call |
|
||||
|
||||
---
|
||||
|
||||
## 6. The Secret Handle Pattern
|
||||
|
||||
This is the central security innovation of the service. It solves the problem: *How does an AI model invoke privileged operations without ever knowing the password?*
|
||||
|
||||
```
|
||||
Step 1 — Credential fetch
|
||||
──────────────────────────
|
||||
Claude calls: get_credential(safe="PROD-LINUX", object_name="svc_root")
|
||||
|
||||
Service:
|
||||
1. Calls CyberArk CCP REST API
|
||||
2. Receives { "UserName": "root", "Content": "P@ssword123", ... }
|
||||
3. Calls secret_store.store("root", "P@ssword123")
|
||||
→ stores in RAM as _Entry with a random 32-char hex handle_id
|
||||
→ returns handle = "secret://a3f9c2e1b8d7..."
|
||||
4. Returns to Claude: "Handle: secret://a3f9c2e1... TTL: 300s"
|
||||
PASSWORD IS NEVER IN THIS RETURN VALUE
|
||||
|
||||
Step 2 — Privileged operation
|
||||
──────────────────────────────
|
||||
Claude calls: ssh_execute(host="server01", command="df -h",
|
||||
secret_handle="secret://a3f9c2e1b8d7...")
|
||||
|
||||
Service:
|
||||
1. Calls secret_store.resolve("secret://a3f9c2e1b8d7...")
|
||||
→ checks TTL and single-use flag
|
||||
→ if valid: returns ("root", "P@ssword123") and deletes handle
|
||||
2. Calls asyncssh.connect("server01", username="root", password="P@ssword123")
|
||||
3. Runs command, collects output
|
||||
4. Deletes password variable (del password)
|
||||
5. Returns: "Exit code: 0\nstdout:\n/dev/sda1 50G 10G 40G 20% /"
|
||||
PASSWORD IS NEVER IN THIS RETURN VALUE
|
||||
|
||||
Step 3 — Handle is gone
|
||||
────────────────────────
|
||||
If Claude tries to reuse the same handle:
|
||||
secret_store.resolve(...) raises KeyError("Handle already consumed")
|
||||
→ Claude must call get_credential again for the next operation
|
||||
```
|
||||
|
||||
**Handle lifecycle state machine:**
|
||||
|
||||
```
|
||||
store()
|
||||
CREATED ────────────────► ACTIVE
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ │
|
||||
resolve() TTL expired
|
||||
(single_use=True) (sweeper task)
|
||||
│ │
|
||||
▼ ▼
|
||||
CONSUMED EXPIRED
|
||||
(deleted) (deleted)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Flow — Key Use Cases
|
||||
|
||||
### 7.1 SSH Command Execution
|
||||
|
||||
```
|
||||
Claude CyberArk MCP SecretStore SSH MCP Linux Host
|
||||
│ │ │ │ │
|
||||
│ get_credential │ │ │ │
|
||||
│────────────────►│ │ │ │
|
||||
│ │ GET CCP REST │ │ │
|
||||
│ │──────────────────────────────────────────────►│(CyberArk)
|
||||
│ │◄─────────────────────────────────────────────-│
|
||||
│ │ store(user,pw)│ │ │
|
||||
│ │──────────────►│ │ │
|
||||
│ │◄── handle ────│ │ │
|
||||
│◄── handle ──────│ │ │ │
|
||||
│ │ │ │ │
|
||||
│ ssh_execute(handle) │ │ │
|
||||
│─────────────────────────────────────────────────► │
|
||||
│ │ │ resolve(handle│ │
|
||||
│ │ │◄──────────────│ │
|
||||
│ │ │──(user,pw)───►│ │
|
||||
│ │ │ │ SSH connect │
|
||||
│ │ │ │──────────────►│
|
||||
│ │ │ │◄── output ───-│
|
||||
│ │ │ │ del password │
|
||||
│◄─────────────────────────────────────────────────output────────│
|
||||
```
|
||||
|
||||
### 7.2 Database Query
|
||||
|
||||
Identical flow to SSH, substituting `db_query` for `ssh_execute` and the target database driver for asyncssh.
|
||||
|
||||
### 7.3 PowerShell Execution
|
||||
|
||||
The WinRM flow differs in one aspect: pypsrp is synchronous, so the call is offloaded to a thread-pool executor while the asyncio event loop continues serving other requests.
|
||||
|
||||
```
|
||||
asyncio event loop Thread pool executor
|
||||
────────────────── ────────────────────
|
||||
resolve handle
|
||||
await run_in_executor(None, _run_ps_sync, ...) ──────► _run_ps_sync()
|
||||
[event loop free to handle other requests] WSMan()
|
||||
RunspacePool()
|
||||
ps.invoke()
|
||||
◄────────────────────────────────────────────── return output
|
||||
del password
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Architecture
|
||||
|
||||
### Recommended (Docker on a hardened VM)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Hardened VM (e.g., Ubuntu 22.04) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Docker container │ │
|
||||
│ │ Image: mcp-privileged:1.0 │ │
|
||||
│ │ User: mcpuser (non-root) │ │
|
||||
│ │ Port: 8443 (internal) │ │
|
||||
│ └──────────────┬──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────▼──────────────────────┐ │
|
||||
│ │ Reverse proxy (nginx / Caddy) │ │
|
||||
│ │ TLS termination │ │
|
||||
│ │ Port: 443 (external) │ │
|
||||
│ └────────────────────────────────────── │
|
||||
└──────────────────────────────────────────────┘
|
||||
│
|
||||
│ Firewall: only Claude Code source IPs allowed
|
||||
```
|
||||
|
||||
### Network segmentation requirements
|
||||
|
||||
| Connection | Inbound to | Source | Port |
|
||||
|-----------|------------|--------|------|
|
||||
| Claude Code → Service | Service host | Claude Code client IPs | 443 (HTTPS) |
|
||||
| Service → CyberArk CCP | CyberArk | Service host IP | 443 (HTTPS) |
|
||||
| Service → SSH targets | Linux hosts | Service host IP | 22 (or custom) |
|
||||
| Service → WinRM targets | Windows hosts | Service host IP | 5985/5986 |
|
||||
| Service → Databases | DB servers | Service host IP | 5432/3306/1433 |
|
||||
|
||||
### Health check
|
||||
|
||||
`GET /health` returns `{"status": "ok"}` with no authentication. Suitable for load balancer and container health probes.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technology Choices
|
||||
|
||||
| Technology | Choice | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Web framework | FastAPI | Async-native, excellent OpenAPI support, Starlette middleware |
|
||||
| MCP framework | FastMCP (mcp[server]) | Official Python MCP SDK; Streamable HTTP transport |
|
||||
| HTTP client | httpx | Async, connection pooling, easy mock transport for tests |
|
||||
| SSH | asyncssh | Pure-Python async SSH2; no subprocess dependency |
|
||||
| WinRM | pypsrp | Python PowerShell Remoting Protocol; most complete WinRM library |
|
||||
| PostgreSQL | asyncpg | Fastest async Postgres driver; native protocol |
|
||||
| MySQL | aiomysql | Async MySQL driver |
|
||||
| SQL Server | pyodbc | Standard ODBC; requires Microsoft ODBC Driver 18 on host |
|
||||
| Config | pydantic-settings | Type-safe config; reads from env + `.env`; validates at startup |
|
||||
| Logging | structlog | Structured JSON output; easy log shipping; context vars |
|
||||
| Crypto | cryptography | PFX parsing for mTLS; well-maintained |
|
||||
| Runtime | Python 3.11 | asyncio improvements, `tomllib`, `ExceptionGroup`, slots dataclasses |
|
||||
| Container | Docker (multi-stage) | Small runtime image; non-root user; no build tools in production |
|
||||
|
||||
---
|
||||
|
||||
## 10. Security Architecture Summary
|
||||
|
||||
| Control | Implementation | Protects Against |
|
||||
|---------|---------------|-----------------|
|
||||
| TLS in transit | HTTPS everywhere (CCP, service) | Eavesdropping, MITM |
|
||||
| API key auth | `ApiKeyMiddleware` on all `/mcp/*` | Unauthorised tool calls |
|
||||
| CyberArk AppID | Registered in CyberArk policy | Unauthorised credential access |
|
||||
| IP allowlist (CyberArk) | CyberArk trusted-net config | Rogue callers to CCP |
|
||||
| mTLS (future) | PFX cert on CCP requests | Stronger caller identity |
|
||||
| Secret handle | Opaque token, not password | Password exposure to LLM |
|
||||
| Single-use handle | `handle_single_use=True` | Credential replay |
|
||||
| TTL on handle | Default 300s | Handle leakage window |
|
||||
| RAM-only storage | `SecretStore` dict, no disk I/O | Credential at-rest exposure |
|
||||
| `SecretStr` wrapper | Pydantic `SecretStr` | Accidental log/repr of password |
|
||||
| `del password` | Explicit deletion after use | Password in heap dumps |
|
||||
| Audit log (no password) | structlog, explicit field list | Credential in log files |
|
||||
| Non-root container | `USER mcpuser` in Dockerfile | Container escape impact |
|
||||
| Output limits | 50 KB per stream, 1000 DB rows | Context flooding / DoS |
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Roadmap
|
||||
|
||||
| Item | Priority | Description |
|
||||
|------|----------|-------------|
|
||||
| mTLS for CyberArk | High | Config already present; needs PFX cert provisioning |
|
||||
| API key rotation without restart | Medium | Watch env file or use a config reload endpoint |
|
||||
| SSH key-based auth | Medium | Support `asyncssh` with private key from CyberArk |
|
||||
| Kerberos/NTLM for WinRM | Medium | Currently NTLM; Kerberos for domain environments |
|
||||
| Connection pooling (SSH) | Low | Reuse SSH connections for repeated calls to same host |
|
||||
| Multi-tenant API keys | Low | Map API keys to CyberArk AppIDs for key-per-team isolation |
|
||||
| Metrics endpoint | Low | Prometheus `/metrics` for connection counts, handle stats |
|
||||
| Session recording integration | Low | Forward SSH output to CyberArk PSM or a SIEM |
|
||||
BIN
docs/LLD.docx
Normal file
BIN
docs/LLD.docx
Normal file
Binary file not shown.
900
docs/LLD.md
Normal file
900
docs/LLD.md
Normal file
@@ -0,0 +1,900 @@
|
||||
# Low-Level Design
|
||||
# MCP Privileged Access Service
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-03-28
|
||||
**Status:** Production-ready
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Module Structure](#1-module-structure)
|
||||
2. [Foundation Modules](#2-foundation-modules)
|
||||
- 2.1 config.py
|
||||
- 2.2 secret_store.py
|
||||
- 2.3 auth.py
|
||||
- 2.4 audit.py
|
||||
- 2.5 main.py
|
||||
3. [CyberArk MCP](#3-cyberark-mcp)
|
||||
4. [SSH MCP](#4-ssh-mcp)
|
||||
5. [PowerShell MCP](#5-powershell-mcp)
|
||||
6. [Database MCP](#6-database-mcp)
|
||||
7. [MCP Tool API Reference](#7-mcp-tool-api-reference)
|
||||
8. [Data Models](#8-data-models)
|
||||
9. [Configuration Reference](#9-configuration-reference)
|
||||
10. [Error Handling Matrix](#10-error-handling-matrix)
|
||||
11. [Audit Event Catalog](#11-audit-event-catalog)
|
||||
12. [Test Strategy](#12-test-strategy)
|
||||
|
||||
---
|
||||
|
||||
## 1. Module Structure
|
||||
|
||||
```
|
||||
src/mcp_privileged/
|
||||
├── __init__.py
|
||||
├── config.py ← All settings; read once at import time
|
||||
├── secret_store.py ← In-RAM handle store + background sweeper
|
||||
├── auth.py ← API key middleware
|
||||
├── audit.py ← Structured log helpers
|
||||
├── main.py ← FastAPI app assembly + lifespan
|
||||
│
|
||||
├── cyberark/
|
||||
│ ├── __init__.py
|
||||
│ ├── client.py ← CCP REST client (httpx)
|
||||
│ └── server.py ← FastMCP server; get_credential, list_safes tools
|
||||
│
|
||||
├── ssh/
|
||||
│ ├── __init__.py
|
||||
│ └── server.py ← FastMCP server; ssh_execute tool
|
||||
│
|
||||
├── powershell/
|
||||
│ ├── __init__.py
|
||||
│ └── server.py ← FastMCP server; ps_execute tool
|
||||
│
|
||||
└── database/
|
||||
├── __init__.py
|
||||
└── server.py ← FastMCP server; db_query tool
|
||||
```
|
||||
|
||||
**Dependency graph (no cycles):**
|
||||
|
||||
```
|
||||
main.py
|
||||
├── config.py (leaf)
|
||||
├── audit.py ← config.py
|
||||
├── auth.py ← config.py, audit.py
|
||||
├── secret_store.py ← config.py, audit.py
|
||||
├── cyberark/
|
||||
│ ├── client.py ← config.py, audit.py
|
||||
│ └── server.py ← cyberark/client.py, secret_store.py, audit.py
|
||||
├── ssh/server.py ← config.py, secret_store.py, audit.py
|
||||
├── powershell/server.py ← config.py, secret_store.py, audit.py
|
||||
└── database/server.py ← config.py, secret_store.py, audit.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Foundation Modules
|
||||
|
||||
### 2.1 config.py
|
||||
|
||||
**Class:** `Settings(BaseSettings)`
|
||||
**Singleton:** `settings = Settings()` — imported everywhere as `from mcp_privileged.config import settings`
|
||||
|
||||
The settings object is created once when the module is first imported. If any required value is missing or invalid, pydantic raises a `ValidationError` at startup — fail fast.
|
||||
|
||||
#### Settings groups
|
||||
|
||||
**Service**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `mcp_host` | `MCP_HOST` | `0.0.0.0` | `str` | Bind address for uvicorn |
|
||||
| `mcp_port` | `MCP_PORT` | `8443` | `int` | Listen port |
|
||||
| `mcp_api_keys_raw` | `MCP_API_KEYS` | *(required)* | `str` | Comma-separated API keys — access via the `mcp_api_keys` property |
|
||||
|
||||
**Secret Handle Store**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `handle_ttl_seconds` | `HANDLE_TTL_SECONDS` | `300` | `int` (30–3600) | Handle expiry |
|
||||
| `handle_single_use` | `HANDLE_SINGLE_USE` | `True` | `bool` | Invalidate on first resolve |
|
||||
|
||||
**CyberArk CCP**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `cyberark_ccp_url` | `CYBERARK_CCP_URL` | — | `str` | Full CCP REST endpoint URL |
|
||||
| `cyberark_app_id` | `CYBERARK_APP_ID` | — | `str` | AppID registered in CyberArk |
|
||||
| `cyberark_verify_ssl` | `CYBERARK_VERIFY_SSL` | system CAs | `str` | `"false"`, `"true"`, or CA path |
|
||||
| `cyberark_cert_pfx_path` | `CYBERARK_CERT_PFX_PATH` | `None` | `Path\|None` | mTLS client cert (PFX) |
|
||||
| `cyberark_cert_pfx_password` | `CYBERARK_CERT_PFX_PASSWORD` | `None` | `str\|None` | PFX password |
|
||||
|
||||
**PowerShell / WinRM**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `winrm_auth` | `WINRM_AUTH` | `ntlm` | `str` | `ntlm` or `basic` |
|
||||
| `winrm_connect_timeout_seconds` | `WINRM_CONNECT_TIMEOUT_SECONDS` | `15` | `int` | WinRM connection timeout |
|
||||
| `winrm_operation_timeout_seconds` | `WINRM_OPERATION_TIMEOUT_SECONDS` | `20` | `int` | WinRM operation timeout |
|
||||
| `winrm_max_output_bytes` | `WINRM_MAX_OUTPUT_BYTES` | `51200` | `int` | Max bytes per output object |
|
||||
|
||||
**SSH**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `ssh_known_hosts` | `SSH_KNOWN_HOSTS` | `~/.ssh/known_hosts` | `str` | Path or `"disable"` |
|
||||
| `ssh_connect_timeout_seconds` | `SSH_CONNECT_TIMEOUT_SECONDS` | `10` | `int` | SSH connection timeout |
|
||||
| `ssh_max_output_bytes` | `SSH_MAX_OUTPUT_BYTES` | `51200` | `int` | Max bytes per stdout/stderr |
|
||||
|
||||
**Database**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `db_connect_timeout_seconds` | `DB_CONNECT_TIMEOUT_SECONDS` | `10` | `int` | DB connection timeout |
|
||||
| `db_query_timeout_seconds` | `DB_QUERY_TIMEOUT_SECONDS` | `30` | `int` | Query execution timeout |
|
||||
| `db_max_rows` | `DB_MAX_ROWS` | `1000` | `int` | Row result cap |
|
||||
| `db_max_cell_bytes` | `DB_MAX_CELL_BYTES` | `1024` | `int` | Per-cell truncation threshold |
|
||||
|
||||
**Logging**
|
||||
|
||||
| Setting | Env var | Default | Type | Description |
|
||||
|---------|---------|---------|------|-------------|
|
||||
| `log_format` | `LOG_FORMAT` | `json` | `"json"\|"console"` | Output format |
|
||||
| `log_level` | `LOG_LEVEL` | `INFO` | `"DEBUG"\|"INFO"\|..` | Minimum log level |
|
||||
|
||||
#### Validators
|
||||
|
||||
- `_parse_and_validate_api_keys` (model validator): validates that `MCP_API_KEYS` is non-empty and not equal to the default `"changeme"` — service refuses to start if either condition is violated. Raises `ValidationError` at import time (fail-fast).
|
||||
- `mcp_api_keys` (property): splits `mcp_api_keys_raw` on commas, strips whitespace, returns `frozenset[str]`. Implemented as a `@property` (not a pydantic field) to avoid a name collision with the pydantic-settings env-var auto-mapping.
|
||||
- `cyberark_ssl_verify`: maps `"false"` → `False`, `"true"` or `""` → `True`, anything else → path string
|
||||
- `cyberark_cert_pfx_path`: empty string → `None`
|
||||
- `_validate_pfx` (model validator): if PFX path is set, the file must exist and the password must be non-empty
|
||||
|
||||
---
|
||||
|
||||
### 2.2 secret_store.py
|
||||
|
||||
**Class:** `SecretStore`
|
||||
**Singleton:** `secret_store = SecretStore()`
|
||||
|
||||
#### Internal data structure
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class _Entry:
|
||||
handle_id: str # 32-char hex, the key in _store
|
||||
username: str # plaintext (used as SSH/DB username)
|
||||
password: SecretStr # pydantic SecretStr — prevents accidental str() exposure
|
||||
created_at: float # time.monotonic() at creation
|
||||
resolved: bool = False # set to True on first resolve
|
||||
|
||||
def is_expired(self, ttl: int) -> bool:
|
||||
return (time.monotonic() - self.created_at) > ttl
|
||||
```
|
||||
|
||||
```python
|
||||
class SecretStore:
|
||||
_store: dict[str, _Entry] # handle_id → entry
|
||||
_lock: asyncio.Lock # all mutations are locked
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
**`async store(username, password) → str`**
|
||||
1. Generate `handle_id = secrets.token_hex(16)` (32 hex chars, cryptographically random)
|
||||
2. Create `_Entry(handle_id, username, SecretStr(password))`
|
||||
3. Acquire lock, insert into `_store`
|
||||
4. Return `"secret://" + handle_id`
|
||||
|
||||
**`async resolve(handle, resolved_by="unknown") → (str, str)`**
|
||||
1. Parse handle → extract `handle_id` (raises `ValueError` if prefix wrong)
|
||||
2. Acquire lock
|
||||
3. Lookup entry — `KeyError` if not found
|
||||
4. Check TTL — `KeyError("expired")` + delete if expired
|
||||
5. Check `resolved` + `single_use` — `KeyError("already consumed")` if violated
|
||||
6. Mark `entry.resolved = True`; delete if `single_use`
|
||||
7. Release lock
|
||||
8. Log `handle_resolved` audit event
|
||||
9. Return `(entry.username, entry.password.get_secret_value())`
|
||||
|
||||
> `get_secret_value()` is the **only** intentional unwrap point in the entire codebase.
|
||||
|
||||
**`async revoke(handle) → bool`**
|
||||
Explicit early revocation. Returns `True` if the handle existed.
|
||||
|
||||
**`async purge_expired() → int`**
|
||||
Scans all entries and deletes expired ones. Called by the background sweeper every 60 seconds. Returns count of deleted entries.
|
||||
|
||||
#### Background sweeper
|
||||
|
||||
```python
|
||||
async def _sweeper(store, interval_seconds=60):
|
||||
while True:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
count = await store.purge_expired()
|
||||
```
|
||||
|
||||
Started in `main.py` lifespan as an `asyncio.Task`. Cancelled on shutdown.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 auth.py
|
||||
|
||||
**Class:** `ApiKeyMiddleware(BaseHTTPMiddleware)`
|
||||
|
||||
```
|
||||
Request arrives
|
||||
│
|
||||
▼
|
||||
Does path start with "/mcp/"?
|
||||
│
|
||||
NO ├──────────────────────────────► pass through (health check etc.)
|
||||
│
|
||||
YES ▼
|
||||
Extract key from headers:
|
||||
1. X-API-Key: <value>
|
||||
2. Authorization: Bearer <value>
|
||||
│
|
||||
▼
|
||||
key in settings.mcp_api_keys ?
|
||||
│
|
||||
NO ├──────────────────────────────► 401 JSON + log_auth_failure()
|
||||
│
|
||||
YES ▼
|
||||
call_next(request)
|
||||
```
|
||||
|
||||
Key validation uses `hmac.compare_digest` in a non-short-circuiting loop over all configured keys, providing timing-safe comparison that prevents an attacker from inferring key length or prefix from response time differences:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def _is_valid_key(key: str) -> bool:
|
||||
key_bytes = key.encode()
|
||||
valid = False
|
||||
for configured_key in settings.mcp_api_keys:
|
||||
if hmac.compare_digest(key_bytes, configured_key.encode()):
|
||||
valid = True # set flag, do NOT return early
|
||||
return valid
|
||||
```
|
||||
|
||||
The loop always iterates all keys (no `return True` inside the loop) so the response time does not leak how many keys are configured or how close a guess was.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 audit.py
|
||||
|
||||
Wraps `structlog` with named functions so every audit event has a consistent schema. See [Section 11](#11-audit-event-catalog) for the full catalog.
|
||||
|
||||
**Configuration:** `configure_logging()` must be called once at startup (called in lifespan and in the `run()` entry point).
|
||||
|
||||
Processors pipeline:
|
||||
```
|
||||
merge_contextvars → add_logger_name → add_log_level → TimeStamper(iso)
|
||||
→ StackInfoRenderer → ProcessorFormatter.wrap_for_formatter
|
||||
→ [JSONRenderer | ConsoleRenderer]
|
||||
```
|
||||
|
||||
Third-party loggers suppressed to WARNING: `uvicorn.access`, `asyncssh`, `pypsrp`.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 main.py
|
||||
|
||||
**Function:** `create_app() → FastAPI`
|
||||
|
||||
Assembly sequence:
|
||||
1. Create `FastAPI(lifespan=lifespan, docs_url=None, ...)` — docs disabled in production
|
||||
2. Add `ApiKeyMiddleware`
|
||||
3. Register `GET /health` route (no auth)
|
||||
4. Import and mount four MCP servers:
|
||||
- `cyberark_mcp` at `/mcp/cyberark`
|
||||
- `ssh_mcp` at `/mcp/ssh`
|
||||
- `powershell_mcp` at `/mcp/powershell`
|
||||
- `database_mcp` at `/mcp/database`
|
||||
|
||||
**Lifespan (async context manager):**
|
||||
|
||||
```
|
||||
startup:
|
||||
configure_logging()
|
||||
await cyberark_client.start() ← creates httpx.AsyncClient
|
||||
sweeper_task = await start_sweeper(secret_store)
|
||||
|
||||
shutdown:
|
||||
sweeper_task.cancel()
|
||||
await sweeper_task ← wait for cancellation
|
||||
await cyberark_client.stop() ← closes httpx.AsyncClient
|
||||
```
|
||||
|
||||
**CLI entry point:** `mcp-privileged` → `mcp_privileged.main:run`
|
||||
|
||||
```python
|
||||
def run():
|
||||
configure_logging()
|
||||
app = create_app()
|
||||
uvicorn.run(app, host=settings.mcp_host, port=settings.mcp_port,
|
||||
log_config=None, access_log=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CyberArk MCP
|
||||
|
||||
### 3.1 client.py — CyberArkCCPClient
|
||||
|
||||
**Singleton:** `cyberark_client = CyberArkCCPClient()`
|
||||
|
||||
#### Lifecycle
|
||||
|
||||
```python
|
||||
await cyberark_client.start() # creates httpx.AsyncClient
|
||||
await cyberark_client.stop() # closes httpx.AsyncClient
|
||||
```
|
||||
|
||||
The `httpx.AsyncClient` is created once and reused for connection pooling. Timeouts: connect=5s, read=15s, write=5s, pool=5s.
|
||||
|
||||
#### `get_credential(app_id, safe, object_name) → Credential`
|
||||
|
||||
```
|
||||
GET {CYBERARK_CCP_URL}?AppID={app_id}&Safe={safe}&Object={object_name}
|
||||
```
|
||||
|
||||
Response parsing:
|
||||
- HTTP 200 → `Credential` dataclass from JSON body
|
||||
- HTTP 4xx/5xx → parse `ErrorCode`/`ErrorMsg` → raise `CyberArkError`
|
||||
- Non-JSON body → raise `CyberArkError(status_code=...)`
|
||||
- `httpx.ConnectError` → `CyberArkError("Cannot reach CCP")`
|
||||
- `httpx.TimeoutException` → `CyberArkError("CCP request timed out")`
|
||||
|
||||
#### SSL modes
|
||||
|
||||
| Condition | `_build_ssl_context()` returns |
|
||||
|-----------|-------------------------------|
|
||||
| `cyberark_cert_pfx_path is None` | `settings.cyberark_ssl_verify` (bool or path) |
|
||||
| PFX path set | `ssl.SSLContext` with client cert loaded |
|
||||
|
||||
For mTLS, the PFX is parsed with `cryptography`, cert+key are written to a `tempfile.mkstemp(suffix=".pem")` with `chmod 600`, loaded into the SSLContext, then the temp file is immediately deleted with `os.unlink()`.
|
||||
|
||||
#### Error codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `APPAP004E` | AppID not found or not permitted |
|
||||
| `APPAP006E` | Authentication failure (IP allowlist / AppID mismatch) |
|
||||
| `APPAP007E` | Credential object not found in safe |
|
||||
| `APPAP008E` | No password found for object |
|
||||
| `APPAP009E` | Dual control pending approval |
|
||||
| `APPAP010E` | Dual control approval timed out |
|
||||
| `ITATS023E` | Object not found |
|
||||
| `ITATS012E` | Safe not found |
|
||||
|
||||
### 3.2 server.py — CyberArk MCP
|
||||
|
||||
**Tools:** `get_credential`, `list_safes`
|
||||
|
||||
#### `get_credential(safe, object_name, ctx, app_id="")`
|
||||
|
||||
```
|
||||
1. Resolve effective AppID (param or settings.cyberark_app_id)
|
||||
2. ctx.info(...)
|
||||
3. cyberark_client.get_credential(...)
|
||||
→ on CyberArkError: ctx.error(...); raise
|
||||
4. secret_store.store(credential.username, credential.password)
|
||||
→ returns handle
|
||||
5. log_credential_fetched(app_id, safe, object_name, handle_id, ttl, client_ip)
|
||||
6. ctx.info("Credential retrieved. Handle issued...")
|
||||
7. Return formatted string:
|
||||
"Credential retrieved successfully.\n
|
||||
Handle: secret://...\n
|
||||
Username: ...\n
|
||||
Address: ...\n
|
||||
Platform: ...\n
|
||||
TTL: 300 seconds\n
|
||||
Use this handle with ssh_execute, ps_execute, or db_connect."
|
||||
```
|
||||
|
||||
The return value is carefully crafted — it contains the handle (needed by Claude for the next step) plus metadata (username, address) to help Claude route the next call correctly, but **never** the password.
|
||||
|
||||
#### `list_safes(ctx, app_id="")`
|
||||
|
||||
Calls `cyberark_client.list_safes(app_id)`. Currently raises `NotImplementedError` (CCP has no native list-safes endpoint). The tool catches this and returns an informational message instead of raising.
|
||||
|
||||
---
|
||||
|
||||
## 4. SSH MCP
|
||||
|
||||
### 4.1 server.py
|
||||
|
||||
**Tool:** `ssh_execute`
|
||||
|
||||
#### Execution sequence
|
||||
|
||||
```
|
||||
ssh_execute(host, command, secret_handle, ctx, port=22, username_override="", timeout_seconds=30)
|
||||
│
|
||||
├── secret_store.resolve(secret_handle, resolved_by="ssh")
|
||||
│ KeyError → ctx.error(...); raise
|
||||
│
|
||||
├── if username_override: username = username_override
|
||||
│
|
||||
├── ctx.info("SSH connecting to ...")
|
||||
│
|
||||
├── _resolve_known_hosts(settings.ssh_known_hosts)
|
||||
│ "disable" → None (no host key check, logs warning)
|
||||
│ else → expanded path string
|
||||
│
|
||||
├── async with asyncssh.connect(host, port, username, password,
|
||||
│ known_hosts, connect_timeout) as conn:
|
||||
│ ├── result = await conn.run(command, timeout=timeout_seconds)
|
||||
│ └── [exceptions caught — see error matrix]
|
||||
│
|
||||
├── del password (in finally block)
|
||||
│
|
||||
├── stdout = _truncate(result.stdout, ssh_max_output_bytes, "stdout")
|
||||
├── stderr = _truncate(result.stderr, ssh_max_output_bytes, "stderr")
|
||||
├── exit_code = result.exit_status ?? -1
|
||||
│
|
||||
├── log_ssh_executed(...)
|
||||
├── ctx.info("SSH command completed ...")
|
||||
│
|
||||
└── return _format_result(host, command, exit_code, stdout, stderr)
|
||||
```
|
||||
|
||||
#### Output format
|
||||
|
||||
```
|
||||
Host: linux01.internal
|
||||
Command: df -h
|
||||
Exit code: 0
|
||||
|
||||
--- stdout ---
|
||||
Filesystem Size Used Avail Use% Mounted on
|
||||
/dev/sda1 50G 10G 40G 20% /
|
||||
|
||||
--- stderr ---
|
||||
(only present if non-empty)
|
||||
```
|
||||
|
||||
#### Known hosts handling
|
||||
|
||||
| `ssh_known_hosts` value | Passed to asyncssh | Behaviour |
|
||||
|------------------------|-------------------|-----------|
|
||||
| `"disable"` | `None` | No host key verification (dev/lab only) |
|
||||
| `"~/.ssh/known_hosts"` | `"/home/user/.ssh/known_hosts"` | Verify against file |
|
||||
| `/etc/ssh/known_hosts` | `/etc/ssh/known_hosts` | Verify against file |
|
||||
|
||||
---
|
||||
|
||||
## 5. PowerShell MCP
|
||||
|
||||
### 5.1 server.py
|
||||
|
||||
**Tool:** `ps_execute`
|
||||
|
||||
#### Thread executor pattern
|
||||
|
||||
pypsrp is synchronous. The blocking WinRM call is wrapped in `asyncio.get_running_loop().run_in_executor(None, ...)`:
|
||||
|
||||
```python
|
||||
loop = asyncio.get_running_loop()
|
||||
output_lines, had_errors, error_records = await loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(_run_ps_sync, host, port, username, password, script, use_ssl, timeout_seconds),
|
||||
)
|
||||
```
|
||||
|
||||
`None` uses the default `ThreadPoolExecutor`. The event loop remains responsive to other requests while WinRM is in progress.
|
||||
|
||||
#### `_run_ps_sync()` (thread worker)
|
||||
|
||||
```python
|
||||
wsman = WSMan(
|
||||
host, port=port, username=username, password=password,
|
||||
ssl=use_ssl,
|
||||
auth=settings.winrm_auth, # "ntlm" or "basic"
|
||||
cert_validation=use_ssl, # only check cert when using HTTPS
|
||||
connection_timeout=settings.winrm_connect_timeout_seconds,
|
||||
operation_timeout=max(
|
||||
timeout_seconds + 10,
|
||||
settings.winrm_operation_timeout_seconds,
|
||||
),
|
||||
)
|
||||
with RunspacePool(wsman) as pool:
|
||||
ps = PowerShell(pool)
|
||||
ps.add_script(script)
|
||||
raw_output = ps.invoke()
|
||||
had_errors = ps.had_errors
|
||||
error_records = [str(e) for e in ps.streams.error]
|
||||
```
|
||||
|
||||
Each item in `raw_output` is converted via `str()` and truncated to `winrm_max_output_bytes`.
|
||||
|
||||
#### Output format
|
||||
|
||||
```
|
||||
Host: win01.internal
|
||||
Script length: 43 chars
|
||||
Had errors: False
|
||||
|
||||
--- output ---
|
||||
WIN-SERVER-01
|
||||
6.1.7601.65536
|
||||
|
||||
--- errors ---
|
||||
(only present if had_errors or error_records is non-empty)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Database MCP
|
||||
|
||||
### 6.1 server.py
|
||||
|
||||
**Tool:** `db_query`
|
||||
|
||||
#### Driver dispatch
|
||||
|
||||
```python
|
||||
async def _dispatch_query(db_type, host, port, database, username, password, query, timeout_seconds):
|
||||
if db_type == "postgres": return await _query_postgres(...)
|
||||
if db_type == "mysql": return await _query_mysql(...)
|
||||
# mssql: run synchronous pyodbc in thread pool
|
||||
return await loop.run_in_executor(None, partial(_query_mssql_sync, ...))
|
||||
```
|
||||
|
||||
#### PostgreSQL (`asyncpg`)
|
||||
|
||||
```python
|
||||
conn = await asyncpg.connect(host, port, user, password, database, timeout=connect_timeout)
|
||||
rows = await conn.fetch(query, timeout=query_timeout)
|
||||
columns = list(rows[0].keys())
|
||||
data = [list(row.values()) for row in rows]
|
||||
await conn.close()
|
||||
```
|
||||
|
||||
#### MySQL (`aiomysql`)
|
||||
|
||||
```python
|
||||
conn = await aiomysql.connect(host, port, user, password, db, connect_timeout)
|
||||
async with conn.cursor() as cursor:
|
||||
await asyncio.wait_for(cursor.execute(query), timeout=query_timeout)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = await cursor.fetchall()
|
||||
conn.close()
|
||||
```
|
||||
|
||||
#### SQL Server (`pyodbc` — sync)
|
||||
|
||||
```python
|
||||
conn_str = (
|
||||
"DRIVER={ODBC Driver 18 for SQL Server};"
|
||||
f"SERVER={host},{port};DATABASE={database};UID={username};PWD={password};"
|
||||
f"Connection Timeout={connect_timeout};"
|
||||
)
|
||||
with pyodbc.connect(conn_str, timeout=query_timeout) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = [list(row) for row in cursor.fetchall()]
|
||||
```
|
||||
|
||||
If `pyodbc` is not importable (missing system ODBC driver), raises `RuntimeError` with installation instructions.
|
||||
|
||||
#### Row and cell limits
|
||||
|
||||
After query execution:
|
||||
1. If `len(rows) > settings.db_max_rows`: truncate to `db_max_rows`, set `truncated=True`
|
||||
2. For each cell in `_format_result`: `_cell_str()` truncates at `db_max_cell_bytes` UTF-8 bytes and appends `…`
|
||||
|
||||
#### Output format
|
||||
|
||||
```
|
||||
Host: pg.internal
|
||||
Database: prod (postgres)
|
||||
Query length: 38 chars
|
||||
Rows returned: 3
|
||||
Elapsed: 12ms
|
||||
|
||||
id | name | email
|
||||
----|---------|----------------
|
||||
1 | Alice | alice@corp.com
|
||||
2 | Bob | bob@corp.com
|
||||
3 | Charlie | charlie@corp.com
|
||||
```
|
||||
|
||||
If rows are capped: `Rows returned: 1000 (capped — more rows exist)`
|
||||
|
||||
---
|
||||
|
||||
## 7. MCP Tool API Reference
|
||||
|
||||
All tools follow the JSON-RPC 2.0 envelope defined by the MCP protocol. Parameters below are the tool-level parameters (inside `arguments`).
|
||||
|
||||
### `get_credential`
|
||||
|
||||
**MCP path:** `POST /mcp/cyberark/...`
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `safe` | `string` | Yes | — | CyberArk Safe name |
|
||||
| `object_name` | `string` | Yes | — | Credential object name in the Safe |
|
||||
| `app_id` | `string` | No | `CYBERARK_APP_ID` | Override the service AppID |
|
||||
|
||||
**Returns:** Plain text with handle, username, address, platform, TTL.
|
||||
|
||||
**Errors:**
|
||||
- `CyberArkError` — CCP returned an error (APPAP00xE etc.)
|
||||
- `RuntimeError` — CyberArk client not started
|
||||
|
||||
---
|
||||
|
||||
### `list_safes`
|
||||
|
||||
**MCP path:** `POST /mcp/cyberark/...`
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `app_id` | `string` | No | `CYBERARK_APP_ID` | AppID to list safes for |
|
||||
|
||||
**Returns:** Newline-separated list of Safe names, or informational message if not configured.
|
||||
|
||||
---
|
||||
|
||||
### `ssh_execute`
|
||||
|
||||
**MCP path:** `POST /mcp/ssh/...`
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `host` | `string` | Yes | — | Hostname or IP |
|
||||
| `command` | `string` | Yes | — | Shell command |
|
||||
| `secret_handle` | `string` | Yes | — | Handle from `get_credential` |
|
||||
| `port` | `integer` | No | `22` | SSH port |
|
||||
| `username_override` | `string` | No | `""` | Override credential username |
|
||||
| `timeout_seconds` | `integer` | No | `30` | Command timeout |
|
||||
|
||||
**Returns:** Formatted text with host, command, exit code, stdout, stderr.
|
||||
|
||||
**Errors:**
|
||||
- `KeyError` — handle not found, expired, or already consumed
|
||||
- `asyncssh.PermissionDenied` — authentication failure
|
||||
- `asyncssh.DisconnectError` — SSH disconnection
|
||||
- `asyncio.TimeoutError` — command timed out
|
||||
- `OSError` — network error (connection refused, DNS failure)
|
||||
|
||||
---
|
||||
|
||||
### `ps_execute`
|
||||
|
||||
**MCP path:** `POST /mcp/powershell/...`
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `host` | `string` | Yes | — | Hostname or IP |
|
||||
| `script` | `string` | Yes | — | PowerShell script text |
|
||||
| `secret_handle` | `string` | Yes | — | Handle from `get_credential` |
|
||||
| `port` | `integer` | No | `5985` | WinRM port (5986 for HTTPS) |
|
||||
| `use_ssl` | `boolean` | No | `false` | Use HTTPS for WinRM |
|
||||
| `timeout_seconds` | `integer` | No | `60` | Script execution timeout |
|
||||
| `username_override` | `string` | No | `""` | Override credential username |
|
||||
|
||||
**Returns:** Formatted text with host, script length, had_errors, output, error records.
|
||||
|
||||
**Errors:**
|
||||
- `KeyError` — handle not found/expired/consumed
|
||||
- Any exception from pypsrp (WinRM connection error, auth failure, etc.)
|
||||
|
||||
---
|
||||
|
||||
### `db_query`
|
||||
|
||||
**MCP path:** `POST /mcp/database/...`
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `host` | `string` | Yes | — | Database server hostname |
|
||||
| `database` | `string` | Yes | — | Database/schema name |
|
||||
| `query` | `string` | Yes | — | SQL query text |
|
||||
| `secret_handle` | `string` | Yes | — | Handle from `get_credential` |
|
||||
| `db_type` | `string` | No | `"postgres"` | `"postgres"`, `"mysql"`, `"mssql"` |
|
||||
| `port` | `integer` | No | `0` | 0 = use default for db_type |
|
||||
| `username_override` | `string` | No | `""` | Override credential username |
|
||||
| `timeout_seconds` | `integer` | No | `30` | Query timeout |
|
||||
|
||||
**Returns:** Text table with columns, rows, counts, elapsed time.
|
||||
|
||||
**Errors:**
|
||||
- `ValueError` — unsupported `db_type`
|
||||
- `KeyError` — handle not found/expired/consumed
|
||||
- `asyncpg.PostgresError` — PostgreSQL error
|
||||
- `aiomysql.Error` — MySQL error
|
||||
- `pyodbc.Error` — SQL Server error
|
||||
- `RuntimeError` — pyodbc not installed
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Models
|
||||
|
||||
### Credential (CyberArk CCP response)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Credential:
|
||||
username: str # "svc_account"
|
||||
password: str # raw password — stored in SecretStr immediately
|
||||
address: str # "db.internal" — target host from CyberArk
|
||||
safe: str # "PROD-DB"
|
||||
folder: str # "Root"
|
||||
object_name: str # "PROD-DB-svc_account"
|
||||
platform_id: str # "Oracle", "UnixSSH", etc.
|
||||
password_change_in_process: bool # True if CyberArk is rotating this credential
|
||||
```
|
||||
|
||||
> `password_change_in_process=True` should trigger a warning — the credential may be mid-rotation.
|
||||
|
||||
### SecretStore entry
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class _Entry:
|
||||
handle_id: str # 32 hex chars (key in _store dict)
|
||||
username: str
|
||||
password: SecretStr # pydantic SecretStr — str() returns "**********"
|
||||
created_at: float # time.monotonic()
|
||||
resolved: bool # True after first resolve
|
||||
```
|
||||
|
||||
### Handle format
|
||||
|
||||
```
|
||||
secret://a3f9c2e1b8d74f2c9e1a0b5d3c8f7e2a
|
||||
└──────────────────────────────────┘
|
||||
32-char lowercase hex = 128 bits of entropy
|
||||
from secrets.token_hex(16)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Configuration Reference
|
||||
|
||||
Full `.env.example`:
|
||||
|
||||
```ini
|
||||
# ── Service ───────────────────────────────────────────────────────────
|
||||
MCP_HOST=0.0.0.0
|
||||
MCP_PORT=8443
|
||||
MCP_API_KEYS=key-for-claude-desktop,key-for-vscode
|
||||
|
||||
# ── Secret Handle Store ───────────────────────────────────────────────
|
||||
HANDLE_TTL_SECONDS=300
|
||||
HANDLE_SINGLE_USE=true
|
||||
|
||||
# ── CyberArk CCP ─────────────────────────────────────────────────────
|
||||
CYBERARK_CCP_URL=https://cyberark.internal/AIMWebService/api/Accounts
|
||||
CYBERARK_APP_ID=MCP-Privileged-Service
|
||||
CYBERARK_VERIFY_SSL=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# ── CyberArk mTLS (leave empty for IP allowlist mode) ─────────────────
|
||||
CYBERARK_CERT_PFX_PATH=
|
||||
CYBERARK_CERT_PFX_PASSWORD=
|
||||
|
||||
# ── PowerShell / WinRM ────────────────────────────────────────────────
|
||||
WINRM_AUTH=ntlm
|
||||
WINRM_CONNECT_TIMEOUT_SECONDS=15
|
||||
WINRM_OPERATION_TIMEOUT_SECONDS=20
|
||||
WINRM_MAX_OUTPUT_BYTES=51200
|
||||
|
||||
# ── SSH ───────────────────────────────────────────────────────────────
|
||||
SSH_KNOWN_HOSTS=~/.ssh/known_hosts
|
||||
SSH_CONNECT_TIMEOUT_SECONDS=10
|
||||
SSH_MAX_OUTPUT_BYTES=51200
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────
|
||||
DB_CONNECT_TIMEOUT_SECONDS=10
|
||||
DB_QUERY_TIMEOUT_SECONDS=30
|
||||
DB_MAX_ROWS=1000
|
||||
DB_MAX_CELL_BYTES=1024
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────
|
||||
LOG_FORMAT=json
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Error Handling Matrix
|
||||
|
||||
| Layer | Exception | Handling | What Claude sees |
|
||||
|-------|-----------|----------|-----------------|
|
||||
| Auth middleware | Bad/missing key | 401 JSON response | `{"detail": "Invalid or missing API key"}` |
|
||||
| SecretStore | `KeyError` (unknown) | Caught in tool, `ctx.error`, re-raised | MCP error response |
|
||||
| SecretStore | `KeyError` (expired) | Same | MCP error response |
|
||||
| SecretStore | `KeyError` (consumed) | Same | MCP error response |
|
||||
| CyberArk CCP | `CyberArkError` | Caught in tool, `ctx.error`, re-raised | MCP error response with error code |
|
||||
| SSH | `asyncssh.PermissionDenied` | `ctx.error`, re-raised | MCP error response |
|
||||
| SSH | `asyncssh.DisconnectError` | `ctx.error`, re-raised | MCP error response |
|
||||
| SSH | `asyncio.TimeoutError` | `ctx.error`, re-raised | MCP error response |
|
||||
| SSH | `OSError` | `ctx.error`, re-raised | MCP error response |
|
||||
| SSH | Non-zero exit code | NOT raised — returned in result | Normal result with `Exit code: N` |
|
||||
| WinRM | Any exception from pypsrp | `ctx.error`, re-raised | MCP error response |
|
||||
| WinRM | Script errors (`had_errors=True`) | NOT raised — returned in result | Normal result with `Had errors: True` |
|
||||
| Database | `ValueError` (bad db_type) | Raised before credential access | MCP error response |
|
||||
| Database | Driver exceptions | `ctx.error`, re-raised | MCP error response |
|
||||
| Database | Row cap exceeded | NOT raised — result truncated | Normal result with `(capped)` note |
|
||||
|
||||
---
|
||||
|
||||
## 11. Audit Event Catalog
|
||||
|
||||
All events are emitted via structlog at `INFO` or `WARNING` level to the `audit` logger.
|
||||
The logger is named `"audit"` — log shippers can filter on this name.
|
||||
|
||||
| Event | Level | When | Key fields |
|
||||
|-------|-------|------|-----------|
|
||||
| `credential_fetched` | INFO | CyberArk credential retrieved | `app_id, safe, object_name, handle_id, ttl_seconds, client_ip` |
|
||||
| `handle_resolved` | INFO | Handle consumed by a tool | `handle_id, resolved_by, target_host, single_use_invalidated` |
|
||||
| `handle_expired` | WARNING | Handle TTL exceeded or already consumed | `handle_id, reason` |
|
||||
| `auth_failure` | WARNING | Invalid/missing API key | `client_ip, reason` |
|
||||
| `cyberark_error` | ERROR | CCP returned error | `app_id, safe, object_name, status_code, error_code, message` |
|
||||
| `ssh_executed` | INFO | SSH command completed | `handle_id, host, port, username, command, exit_code, elapsed_ms, client_ip` |
|
||||
| `ps_executed` | INFO | PowerShell script completed | `handle_id, host, port, username, script_length, had_errors, elapsed_ms, client_ip` |
|
||||
| `db_queried` | INFO | Database query completed | `handle_id, host, port, database, db_type, username, query_length, row_count, elapsed_ms, client_ip` |
|
||||
|
||||
**Fields intentionally absent from all events:**
|
||||
- `password` (never)
|
||||
- `secret_handle` (never — only `handle_id` which is non-reversible)
|
||||
- stdout / stderr output (may contain sensitive data)
|
||||
- SQL query text (logged only as `query_length`)
|
||||
- PowerShell script text (logged only as `script_length`)
|
||||
|
||||
---
|
||||
|
||||
## 12. Test Strategy
|
||||
|
||||
### Test layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py ← shared fixtures and mock helpers
|
||||
├── test_auth.py ← API key middleware (FastAPI TestClient)
|
||||
├── test_secret_store.py ← handle lifecycle (pure asyncio)
|
||||
├── test_cyberark_client.py ← CCP HTTP client (httpx MockTransport)
|
||||
├── test_ssh_server.py ← SSH tool (mock asyncssh.connect)
|
||||
├── test_powershell_server.py ← PS tool (mock _run_ps_sync)
|
||||
├── test_database_server.py ← DB tool (mock _dispatch_query)
|
||||
└── test_integration.py ← end-to-end pipelines (all mocks combined)
|
||||
```
|
||||
|
||||
### Test patterns
|
||||
|
||||
| Pattern | Used for | Why |
|
||||
|---------|----------|-----|
|
||||
| `httpx.MockTransport` | CyberArk client | Tests full HTTP response parsing without real CyberArk |
|
||||
| `unittest.mock.patch` on transport layer | SSH, PowerShell, DB tools | Isolates MCP tool logic from network I/O |
|
||||
| Real `secret_store` | All tool tests | Tests handle lifecycle end-to-end |
|
||||
| `MagicMock` for `Context` | All tool tests | Tests `ctx.info` / `ctx.error` calls without MCP framework |
|
||||
| `patch.object(settings, ...)` | Settings-sensitive tests | Overrides config for a test without process restart |
|
||||
|
||||
### Coverage targets
|
||||
|
||||
- Foundation modules: 100%
|
||||
- CyberArk client: 100% (all HTTP response paths)
|
||||
- MCP tools: ≥90% (happy path + all error paths)
|
||||
- Integration flows: key pipelines (CyberArk→SSH, →PS, →DB)
|
||||
- Known gap: real-system integration tests (require live CyberArk/WinRM/DB)
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# With coverage
|
||||
python -m pytest tests/ --cov=src/mcp_privileged --cov-report=term-missing
|
||||
|
||||
# Single module
|
||||
python -m pytest tests/test_integration.py -v
|
||||
```
|
||||
BIN
docs/MANUAL.docx
Normal file
BIN
docs/MANUAL.docx
Normal file
Binary file not shown.
966
docs/MANUAL.md
Normal file
966
docs/MANUAL.md
Normal file
@@ -0,0 +1,966 @@
|
||||
# Operations Manual
|
||||
# MCP Privileged Access Service
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-03-28
|
||||
**Audience:** System administrators, security engineers, DevOps teams
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [CyberArk Prerequisites](#2-cyberark-prerequisites)
|
||||
3. [Installation — Bare Metal / VM](#3-installation--bare-metal--vm)
|
||||
4. [Installation — Docker](#4-installation--docker)
|
||||
5. [Configuration Walkthrough](#5-configuration-walkthrough)
|
||||
6. [SSH Host Key Setup](#6-ssh-host-key-setup)
|
||||
7. [Windows WinRM Setup](#7-windows-winrm-setup)
|
||||
8. [SQL Server ODBC Driver Setup](#8-sql-server-odbc-driver-setup)
|
||||
9. [Claude Code Integration](#9-claude-code-integration)
|
||||
10. [Usage Examples](#10-usage-examples)
|
||||
11. [Monitoring & Log Events](#11-monitoring--log-events)
|
||||
12. [Troubleshooting Guide](#13-troubleshooting-guide)
|
||||
13. [Security Hardening Checklist](#14-security-hardening-checklist)
|
||||
14. [Backup & Recovery](#15-backup--recovery)
|
||||
15. [Upgrade Procedure](#16-upgrade-procedure)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### System requirements
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| OS | Ubuntu 22.04 / RHEL 9 | Ubuntu 22.04 LTS |
|
||||
| CPU | 1 vCPU | 2 vCPU |
|
||||
| RAM | 512 MB | 1 GB |
|
||||
| Disk | 2 GB | 5 GB (for logs) |
|
||||
| Python | 3.11 | 3.11 |
|
||||
| Network | See firewall rules below | — |
|
||||
|
||||
### Network access required (outbound from service host)
|
||||
|
||||
| Destination | Port | Protocol | Purpose |
|
||||
|-------------|------|----------|---------|
|
||||
| CyberArk CCP | 443 | HTTPS | Credential retrieval |
|
||||
| Linux target hosts | 22 | SSH | `ssh_execute` tool |
|
||||
| Windows target hosts | 5985 or 5986 | HTTP/HTTPS | `ps_execute` tool (WinRM) |
|
||||
| PostgreSQL servers | 5432 | TCP | `db_query` (postgres) |
|
||||
| MySQL servers | 3306 | TCP | `db_query` (mysql) |
|
||||
| SQL Server | 1433 | TCP | `db_query` (mssql) |
|
||||
|
||||
### Network access required (inbound to service host)
|
||||
|
||||
| Source | Port | Protocol | Purpose |
|
||||
|--------|------|----------|---------|
|
||||
| Claude Code clients | 443 | HTTPS | MCP tool calls |
|
||||
| Load balancer / monitoring | 8443 | HTTP | Health check (if no TLS termination) |
|
||||
|
||||
---
|
||||
|
||||
## 2. CyberArk Prerequisites
|
||||
|
||||
Before deploying the service, complete the following in CyberArk.
|
||||
|
||||
### 2.1 Create an Application ID
|
||||
|
||||
1. In PVWA, navigate to **Applications** → **Add Application**
|
||||
2. Set the application name to `MCP-Privileged-Service` (or your chosen value)
|
||||
3. Under **Authentication**, add the service host's IP address to the **Allowed Machines** list
|
||||
4. Save
|
||||
|
||||
### 2.2 Grant access to Safes
|
||||
|
||||
For each Safe containing credentials the service needs to retrieve:
|
||||
1. Navigate to the Safe → **Members** → **Add Member**
|
||||
2. Add `MCP-Privileged-Service` (the Application ID)
|
||||
3. Grant permissions: **Retrieve accounts** (minimum)
|
||||
4. Do **NOT** grant: Add, Update, Delete, Manage — principle of least privilege
|
||||
|
||||
### 2.3 Verify CCP is reachable
|
||||
|
||||
From the service host:
|
||||
```bash
|
||||
curl -k "https://cyberark.internal/AIMWebService/api/Accounts?AppID=MCP-Privileged-Service&Safe=TEST&Object=TEST-obj"
|
||||
```
|
||||
|
||||
Expected responses:
|
||||
- HTTP 200 — credential returned (safe and object exist)
|
||||
- HTTP 404 `APPAP007E` — AppID valid but object not found (CCP is reachable and trusted)
|
||||
- HTTP 403 `APPAP006E` — IP not in allowlist (add the service host IP to CyberArk)
|
||||
- Connection refused — CCP URL is wrong or firewall is blocking
|
||||
|
||||
### 2.4 (Future) mTLS — Export client certificate
|
||||
|
||||
1. In CyberArk, generate or import a client certificate for the AppID
|
||||
2. Export as PFX with a strong password
|
||||
3. Copy the PFX file to the service host at a path like `/app/certs/mcp.pfx`
|
||||
4. Set `chmod 400 /app/certs/mcp.pfx`
|
||||
5. Set `CYBERARK_CERT_PFX_PATH=/app/certs/mcp.pfx` and `CYBERARK_CERT_PFX_PASSWORD=<password>` in `.env`
|
||||
|
||||
---
|
||||
|
||||
## 3. Installation — Bare Metal / VM
|
||||
|
||||
### 3.1 System packages
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3.11 python3.11-venv python3.11-dev \
|
||||
unixodbc unixodbc-dev ca-certificates
|
||||
```
|
||||
|
||||
For SQL Server support (optional):
|
||||
```bash
|
||||
# Add Microsoft repository
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list \
|
||||
| sudo tee /etc/apt/sources.list.d/mssql-release.list
|
||||
sudo apt-get update
|
||||
sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18
|
||||
```
|
||||
|
||||
### 3.2 Create service user
|
||||
|
||||
```bash
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin mcpuser
|
||||
sudo mkdir -p /opt/mcp-privileged /opt/mcp-privileged/certs
|
||||
sudo chown mcpuser:mcpuser /opt/mcp-privileged
|
||||
```
|
||||
|
||||
### 3.3 Install the package
|
||||
|
||||
```bash
|
||||
cd /opt/mcp-privileged
|
||||
|
||||
# Create and activate virtualenv
|
||||
python3.11 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Clone or copy source
|
||||
# (assuming source is in /tmp/MCP_CyberArk)
|
||||
pip install /tmp/MCP_CyberArk
|
||||
|
||||
# Verify
|
||||
mcp-privileged --help
|
||||
```
|
||||
|
||||
### 3.4 Configure
|
||||
|
||||
```bash
|
||||
cp /tmp/MCP_CyberArk/.env.example /opt/mcp-privileged/.env
|
||||
chmod 600 /opt/mcp-privileged/.env
|
||||
nano /opt/mcp-privileged/.env
|
||||
# Edit values — see Section 5
|
||||
```
|
||||
|
||||
### 3.5 Configure SSH known_hosts
|
||||
|
||||
```bash
|
||||
# Pre-populate known_hosts for all SSH target hosts:
|
||||
sudo -u mcpuser ssh-keyscan linux01.internal linux02.internal >> \
|
||||
/home/mcpuser/.ssh/known_hosts 2>/dev/null
|
||||
# Or set SSH_KNOWN_HOSTS=/etc/ssh/known_hosts and populate there
|
||||
```
|
||||
|
||||
### 3.6 Create systemd service
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/mcp-privileged.service > /dev/null <<'EOF'
|
||||
[Unit]
|
||||
Description=MCP Privileged Access Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mcpuser
|
||||
Group=mcpuser
|
||||
WorkingDirectory=/opt/mcp-privileged
|
||||
EnvironmentFile=/opt/mcp-privileged/.env
|
||||
ExecStart=/opt/mcp-privileged/.venv/bin/mcp-privileged
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/mcp-privileged
|
||||
CapabilityBoundingSet=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable mcp-privileged
|
||||
sudo systemctl start mcp-privileged
|
||||
sudo systemctl status mcp-privileged
|
||||
```
|
||||
|
||||
### 3.7 Reverse proxy (nginx)
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/mcp-privileged
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name mcp.yourcompany.internal;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/mcp.crt;
|
||||
ssl_certificate_key /etc/ssl/private/mcp.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Restrict to Claude Code client IPs (replace with real IPs)
|
||||
allow 10.0.0.0/24;
|
||||
deny all;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8443;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/mcp-privileged /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation — Docker
|
||||
|
||||
### 4.1 Build the image
|
||||
|
||||
```bash
|
||||
cd /path/to/MCP_CyberArk
|
||||
docker build -t mcp-privileged:1.0 .
|
||||
```
|
||||
|
||||
### 4.2 Create .env file
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
# Edit .env with your values
|
||||
```
|
||||
|
||||
### 4.3 Run with Docker Compose
|
||||
|
||||
```bash
|
||||
# Service only
|
||||
docker compose up -d mcp-privileged
|
||||
|
||||
# Service + test databases (for integration testing)
|
||||
docker compose --profile db up -d
|
||||
```
|
||||
|
||||
### 4.4 Run with Docker (direct)
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name mcp-privileged \
|
||||
--restart unless-stopped \
|
||||
-p 8443:8443 \
|
||||
--env-file .env \
|
||||
-v "$(pwd)/certs:/app/certs:ro" \
|
||||
mcp-privileged:1.0
|
||||
```
|
||||
|
||||
### 4.5 View logs
|
||||
|
||||
```bash
|
||||
docker logs -f mcp-privileged
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration Walkthrough
|
||||
|
||||
Copy `.env.example` to `.env` and set each value:
|
||||
|
||||
### Mandatory values
|
||||
|
||||
```ini
|
||||
# API keys — comma-separated, no spaces around commas
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
MCP_API_KEYS=abc123def456...,xyz789uvw012...
|
||||
|
||||
# CyberArk CCP URL — the full REST endpoint
|
||||
CYBERARK_CCP_URL=https://cyberark.yourcompany.internal/AIMWebService/api/Accounts
|
||||
|
||||
# AppID registered in CyberArk (must match exactly — case-sensitive)
|
||||
CYBERARK_APP_ID=MCP-Privileged-Service
|
||||
```
|
||||
|
||||
### TLS verification
|
||||
|
||||
```ini
|
||||
# Option 1: Use system CAs (default — works if CyberArk cert is signed by a trusted CA)
|
||||
CYBERARK_VERIFY_SSL=true
|
||||
|
||||
# Option 2: Custom CA bundle (common for internal PKI)
|
||||
CYBERARK_VERIFY_SSL=/etc/ssl/certs/internal-ca-bundle.crt
|
||||
|
||||
# Option 3: Disable (NEVER in production — dev/lab only)
|
||||
CYBERARK_VERIFY_SSL=false
|
||||
```
|
||||
|
||||
### Handle security
|
||||
|
||||
```ini
|
||||
# How long a handle stays valid (seconds). Shorter = more secure.
|
||||
# Operations that take < 30s: keep at 120-300s
|
||||
# Long-running database imports: consider up to 600s
|
||||
HANDLE_TTL_SECONDS=300
|
||||
|
||||
# Single-use enforces that each get_credential call is for one operation only.
|
||||
# Set to false only if Claude needs the same credential for multiple parallel calls.
|
||||
HANDLE_SINGLE_USE=true
|
||||
```
|
||||
|
||||
### WinRM authentication
|
||||
|
||||
```ini
|
||||
# ntlm — works for domain accounts, most common
|
||||
# basic — works for local accounts but requires HTTPS (use_ssl=true in the tool call)
|
||||
WINRM_AUTH=ntlm
|
||||
```
|
||||
|
||||
### SSH known hosts
|
||||
|
||||
```ini
|
||||
# Use the service user's known_hosts file (default)
|
||||
SSH_KNOWN_HOSTS=~/.ssh/known_hosts
|
||||
|
||||
# Use a shared known_hosts for the whole service
|
||||
SSH_KNOWN_HOSTS=/etc/mcp/ssh_known_hosts
|
||||
|
||||
# Disable host key checking (dev/lab ONLY — logs a warning on every connection)
|
||||
SSH_KNOWN_HOSTS=disable
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
```ini
|
||||
# Production: use json for log shipping to SIEM
|
||||
LOG_FORMAT=json
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Development: use console for human-readable output
|
||||
LOG_FORMAT=console
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SSH Host Key Setup
|
||||
|
||||
The service verifies SSH host keys against a `known_hosts` file. New hosts must be added before Claude can connect.
|
||||
|
||||
### Add a single host
|
||||
|
||||
```bash
|
||||
# As the mcpuser (or root, then chown)
|
||||
ssh-keyscan -H linux01.internal >> ~/.ssh/known_hosts
|
||||
```
|
||||
|
||||
### Add multiple hosts from a list
|
||||
|
||||
```bash
|
||||
cat hosts.txt | xargs ssh-keyscan -H >> ~/.ssh/known_hosts
|
||||
```
|
||||
|
||||
Where `hosts.txt` contains one hostname per line.
|
||||
|
||||
### Using a shared known_hosts file
|
||||
|
||||
```bash
|
||||
# Create shared file
|
||||
sudo mkdir -p /etc/mcp
|
||||
sudo ssh-keyscan -H linux01.internal linux02.internal db01.internal \
|
||||
> /etc/mcp/ssh_known_hosts
|
||||
sudo chown mcpuser:mcpuser /etc/mcp/ssh_known_hosts
|
||||
sudo chmod 440 /etc/mcp/ssh_known_hosts
|
||||
```
|
||||
|
||||
Then set `SSH_KNOWN_HOSTS=/etc/mcp/ssh_known_hosts` in `.env`.
|
||||
|
||||
### Verify a host key
|
||||
|
||||
```bash
|
||||
ssh-keygen -F linux01.internal -f ~/.ssh/known_hosts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Windows WinRM Setup
|
||||
|
||||
### 7.1 Enable WinRM on Windows hosts
|
||||
|
||||
Run on each Windows target host (as Administrator):
|
||||
|
||||
```powershell
|
||||
# Enable WinRM with default settings (HTTP, port 5985)
|
||||
Enable-PSRemoting -Force
|
||||
|
||||
# Allow connections from the MCP service host IP
|
||||
Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true
|
||||
winrm set winrm/config/client/auth '@{Basic="true"}'
|
||||
|
||||
# Allow specific IP in firewall (replace 10.0.0.5 with your service host IP)
|
||||
New-NetFirewallRule -Name "WinRM-MCP" -DisplayName "WinRM for MCP Service" `
|
||||
-Protocol TCP -LocalPort 5985 `
|
||||
-RemoteAddress 10.0.0.5 -Action Allow
|
||||
```
|
||||
|
||||
### 7.2 HTTPS WinRM (recommended for production)
|
||||
|
||||
```powershell
|
||||
# On the Windows host — create HTTPS listener with a certificate
|
||||
# (assumes cert is in the Local Machine store)
|
||||
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -like "*win01*" }
|
||||
New-WSManInstance winrm/config/Listener `
|
||||
-SelectorSet @{Transport="HTTPS"; Address="*"} `
|
||||
-ValueSet @{CertificateThumbprint=$cert.Thumbprint}
|
||||
|
||||
# Open HTTPS WinRM port in firewall
|
||||
New-NetFirewallRule -Name "WinRM-HTTPS-MCP" `
|
||||
-Protocol TCP -LocalPort 5986 `
|
||||
-RemoteAddress 10.0.0.5 -Action Allow
|
||||
```
|
||||
|
||||
Then use `port=5986` and `use_ssl=true` in `ps_execute` tool calls.
|
||||
|
||||
### 7.3 Test WinRM from the service host
|
||||
|
||||
```bash
|
||||
# Test HTTP WinRM connectivity (requires Python + pypsrp)
|
||||
python3 -c "
|
||||
from pypsrp.wsman import WSMan
|
||||
from pypsrp.powershell import PowerShell, RunspacePool
|
||||
wsman = WSMan('win01.internal', port=5985, username='domain\\\\svc_user',
|
||||
password='P@ssword', ssl=False, auth='ntlm')
|
||||
with RunspacePool(wsman) as pool:
|
||||
ps = PowerShell(pool)
|
||||
ps.add_script('hostname')
|
||||
out = ps.invoke()
|
||||
print(out)
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. SQL Server ODBC Driver Setup
|
||||
|
||||
Required for `db_query` with `db_type=mssql`.
|
||||
|
||||
### Ubuntu 22.04
|
||||
|
||||
```bash
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list \
|
||||
| sudo tee /etc/apt/sources.list.d/mssql-release.list
|
||||
sudo apt-get update
|
||||
sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev
|
||||
```
|
||||
|
||||
### Verify ODBC driver
|
||||
|
||||
```bash
|
||||
odbcinst -q -d -n "ODBC Driver 18 for SQL Server"
|
||||
# Should print the driver configuration
|
||||
```
|
||||
|
||||
### Test SQL Server connectivity
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import pyodbc
|
||||
conn = pyodbc.connect('DRIVER={ODBC Driver 18 for SQL Server};'
|
||||
'SERVER=sql.internal,1433;DATABASE=master;'
|
||||
'UID=sa;PWD=P@ssword;')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT @@VERSION')
|
||||
print(cur.fetchone()[0])
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Claude Code Integration
|
||||
|
||||
### 9.1 Configure MCP servers in Claude Code
|
||||
|
||||
Edit your Claude Code settings (usually `~/.claude/settings.json` or via `claude code config`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberark": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.yourcompany.internal/mcp/cyberark",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
},
|
||||
"ssh": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.yourcompany.internal/mcp/ssh",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
},
|
||||
"powershell": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.yourcompany.internal/mcp/powershell",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.yourcompany.internal/mcp/database",
|
||||
"headers": {
|
||||
"X-API-Key": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Verify connectivity
|
||||
|
||||
In the Claude Code chat:
|
||||
```
|
||||
Check if the MCP servers are connected
|
||||
```
|
||||
|
||||
Claude should report all four MCP servers (cyberark, ssh, powershell, database) as available tools.
|
||||
|
||||
### 9.3 Test with a simple operation
|
||||
|
||||
```
|
||||
Using the PROD-LINUX safe, get the credential for svc_root on linux01.internal,
|
||||
then run the command "whoami && uptime" on that host.
|
||||
```
|
||||
|
||||
Claude should:
|
||||
1. Call `get_credential(safe="PROD-LINUX", object_name="svc_root")`
|
||||
2. Receive a handle
|
||||
3. Call `ssh_execute(host="linux01.internal", command="whoami && uptime", secret_handle="secret://...")`
|
||||
4. Return the output
|
||||
|
||||
---
|
||||
|
||||
## 10. Usage Examples
|
||||
|
||||
### Example 1: Check disk space on a Linux server
|
||||
|
||||
**User prompt to Claude:**
|
||||
```
|
||||
Get the root credential from the PROD-LINUX safe (object name: linux-root),
|
||||
then check disk usage on server01.internal.
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
1. `get_credential(safe="PROD-LINUX", object_name="linux-root")`
|
||||
→ Returns: `Handle: secret://abc123... Username: root Address: server01.internal`
|
||||
|
||||
2. `ssh_execute(host="server01.internal", command="df -h", secret_handle="secret://abc123...")`
|
||||
→ Returns:
|
||||
```
|
||||
Host: server01.internal
|
||||
Command: df -h
|
||||
Exit code: 0
|
||||
|
||||
--- stdout ---
|
||||
Filesystem Size Used Avail Use% Mounted on
|
||||
/dev/sda1 50G 12G 38G 24% /
|
||||
/dev/sdb1 200G 80G 120G 40% /data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Run a PowerShell script on Windows
|
||||
|
||||
**User prompt to Claude:**
|
||||
```
|
||||
Get the domain admin credential from WIN-SAFE (object: domain-admin),
|
||||
then list all running services on win-server01.internal that are stopped.
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
1. `get_credential(safe="WIN-SAFE", object_name="domain-admin")`
|
||||
|
||||
2. `ps_execute(host="win-server01.internal", script="Get-Service | Where-Object {$_.Status -eq 'Stopped'} | Select-Object Name, DisplayName", secret_handle="secret://...")`
|
||||
→ Returns:
|
||||
```
|
||||
Host: win-server01.internal
|
||||
Script length: 89 chars
|
||||
Had errors: False
|
||||
|
||||
--- output ---
|
||||
Name DisplayName
|
||||
---- -----------
|
||||
wuauserv Windows Update
|
||||
XblGameSave Xbox Game Bar Saving Service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Query a database
|
||||
|
||||
**User prompt to Claude:**
|
||||
```
|
||||
Get the db_reader credential from DB-SAFE (object: pg-reader),
|
||||
then count the orders placed in the last 24 hours in the prod PostgreSQL database
|
||||
on pg.internal, database name: orders.
|
||||
```
|
||||
|
||||
**What Claude does:**
|
||||
1. `get_credential(safe="DB-SAFE", object_name="pg-reader")`
|
||||
|
||||
2. `db_query(host="pg.internal", database="orders", db_type="postgres", secret_handle="secret://...", query="SELECT COUNT(*) as orders_24h FROM orders WHERE created_at > NOW() - INTERVAL '24 hours'")`
|
||||
→ Returns:
|
||||
```
|
||||
Host: pg.internal
|
||||
Database: orders (postgres)
|
||||
Query length: 84 chars
|
||||
Rows returned: 1
|
||||
Elapsed: 8ms
|
||||
|
||||
orders_24h
|
||||
----------
|
||||
1247
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 4: Multi-step workflow
|
||||
|
||||
**User prompt to Claude:**
|
||||
```
|
||||
I need to patch the Apache web servers in the PROD-LINUX safe.
|
||||
For each of web01, web02, and web03:
|
||||
1. Get the svc_admin credential
|
||||
2. Run "sudo apt-get install --only-upgrade apache2 -y" on each host
|
||||
3. Then check "apache2 -v" to confirm the version
|
||||
```
|
||||
|
||||
**Note:** Because `HANDLE_SINGLE_USE=true`, Claude must call `get_credential` once per server (the handle is consumed by the first `ssh_execute`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Monitoring & Log Events
|
||||
|
||||
### Log format (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "credential_fetched",
|
||||
"logger": "audit",
|
||||
"level": "info",
|
||||
"timestamp": "2026-03-28T10:30:00.123Z",
|
||||
"app_id": "MCP-Privileged-Service",
|
||||
"safe": "PROD-LINUX",
|
||||
"object_name": "linux-root",
|
||||
"handle_id": "a3f9c2e1b8d74f2c",
|
||||
"ttl_seconds": 300,
|
||||
"client_ip": "10.0.0.50"
|
||||
}
|
||||
```
|
||||
|
||||
### Key events to alert on
|
||||
|
||||
| Event | Condition | Suggested alert |
|
||||
|-------|-----------|-----------------|
|
||||
| `auth_failure` | `reason=invalid_or_missing_api_key` | Any single occurrence |
|
||||
| `auth_failure` | Rate > 5/minute from same IP | Possible brute-force |
|
||||
| `cyberark_error` | `error_code=APPAP006E` | CyberArk allowlist may be wrong |
|
||||
| `cyberark_error` | Rate > 10/hour | Possible misconfiguration |
|
||||
| `handle_expired` | `reason=already_consumed` + high rate | Handle replay attempt |
|
||||
| `ssh_executed` | `exit_code != 0` | Command failure — review |
|
||||
| `ps_executed` | `had_errors=true` | Script error — review |
|
||||
| Health check | No response within 10s | Service down |
|
||||
|
||||
### Log shipping
|
||||
|
||||
The service writes JSON logs to stdout. Use your standard log shipper:
|
||||
|
||||
**Filebeat:**
|
||||
```yaml
|
||||
- type: container
|
||||
paths:
|
||||
- /var/lib/docker/containers/*/*.log
|
||||
processors:
|
||||
- decode_json_fields:
|
||||
fields: ["message"]
|
||||
target: ""
|
||||
```
|
||||
|
||||
**Splunk universal forwarder:**
|
||||
Configure to tail the stdout log file or Docker container logs.
|
||||
|
||||
**Grafana Loki + promtail:**
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: mcp-privileged
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_docker_container_name]
|
||||
regex: mcp-privileged
|
||||
action: keep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Troubleshooting Guide
|
||||
|
||||
### Service fails to start
|
||||
|
||||
**Symptom:** `systemctl status mcp-privileged` shows `failed` or immediate exit.
|
||||
|
||||
**Check 1:** Configuration validation
|
||||
```bash
|
||||
cd /opt/mcp-privileged && source .venv/bin/activate
|
||||
python3 -c "from mcp_privileged.config import settings; print('Config OK')"
|
||||
```
|
||||
If this fails, the error message shows which setting is invalid.
|
||||
|
||||
**Check 2:** PFX file (if mTLS is configured)
|
||||
```bash
|
||||
ls -la $CYBERARK_CERT_PFX_PATH
|
||||
# Must exist and be readable by mcpuser
|
||||
```
|
||||
|
||||
**Check 3:** Port in use
|
||||
```bash
|
||||
ss -tlnp | grep 8443
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 401 Unauthorized from Claude Code
|
||||
|
||||
**Cause:** API key mismatch between Claude Code settings and `MCP_API_KEYS`.
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
# Check what keys are configured (value is obfuscated in logs)
|
||||
grep MCP_API_KEYS /opt/mcp-privileged/.env
|
||||
|
||||
# Test with curl
|
||||
curl -H "X-API-Key: your-key" https://mcp.yourcompany.internal/health
|
||||
# Should return: {"status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CyberArk error APPAP006E (authentication failure)
|
||||
|
||||
**Cause:** The service host's IP is not in the CyberArk allowlist for the AppID.
|
||||
|
||||
**Check:** What IP does CyberArk see?
|
||||
```bash
|
||||
# From the service host, check your outbound IP
|
||||
curl https://api.ipify.org
|
||||
# Or check your internal NAT gateway
|
||||
```
|
||||
|
||||
**Fix:** In PVWA → Applications → `MCP-Privileged-Service` → Allowed Machines → Add the IP.
|
||||
|
||||
---
|
||||
|
||||
### CyberArk error APPAP007E (object not found)
|
||||
|
||||
**Cause:** The `safe` or `object_name` passed to `get_credential` does not exist in CyberArk.
|
||||
|
||||
**Check:**
|
||||
- Spelling and case of Safe name (CyberArk is case-sensitive)
|
||||
- Object name — this is the **Account name** (Name field), not the address or username
|
||||
- The AppID has Retrieve permission on the Safe
|
||||
|
||||
---
|
||||
|
||||
### SSH connection fails: "Host key verification failed"
|
||||
|
||||
**Cause:** The target host's SSH fingerprint is not in the known_hosts file.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
ssh-keyscan -H linux01.internal >> ~/.ssh/known_hosts
|
||||
# Or for the service user:
|
||||
sudo -u mcpuser ssh-keyscan -H linux01.internal >> ~mcpuser/.ssh/known_hosts
|
||||
```
|
||||
|
||||
**Quick diagnostic (dev only):** Temporarily set `SSH_KNOWN_HOSTS=disable` to confirm the issue is host key related, then fix properly.
|
||||
|
||||
---
|
||||
|
||||
### SSH connection fails: "Permission denied"
|
||||
|
||||
**Cause:** Wrong username/password, or password auth is disabled on the target host.
|
||||
|
||||
**Check:**
|
||||
1. Verify the credential in CyberArk PVWA (test retrieval)
|
||||
2. Confirm the target host allows password authentication: `PasswordAuthentication yes` in `/etc/ssh/sshd_config`
|
||||
3. Confirm the account is not locked: `passwd -S <username>` on the target
|
||||
|
||||
---
|
||||
|
||||
### WinRM connection fails
|
||||
|
||||
**Symptom:** `ps_execute` returns a WinRM connection error.
|
||||
|
||||
**Check 1:** WinRM is running on the target
|
||||
```powershell
|
||||
# On the Windows host
|
||||
Get-Service WinRM
|
||||
winrm enumerate winrm/config/listener
|
||||
```
|
||||
|
||||
**Check 2:** Firewall allows the connection
|
||||
```powershell
|
||||
# On the Windows host — test if port is open
|
||||
Test-NetConnection -ComputerName localhost -Port 5985
|
||||
```
|
||||
|
||||
**Check 3:** Auth method matches
|
||||
- NTLM: works for domain accounts and most setups
|
||||
- Basic: requires `WINRM_AUTH=basic` in `.env` AND `use_ssl=true` in the tool call (Basic auth over HTTP is rejected by WinRM by default)
|
||||
|
||||
---
|
||||
|
||||
### Database connection fails
|
||||
|
||||
**PostgreSQL:**
|
||||
```bash
|
||||
# Test from service host
|
||||
psql -h pg.internal -U db_user -d mydb -c "SELECT 1"
|
||||
```
|
||||
|
||||
**MySQL:**
|
||||
```bash
|
||||
mysql -h mysql.internal -u db_user -p -e "SELECT 1"
|
||||
```
|
||||
|
||||
**SQL Server (ODBC):**
|
||||
```bash
|
||||
isql -v "DRIVER={ODBC Driver 18 for SQL Server};SERVER=sql.internal,1433;DATABASE=master" \
|
||||
sa "P@ssword"
|
||||
```
|
||||
|
||||
If `pyodbc` fails with `ImportError: libodbc.so.2: cannot open shared object file`:
|
||||
```bash
|
||||
sudo apt-get install -y unixodbc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Handle expired / already consumed
|
||||
|
||||
**Symptom:** Tool returns `KeyError: Handle expired` or `Handle already consumed`.
|
||||
|
||||
**Causes:**
|
||||
- The TTL elapsed between `get_credential` and the tool call → increase `HANDLE_TTL_SECONDS`
|
||||
- `HANDLE_SINGLE_USE=true` and Claude tried to reuse the handle → normal behaviour; Claude should call `get_credential` again
|
||||
- Clock skew on the service host (TTL uses `time.monotonic()`, so clock skew does not affect it)
|
||||
|
||||
---
|
||||
|
||||
## 13. Security Hardening Checklist
|
||||
|
||||
Use this checklist before production deployment.
|
||||
|
||||
### Network
|
||||
- [ ] Service host is in a restricted network segment (not accessible from general office network)
|
||||
- [ ] Firewall rules allow only approved Claude Code client IPs to reach port 443
|
||||
- [ ] Service host can only reach: CyberArk CCP, target SSH hosts, WinRM hosts, DB servers — no internet
|
||||
- [ ] Reverse proxy handles TLS termination with a valid internal CA certificate
|
||||
|
||||
### Service configuration
|
||||
- [ ] `MCP_API_KEYS` is set to strong random keys (minimum 32 chars each)
|
||||
- [ ] Default key `changeme` is NOT present in `MCP_API_KEYS`
|
||||
- [ ] `HANDLE_SINGLE_USE=true` (default)
|
||||
- [ ] `HANDLE_TTL_SECONDS` ≤ 300 (5 minutes)
|
||||
- [ ] `CYBERARK_VERIFY_SSL` is **not** set to `false`
|
||||
- [ ] `SSH_KNOWN_HOSTS` is **not** set to `disable`
|
||||
- [ ] `LOG_FORMAT=json` (for log shipping)
|
||||
- [ ] `.env` file has `chmod 600` and is owned by the service user
|
||||
|
||||
### CyberArk
|
||||
- [ ] AppID has only Retrieve permission on Safes (no Add/Update/Delete)
|
||||
- [ ] IP allowlist is restricted to the service host IP only
|
||||
- [ ] A dedicated AppID is used for this service (not shared with other applications)
|
||||
|
||||
### Docker
|
||||
- [ ] Container runs as non-root (`USER mcpuser` in Dockerfile — already done)
|
||||
- [ ] Secrets are passed via `--env-file`, not `-e PASSWORD=...` in docker run
|
||||
- [ ] Docker socket is not mounted into the container
|
||||
- [ ] Image is built from official Python base image (verified digest)
|
||||
|
||||
### Operating system
|
||||
- [ ] OS is patched and on a supported LTS release
|
||||
- [ ] Service runs as a dedicated non-root user (`mcpuser`)
|
||||
- [ ] systemd unit has `NoNewPrivileges=yes` and `ProtectSystem=strict`
|
||||
- [ ] Log rotation is configured for stdout logs
|
||||
- [ ] auditd or similar is monitoring privileged operations
|
||||
|
||||
---
|
||||
|
||||
## 14. Backup & Recovery
|
||||
|
||||
The service is **stateless**: no persistent data is stored on disk.
|
||||
|
||||
- **Configuration:** The only file that needs backing up is `.env`. Store it in your secrets management system (HashiCorp Vault, AWS Secrets Manager, etc.), not in a generic file backup.
|
||||
- **Certificates:** Back up PFX files and known_hosts files to your PKI or secrets vault.
|
||||
- **Recovery:** To restore after a host failure, provision a new VM, install the package, and restore `.env` + certificates. All handles in RAM are lost (no active handles = fail-safe state; users must call `get_credential` again).
|
||||
- **RTO:** < 5 minutes (container restart or new VM + `.env` restore).
|
||||
- **RPO:** 0 (no data to lose — the service holds no persistent state).
|
||||
|
||||
---
|
||||
|
||||
## 15. Upgrade Procedure
|
||||
|
||||
### Minor upgrade (no config changes)
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker pull mcp-privileged:1.1
|
||||
docker compose up -d mcp-privileged
|
||||
|
||||
# Bare metal
|
||||
cd /opt/mcp-privileged && source .venv/bin/activate
|
||||
pip install --upgrade /path/to/new/mcp_privileged-1.1.tar.gz
|
||||
sudo systemctl restart mcp-privileged
|
||||
```
|
||||
|
||||
Active handles are lost on restart (they expire within TTL anyway). Notify users if the restart window > 5 minutes.
|
||||
|
||||
### Major upgrade (config changes)
|
||||
|
||||
1. Read the release notes — check for new required env vars
|
||||
2. Test in a staging environment first
|
||||
3. Update `.env` with new required values
|
||||
4. Follow the minor upgrade steps above
|
||||
5. Monitor logs for errors in the first 10 minutes
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
# Docker — roll back to previous image tag
|
||||
docker compose down
|
||||
docker run --name mcp-privileged mcp-privileged:1.0 ...
|
||||
|
||||
# Bare metal
|
||||
pip install mcp_privileged==1.0
|
||||
sudo systemctl restart mcp-privileged
|
||||
```
|
||||
343
docs/md_to_docx.py
Normal file
343
docs/md_to_docx.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Convert the three MCP documentation Markdown files to Word (.docx) format.
|
||||
|
||||
Handles:
|
||||
- Heading levels 1–4
|
||||
- Bold (**text**) and inline code (`text`)
|
||||
- Fenced code blocks (``` ... ```)
|
||||
- Tables (| col | col |)
|
||||
- Unordered lists (- item, * item)
|
||||
- Ordered lists (1. item)
|
||||
- Horizontal rules (---)
|
||||
- Blank lines → paragraph spacing
|
||||
|
||||
Run:
|
||||
python docs/md_to_docx.py
|
||||
Produces:
|
||||
docs/HLD.docx
|
||||
docs/LLD.docx
|
||||
docs/MANUAL.docx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.shared import Inches, Pt, RGBColor
|
||||
|
||||
|
||||
# ── Colour palette ────────────────────────────────────────────────────────────
|
||||
DARK_BLUE = RGBColor(0x1F, 0x49, 0x7D) # heading 1
|
||||
MID_BLUE = RGBColor(0x2E, 0x74, 0xB5) # heading 2
|
||||
STEEL_BLUE = RGBColor(0x1F, 0x78, 0xB4) # heading 3
|
||||
DARK_GREY = RGBColor(0x40, 0x40, 0x40) # body text
|
||||
CODE_BG = RGBColor(0xF2, 0xF2, 0xF2) # code block shading
|
||||
TABLE_HEAD = RGBColor(0x1F, 0x49, 0x7D) # table header background
|
||||
TABLE_EVEN = RGBColor(0xEA, 0xF2, 0xFF) # alternating row colour
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _shade_cell(cell, colour: RGBColor) -> None:
|
||||
"""Apply a solid background fill to a table cell."""
|
||||
tc = cell._tc
|
||||
tcPr = tc.get_or_add_tcPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), f"{colour[0]:02X}{colour[1]:02X}{colour[2]:02X}")
|
||||
tcPr.append(shd)
|
||||
|
||||
|
||||
def _set_cell_border(cell, **kwargs) -> None:
|
||||
"""Set borders on a table cell."""
|
||||
tc = cell._tc
|
||||
tcPr = tc.get_or_add_tcPr()
|
||||
tcBorders = OxmlElement("w:tcBorders")
|
||||
for side in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
if side in kwargs:
|
||||
border = OxmlElement(f"w:{side}")
|
||||
for attr, val in kwargs[side].items():
|
||||
border.set(qn(f"w:{attr}"), val)
|
||||
tcBorders.append(border)
|
||||
tcPr.append(tcBorders)
|
||||
|
||||
|
||||
def _apply_inline(run, text: str) -> None:
|
||||
"""Set run text, detecting and stripping bold/inline-code markers."""
|
||||
run.text = text
|
||||
|
||||
|
||||
def _parse_inline(para, text: str) -> None:
|
||||
"""
|
||||
Parse a line of text for inline Markdown:
|
||||
**bold** → bold run
|
||||
`code` → monospace run
|
||||
plain → normal run
|
||||
Adds runs to the given paragraph.
|
||||
"""
|
||||
pattern = re.compile(r'(\*\*[^*]+\*\*|`[^`]+`)')
|
||||
parts = pattern.split(text)
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
if part.startswith("**") and part.endswith("**"):
|
||||
run = para.add_run(part[2:-2])
|
||||
run.bold = True
|
||||
elif part.startswith("`") and part.endswith("`"):
|
||||
run = para.add_run(part[1:-1])
|
||||
run.font.name = "Courier New"
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = RGBColor(0xC0, 0x39, 0x2B)
|
||||
else:
|
||||
run = para.add_run(part)
|
||||
|
||||
|
||||
def _add_heading(doc: Document, text: str, level: int) -> None:
|
||||
"""Add a styled heading, stripping any leading '#' symbols."""
|
||||
clean = re.sub(r"^#+\s*", "", text).strip()
|
||||
# Remove anchor links like {#section-name}
|
||||
clean = re.sub(r"\s*\{#[^}]+\}", "", clean)
|
||||
para = doc.add_heading(clean, level=level)
|
||||
run = para.runs[0] if para.runs else para.add_run(clean)
|
||||
if level == 1:
|
||||
run.font.color.rgb = DARK_BLUE
|
||||
run.font.size = Pt(20)
|
||||
elif level == 2:
|
||||
run.font.color.rgb = MID_BLUE
|
||||
run.font.size = Pt(15)
|
||||
elif level == 3:
|
||||
run.font.color.rgb = STEEL_BLUE
|
||||
run.font.size = Pt(12)
|
||||
else:
|
||||
run.font.color.rgb = DARK_GREY
|
||||
run.font.size = Pt(11)
|
||||
run.bold = True
|
||||
|
||||
|
||||
def _add_code_block(doc: Document, lines: list[str]) -> None:
|
||||
"""Add a shaded monospace code block."""
|
||||
para = doc.add_paragraph()
|
||||
para.paragraph_format.left_indent = Inches(0.3)
|
||||
para.paragraph_format.space_before = Pt(4)
|
||||
para.paragraph_format.space_after = Pt(4)
|
||||
# Add shading via XML
|
||||
pPr = para._p.get_or_add_pPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), "F2F2F2")
|
||||
pPr.append(shd)
|
||||
|
||||
text = "\n".join(lines)
|
||||
run = para.add_run(text)
|
||||
run.font.name = "Courier New"
|
||||
run.font.size = Pt(8.5)
|
||||
run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x1A)
|
||||
|
||||
|
||||
def _add_table(doc: Document, rows: list[list[str]]) -> None:
|
||||
"""Add a formatted table. First row is treated as the header."""
|
||||
if not rows:
|
||||
return
|
||||
col_count = max(len(r) for r in rows)
|
||||
# Normalise row lengths
|
||||
rows = [r + [""] * (col_count - len(r)) for r in rows]
|
||||
|
||||
table = doc.add_table(rows=len(rows), cols=col_count)
|
||||
table.style = "Table Grid"
|
||||
|
||||
for row_idx, row_data in enumerate(rows):
|
||||
row = table.rows[row_idx]
|
||||
for col_idx, cell_text in enumerate(row_data):
|
||||
cell = row.cells[col_idx]
|
||||
clean = cell_text.strip().strip("`")
|
||||
para = cell.paragraphs[0]
|
||||
para.paragraph_format.space_before = Pt(2)
|
||||
para.paragraph_format.space_after = Pt(2)
|
||||
|
||||
if row_idx == 0:
|
||||
# Header row
|
||||
_shade_cell(cell, TABLE_HEAD)
|
||||
run = para.add_run(clean)
|
||||
run.bold = True
|
||||
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
run.font.size = Pt(9)
|
||||
else:
|
||||
if row_idx % 2 == 0:
|
||||
_shade_cell(cell, TABLE_EVEN)
|
||||
_parse_inline(para, clean)
|
||||
for run in para.runs:
|
||||
run.font.size = Pt(9)
|
||||
|
||||
doc.add_paragraph() # spacing after table
|
||||
|
||||
|
||||
def _add_list_item(doc: Document, text: str, level: int, ordered: bool,
|
||||
counter: int) -> None:
|
||||
"""Add a bullet or numbered list item."""
|
||||
style = "List Bullet" if not ordered else "List Number"
|
||||
para = doc.add_paragraph(style=style)
|
||||
if level > 0:
|
||||
para.paragraph_format.left_indent = Inches(0.25 * (level + 1))
|
||||
_parse_inline(para, text)
|
||||
for run in para.runs:
|
||||
run.font.size = Pt(10)
|
||||
|
||||
|
||||
def _parse_md_table(raw_rows: list[str]) -> list[list[str]]:
|
||||
"""Convert raw Markdown table lines to a list of cell lists."""
|
||||
result = []
|
||||
for line in raw_rows:
|
||||
# Skip separator rows (---|---)
|
||||
if re.match(r"^\s*\|?[\s\-:]+\|[\s\-:|]+\s*$", line):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
if cells:
|
||||
result.append(cells)
|
||||
return result
|
||||
|
||||
|
||||
# ── Main converter ────────────────────────────────────────────────────────────
|
||||
|
||||
def convert(md_path: Path, docx_path: Path) -> None:
|
||||
doc = Document()
|
||||
|
||||
# Page margins
|
||||
for section in doc.sections:
|
||||
section.top_margin = Inches(1.0)
|
||||
section.bottom_margin = Inches(1.0)
|
||||
section.left_margin = Inches(1.2)
|
||||
section.right_margin = Inches(1.2)
|
||||
|
||||
# Default body style
|
||||
style = doc.styles["Normal"]
|
||||
style.font.name = "Calibri"
|
||||
style.font.size = Pt(10.5)
|
||||
style.font.color.rgb = DARK_GREY
|
||||
|
||||
lines = md_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
i = 0
|
||||
in_code_block = False
|
||||
code_lines: list[str] = []
|
||||
table_rows: list[str] = []
|
||||
in_table = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# ── Fenced code block ──────────────────────────────────────────────
|
||||
if line.strip().startswith("```"):
|
||||
if not in_code_block:
|
||||
in_code_block = True
|
||||
code_lines = []
|
||||
else:
|
||||
in_code_block = False
|
||||
_add_code_block(doc, code_lines)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
code_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── Table detection ────────────────────────────────────────────────
|
||||
is_table_line = "|" in line and line.strip().startswith("|")
|
||||
if is_table_line:
|
||||
table_rows.append(line)
|
||||
i += 1
|
||||
continue
|
||||
elif table_rows:
|
||||
parsed = _parse_md_table(table_rows)
|
||||
if parsed:
|
||||
_add_table(doc, parsed)
|
||||
table_rows = []
|
||||
|
||||
# ── Headings ────────────────────────────────────────────────────────
|
||||
m = re.match(r"^(#{1,4})\s+(.+)$", line)
|
||||
if m:
|
||||
level = len(m.group(1))
|
||||
_add_heading(doc, m.group(2), level)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── Horizontal rule ─────────────────────────────────────────────────
|
||||
if re.match(r"^[-*_]{3,}\s*$", line.strip()):
|
||||
para = doc.add_paragraph()
|
||||
pPr = para._p.get_or_add_pPr()
|
||||
pBdr = OxmlElement("w:pBdr")
|
||||
bottom = OxmlElement("w:bottom")
|
||||
bottom.set(qn("w:val"), "single")
|
||||
bottom.set(qn("w:sz"), "6")
|
||||
bottom.set(qn("w:space"), "1")
|
||||
bottom.set(qn("w:color"), "2E74B5")
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── Unordered list ──────────────────────────────────────────────────
|
||||
m = re.match(r"^(\s*)[-*]\s+(.+)$", line)
|
||||
if m:
|
||||
indent = len(m.group(1)) // 2
|
||||
_add_list_item(doc, m.group(2), indent, ordered=False, counter=0)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── Ordered list ────────────────────────────────────────────────────
|
||||
m = re.match(r"^(\s*)\d+\.\s+(.+)$", line)
|
||||
if m:
|
||||
indent = len(m.group(1)) // 2
|
||||
_add_list_item(doc, m.group(2), indent, ordered=True, counter=0)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── Blank line ──────────────────────────────────────────────────────
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ── Plain paragraph ─────────────────────────────────────────────────
|
||||
para = doc.add_paragraph()
|
||||
para.paragraph_format.space_after = Pt(4)
|
||||
_parse_inline(para, line)
|
||||
for run in para.runs:
|
||||
run.font.size = Pt(10.5)
|
||||
i += 1
|
||||
|
||||
# Flush any remaining table
|
||||
if table_rows:
|
||||
parsed = _parse_md_table(table_rows)
|
||||
if parsed:
|
||||
_add_table(doc, parsed)
|
||||
|
||||
doc.save(str(docx_path))
|
||||
print(f" Written: {docx_path} ({docx_path.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
docs_dir = Path(__file__).parent
|
||||
|
||||
files = [
|
||||
("HLD.md", "HLD.docx"),
|
||||
("LLD.md", "LLD.docx"),
|
||||
("MANUAL.md", "MANUAL.docx"),
|
||||
]
|
||||
|
||||
print("Converting Markdown → Word (.docx) ...")
|
||||
for md_name, docx_name in files:
|
||||
md_path = docs_dir / md_name
|
||||
docx_path = docs_dir / docx_name
|
||||
print(f" Processing {md_name} ...")
|
||||
convert(md_path, docx_path)
|
||||
|
||||
print("Done.")
|
||||
Reference in New Issue
Block a user