Skip to main content
Glama
Surajp1602

Archive MCP Server

by Surajp1602
README.md
# Archive MCP Server

An MCP server that exposes the **Enterprise Data Archival & Records Management
System**'s records and retention logic to any MCP client — Claude Code, Claude
Desktop, Cursor, or your own client — over stdio.

Instead of clicking through the React dashboard to answer *"what can we archive
in Finance?"*, you ask the model, and it calls these tools.

## Tools

| Tool | What it does |
|---|---|
| `search_records` | Find records by employee, department, or document type |
| `get_record` | Fetch one record with its retention verdict |
| `archival_candidates` | Active records past their retention period, most overdue first |
| `department_summary` | Active vs archived counts per department |
| `retention_forecast` | Month-by-month projection of what becomes archivable next |
| `audit_history` | What the scheduled archival job did, and when |

## Resources

| URI | Contents |
|---|---|
| `policy://retention` | Retention period, in years, per document type |

## Requirements

Python 3.10+ and MCP SDK **2.x**. The v2 SDK renamed `FastMCP` to `MCPServer`
and moved it to `mcp.server.mcpserver`; this code targets v2. Data access is
SQLAlchemy 2.x, with `psycopg2` for PostgreSQL.

## Setup

```bash
python -m venv .venv
source .venv/bin/activate          # macOS/Linux
.venv\Scripts\activate             # Windows

python -m pip install -r requirements.txt
python seed_db.py                  # builds the local demo database
python server.py --selftest        # sanity check, no MCP client needed
```

Then verify it over a real MCP session:

```bash
python verify_mcp.py
```

## Choosing a database

The server reads `DATABASE_URL` (from the environment, or from a `.env` file —
see `.env.example`):

| `DATABASE_URL` | Backend |
|---|---|
| unset | `sqlite:///archive.db`, the local demo database built by `seed_db.py` |
| set | the real archive database, e.g. `postgresql://user:pw@host/db?sslmode=require` |

`archive.db` holds synthetic records, so the server — and `--selftest` — run for
anyone who clones this repo without credentials. It is not a different codebase:
`seed_db.py` builds the **same five-table schema** the production database uses
(`active_records`, `archived_records`, `retention_policy`, `audit_logs`,
`documents`), so every query in `server.py` runs unchanged against either one.

**Never commit a real `DATABASE_URL`.** `.env` is gitignored; `.env.example` is
the committed template.

## Connecting to Claude Code

From the project directory:

```bash
claude mcp add --scope project archive-system -- /absolute/path/to/.venv/bin/python /absolute/path/to/server.py
claude mcp list
```

`--scope project` writes a committable `.mcp.json` at the project root, so anyone
who clones the repo gets the server. Start `claude`, approve the project server
when prompted, and check `/mcp` — `archive-system` should show **Connected** with
6 tools. Then ask:

> Which IT department records are overdue for archival?

If it fails to start, run `claude --debug=mcp` and read the log under
`~/.claude/debug/`.

## Connecting to Claude Desktop

Add this to `claude_desktop_config.json`:

- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`

```json
{
  "mcpServers": {
    "archive-system": {
      "command": "D:\\Python\\project\\archive-mcp\\.venv\\Scripts\\python.exe",
      "args": ["D:\\Python\\project\\archive-mcp\\server.py"]
    }
  }
}
```

Point `command` at the venv's Python, not bare `python` — the host does not
inherit your shell's PATH or your activated virtualenv. On Windows both paths
need doubled backslashes.

Restart from the tray icon — **Quit**, not the window close button — or the app
keeps running with the old config.

Note for the Microsoft Store (MSIX) build on Windows: its config is not under
`%APPDATA%` but under the package's own directory,
`%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\`. It launches
local stdio servers normally. Don't try to confirm that from `logs\mcp.log` —
that file can sit empty and untouched while everything works. Check for the
process instead; the server runs as a child of Claude Desktop:

```powershell
Get-CimInstance Win32_Process -Filter "Name like '%python%'" |
  Where-Object { $_.CommandLine -like "*archive-mcp*" }
```

## Design notes

- **stdio transport**, because the client launches the server as a subprocess on
  the same machine. An HTTP transport would make sense if the server ran
  remotely and served several clients.
- **One seam for storage.** `_connect()` returns a SQLAlchemy `Engine` and is the
  only place that knows what the database is. Queries use named bind parameters
  (`:department`), which are dialect-neutral, so SQLite and PostgreSQL share one
  query set rather than two.
- **`pool_pre_ping=True`**, because a serverless PostgreSQL (Neon and friends)
  suspends idle compute and an MCP server sits idle between questions. Without
  it, the first question after a quiet spell fails on a stale pooled connection.
- **Eligibility is computed in Python, not SQL.** PostgreSQL `INTERVAL`
  arithmetic has no SQLite equivalent, and keeping the comparison in one place
  keeps the two backends honest. At a few thousand active rows the cost is not
  worth optimising away.
- **Age is measured from `joining_date`.** `created_at` is the bulk-load
  timestamp and is identical for every row, so retention computed from it would
  find nothing eligible, ever. `joining_date` is an employee-level date standing
  in for a document date — the schema carries no document date, which is a real
  gap worth closing upstream.
- **Archival state is a table, not a flag.** A record lives in `active_records`
  or in `archived_records`, and ids are stable across the move, so `get_record`
  checks both. The `status` column is *employment* status and is unrelated.
- **Tools are annotated read-only.** Each carries
  `ToolAnnotations(read_only_hint=True, destructive_hint=False)`, so a client can
  tell a safe call from a state-changing one before it runs.
- **Tools are read-only in fact, too.** Archiving is destructive and
  policy-governed; `archival_candidates` deliberately reports what *could* be
  archived and leaves the decision to the existing scheduled job. Exposing a
  destructive tool to a model is a choice that needs a confirmation path first.
- **Docstrings are the API.** The model picks tools from the docstring and type
  hints, so the valid departments and document types are enumerated there. A
  stale enum is worse than none: the model passes a plausible-looking value like
  `Legal`, gets an empty result, and reports that there is nothing to archive.
- **One definition of "eligible", used in both directions.** `_verdict` ages a
  record against its retention period; `_eligible_on` inverts it to give the date
  a record crosses that period, which is what `retention_forecast` buckets by.
  They must agree exactly, or a record can appear as *upcoming* in the forecast
  and *overdue* in `archival_candidates` on the same day. Writing the inverse the
  obvious way (`joining + timedelta(days=years * 365.25)`) breaks this, because
  `date + timedelta` keeps only whole days and silently drops the `.75`.
- **Output is formatted text, not raw JSON dumps**, so the model can quote it
  back to a user without reformatting.