Skip to main content
Glama
dalvivishal

Sharebox MCP Server

by dalvivishal
README.md
# Sharebox MCP Server

A **Model Context Protocol (MCP)** server that exposes a Sharebox-style project / case /
file-management system to any MCP-capable AI client (Claude Desktop, Claude Code, Cursor, or a
custom agent). Every write operation is recorded in an immutable audit trail, so an LLM can
create cases and register file uploads while leaving a complete forensic record.

Built with [FastMCP](https://github.com/jlowin/fastmcp) + SQLAlchemy + SQLite.

---

## Table of Contents

- [Why this exists](#why-this-exists)
- [Architecture](#architecture)
- [Data model](#data-model)
- [MCP tools](#mcp-tools)
- [Installation](#installation)
- [Running the server](#running-the-server)
- [Connecting an MCP client](#connecting-an-mcp-client)
- [Testing](#testing)
- [Project layout](#project-layout)
- [Design notes](#design-notes)
- [Extending the server](#extending-the-server)
- [Troubleshooting](#troubleshooting)

---

## Why this exists

Document and case management systems (legal discovery, insurance claims, compliance
workflows) are exactly the kind of tool an AI assistant should be able to drive — "how many
open cases are on project 7?", "log this file against case 42" — but they are also exactly the
kind of system where an unattributed mutation is unacceptable.

This server addresses both halves:

1. **Read and write tools** are exposed over MCP, so an LLM can query and mutate the system
   through a typed, self-describing interface instead of scraping a UI.
2. **Every mutation writes an `audit_logs` row** alongside the data change, capturing the
   action, the table, the record ID, and a JSON payload of the details.

---

## Architecture

```
+----------------------+
|  MCP Client          |   Claude Desktop / Claude Code / Cursor / custom agent
|  (the LLM host)      |
+----------+-----------+
           |  JSON-RPC over stdio (MCP transport)
           v
+----------------------+
|  app/mcp_server.py   |   FastMCP("ShareboxMCPServer")
|  @mcp.tool()         |   4 registered tools; each opens and closes its own DB session
+----------+-----------+
           v
+----------------------+
|  app/crud.py         |   business logic + add_entrust_logs() audit hook
+----------+-----------+
           v
+----------------------+
|  app/models.py       |   SQLAlchemy ORM
|  app/database.py     |   engine / SessionLocal / Base
+----------+-----------+
           v
     sharebox.db  (SQLite)
```

The server speaks **stdio** transport by default, which is what desktop MCP clients launch and
pipe into. There is no HTTP surface unless you add one.

---

## Data model

Four tables, defined in [app/models.py](app/models.py):

### `projects`
| Column | Type | Notes |
|---|---|---|
| `id` | Integer | primary key |
| `name` | String | indexed, required |
| `description` | String | |
| `created_at` | DateTime(tz) | `server_default=now()` |

### `cases`
| Column | Type | Notes |
|---|---|---|
| `id` | Integer | primary key |
| `project_id` | Integer | FK to `projects.id`, required |
| `title` | String | indexed, required |
| `status` | String | defaults to `"open"` |
| `created_at` | DateTime(tz) | |

### `file_uploads`
| Column | Type | Notes |
|---|---|---|
| `id` | Integer | primary key |
| `case_id` | Integer | FK to `cases.id`, required |
| `filename` | String | required |
| `file_size` | Integer | bytes |
| `uploaded_at` | DateTime(tz) | |

### `audit_logs`
| Column | Type | Notes |
|---|---|---|
| `id` | Integer | primary key |
| `action` | String | e.g. `"CREATE"` |
| `table_name` | String | e.g. `"cases"` |
| `record_id` | Integer | primary key of the mutated row |
| `details` | String | JSON blob of the relevant fields |
| `timestamp` | DateTime(tz) | |

Relationships: `Project 1-N Case 1-N FileUpload`. `AuditLog` is deliberately **not**
foreign-keyed to anything — it stores `table_name` + `record_id` as loose references, so a log
row survives even if the target row is later removed.

---

## MCP tools

All four are declared in [app/mcp_server.py](app/mcp_server.py). The docstring on each function
is what the LLM sees as the tool description, so it is part of the interface.

### `get_project_summary(project_id: int) -> dict`

Read-only. Returns project metadata plus case counts.

```json
{
  "project_id": 1,
  "name": "Project Alpha",
  "description": "A confidential test project",
  "total_cases": 3,
  "open_cases": 2
}
```

Returns `{"error": "Project not found"}` if the ID does not exist.

### `list_recent_file_uploads(days: int = 1) -> list[dict]`

Read-only. Returns every upload with `uploaded_at >= now() - days`.

```json
[
  {
    "id": 1,
    "filename": "evidence.pdf",
    "case_id": 1,
    "file_size": 10240,
    "uploaded_at": "2026-09-15 08:14:22"
  }
]
```

### `create_new_case(project_id: int, title: str, description: str = "") -> dict`

**Mutating — audited.** Validates that the parent project exists, creates the case with
`status="open"`, and writes a `CREATE` / `cases` audit row.

```json
{
  "success": true,
  "case_id": 12,
  "title": "Case 100",
  "message": "Case created and securely audited."
}
```

On a bad `project_id` it returns `{"error": "Project with ID N does not exist."}` rather than
raising — MCP clients handle a structured error better than a stack trace.

> The `description` parameter is accepted by the tool but is not currently persisted: the
> `cases` table has no description column. Add one to `models.py` and thread it through
> `crud.create_case()` if you need it stored.

### `upload_file_metadata(case_id: int, filename: str, file_size: int) -> dict`

**Mutating — audited.** Validates that the parent case exists, records the metadata, and writes
a `CREATE` / `file_uploads` audit row.

```json
{
  "success": true,
  "file_upload_id": 5,
  "message": "File metadata uploaded and securely audited."
}
```

> This tool records **metadata only**. No file bytes cross the MCP boundary. Actual file
> transfer is expected to happen out-of-band; this server tracks that it happened.

### Not exposed as a tool

`crud.create_project()` exists and is audited, but is intentionally **not** registered as an MCP
tool — creating top-level projects is treated as an administrative action reserved for a human.
Register it with `@mcp.tool()` if you want to change that policy.

---

## Installation

Requires **Python 3.10+** (the tool signatures use `list[dict]` builtin generics).

```bash
cd CustomMCPServer

python -m venv venv
source venv/bin/activate          # Windows: venv\Scripts\activate

pip install -r requirements.txt
```

Dependencies (`requirements.txt`): `fastmcp`, `fastapi`, `sqlalchemy`, `pydantic`, `uvicorn`.

### Initialize the database

```bash
python run.py --init-db
```

This runs `Base.metadata.create_all()` and produces `sharebox.db` in the working directory. It
is idempotent — safe to re-run; it will not drop existing tables.

---

## Running the server

```bash
python run.py
```

With no flags, `run.py` calls `mcp.run()`, which serves MCP over **stdio**. You will see no
output and the process will appear to hang — that is correct. It is waiting for JSON-RPC frames
on stdin. Stop it with `Ctrl+C`.

You can also use the FastMCP CLI:

```bash
fastmcp run app/mcp_server.py:mcp
```

---

## Connecting an MCP client

### Claude Desktop / Claude Code

Add the server to your MCP config (`claude_desktop_config.json`, or via `claude mcp add` in
Claude Code):

```json
{
  "mcpServers": {
    "sharebox": {
      "command": "/absolute/path/to/CustomMCPServer/venv/bin/python",
      "args": ["/absolute/path/to/CustomMCPServer/run.py"],
      "cwd": "/absolute/path/to/CustomMCPServer"
    }
  }
}
```

On Windows, use `venv\\Scripts\\python.exe` as the `command`.

**`cwd` matters.** The SQLite URL is the relative path `sqlite:///./sharebox.db`, so the server
resolves the database against whatever directory it was launched from. Point `cwd` at the
project root, or you will silently get a fresh, empty database.

Restart the client. The four tools should appear in its tool list, and you can then ask things
like *"Summarize project 1"* or *"Open a new case on project 1 called Discovery Batch 3."*

---

## Testing

[test_client.py](test_client.py) is a self-contained end-to-end check of the CRUD and audit
layers. It bypasses MCP and drives `crud.py` directly.

```bash
python test_client.py
```

> **Warning:** the script begins with `Base.metadata.drop_all()` — it **destroys** the contents
> of `sharebox.db`. Never run it against a database you care about.

It exercises the full path (create project, create case, upload file metadata, read summary,
list uploads) and then asserts that exactly 3 audit rows were written, exiting with status `1`
if the count is wrong.

Expected tail:

```
6. Verifying Audit Logs...
   Total Audit Logs: 3
   - Action: CREATE, Table: projects, Record ID: 1, Details: {"name": "Project Alpha"}
   - Action: CREATE, Table: cases, Record ID: 1, Details: {"title": "Case 100", "project_id": 1}
   - Action: CREATE, Table: file_uploads, Record ID: 1, Details: {...}
SUCCESS: 3 audit logs successfully recorded.
```

---

## Project layout

```
CustomMCPServer/
├── app/
│   ├── __init__.py
│   ├── crud.py          # business logic + audit logging
│   ├── database.py      # engine, SessionLocal, Base, get_db()
│   ├── mcp_server.py    # FastMCP instance + the 4 @mcp.tool() definitions
│   └── models.py        # SQLAlchemy ORM models
├── requirements.txt
├── run.py               # entrypoint: --init-db, or serve MCP over stdio
├── test_client.py       # destructive end-to-end CRUD/audit test
└── .gitignore
```

---

## Design notes

**Audit logging is a caller-committed hook.** `log_activity()` calls `db.add()` but deliberately
does **not** commit — the comment in [app/crud.py](app/crud.py) states the intent: the caller
commits, so the data change and the audit row land atomically.

In the current code the create functions commit the entity first, then log, then commit again.
A crash between those two commits would leave an entity with no audit row. If you need a hard
guarantee, restructure to a single commit:

```python
db.add(db_case)
db.flush()                      # assigns db_case.id without committing
add_entrust_logs(db, "CREATE", "cases", db_case.id, {...})
db.commit()                     # one atomic transaction
```

**`add_entrust_logs` is an alias.** It forwards to `log_activity()` verbatim; the alias exists to
satisfy an external naming requirement. Either name works.

**Session per tool call.** Each `@mcp.tool()` opens a `SessionLocal()` and closes it in a
`finally` block. Tools are therefore safe to call concurrently, and no session outlives a single
request.

**`check_same_thread=False`** is set on the SQLite connection because FastMCP may dispatch tool
calls from a thread other than the one that created the engine.

**Tool-level error handling.** Mutating tools catch `ValueError` from the CRUD layer and return
`{"error": "..."}`. This is deliberate: an LLM can read and recover from a returned error
object, whereas a raised exception becomes an opaque protocol-level failure.

**`list_recent_file_uploads` uses naive UTC.** The cutoff is computed with `datetime.utcnow()`
and compared against a timezone-aware column. This works on SQLite, which stores naive
timestamps, but will need an aware cutoff (`datetime.now(timezone.utc)`) if you move to
PostgreSQL.

---

## Extending the server

**Add a tool** — decorate a function in `app/mcp_server.py`:

```python
@mcp.tool()
def close_case(case_id: int) -> dict:
    """Close an open case. This action is strictly logged in the audit trail."""
    db = get_db_session()
    try:
        case = crud.close_case(db, case_id)          # add this to crud.py
        return {"success": True, "case_id": case.id}
    except ValueError as e:
        return {"error": str(e)}
    finally:
        db.close()
```

Write the docstring carefully — it is the only description the LLM gets.

**Audit a new mutation** — call `add_entrust_logs(db, action, table_name, record_id, details)`
inside the CRUD function before the final commit. The `details` dict is JSON-serialized, so keep
it to primitives.

**Switch databases** — change `SQLALCHEMY_DATABASE_URL` in [app/database.py](app/database.py)
(e.g. `postgresql+psycopg://user:pass@host/db`) and drop the SQLite-only `connect_args`.

---

## Troubleshooting

| Symptom | Cause / fix |
|---|---|
| Client lists the server but shows zero tools | The process crashed at import. Run `python run.py` manually and read the traceback — stdio transport hides startup errors in the client UI. |
| `no such table: projects` | You skipped `python run.py --init-db`, or `cwd` points elsewhere so a blank `sharebox.db` was created. |
| Summary always reports "Project not found" | Two different `sharebox.db` files. Confirm the `cwd` in your MCP config matches where you ran `--init-db`. |
| `TypeError: 'type' object is not subscriptable` | Python older than 3.10. Upgrade, or rewrite `list[dict]` as `List[Dict]` from `typing`. |
| Test passes but the server sees no data | `test_client.py` drops and recreates all tables. Re-seed after running it. |