Skip to main content
Glama
jianingchenNU

Employee Management MCP Server

README.md
# Employee Management MCP Server (EMP-MCP-2026)

An MCP server exposing 6 employee-management tools over **stdio**, backed by a synthetic
JSON employee dataset.

## Tools

| Tool | Purpose |
|---|---|
| `list_employees` | Filter/search employees (department, status, location, free-text query). |
| `get_employee` | Fetch one employee's full profile by `employee_id`. |
| `get_org_summary` | Headcount aggregation by department / status / location. |
| `create_employee` | Onboard a new hire. |
| `update_employee` | Partial update of an existing employee's mutable fields. |
| `deactivate_employee` | Soft-delete (sets `status` to `inactive`; record stays retrievable). |

## Setup

Requires Python 3.10+.

```bash
python -m venv .venv
.venv\Scripts\activate        # Windows
pip install -e .
pip install -e ".[dev]"       # adds pytest, needed for tests/test_smoke.py
```

Copy `.env.example` to `.env` and adjust if needed (no secrets live in either file —
only paths and mode flags). Note: the server does **not** auto-load `.env` — there is
no `python-dotenv` dependency and nothing in `config.py` reads `.env` files. `.env` is
documentation of what to set; to make it take effect you either export those variables
in your shell before running, set them directly in the `env` block of your MCP host's
server config (the common case — see "Host configuration" below), or load the file
yourself with a tool of your choice before running the server.

## Running the server

```bash
python -m employee_mcp.server
```

On first run, if `EMP_MCP_DATA_PATH` doesn't exist yet, the server copies the committed
seed (`data/employees.seed.json`, 14 synthetic employees across all 5 departments) to
that path and treats it as the mutable working dataset. Delete that file to reset to a
clean seed on the next run.

**Windows note:** make sure `python` resolves to the interpreter with `mcp` installed
(or point a host's `command` at the venv's absolute `python.exe`). Prefer an absolute
`EMP_MCP_DATA_PATH` when a host launches this as a subprocess — a relative path combined
with a host-chosen working directory is the most common cause of "connected but no
tools" / "connection closed" on Windows. In this codebase, relative `EMP_MCP_DATA_PATH`
values are resolved against the repo root (not the process's cwd) specifically to avoid
that trap, but an absolute path is still the most robust choice for host configs.

## Data storage

Employees live in a single JSON array file (`data/employees.json`), loaded in full at
startup into an `employee_id -> Employee` dict (`store.py`) for O(1) lookups. Every
write (`create_employee` / `update_employee` / `deactivate_employee`) re-serializes
the whole in-memory set and persists it atomically — write to a `.tmp` file, then
`os.replace()` over the target — so a crash or kill mid-write can't leave a
half-written, corrupt data file.

JSON (rather than a real database) is a deliberate choice for this project's scale:
the seed is 14 employees, and this design targets small in-memory datasets on the
order of hundreds to low thousands of records, not enterprise HRIS volumes. At that
size, loading the entire file into memory on every server start has
no meaningful cost, and JSON buys a human-readable, git-diffable store with zero
schema migrations, no separate database process to run, and no driver to install —
appropriate for a synthetic-data assignment/demo, not a claim that this approach
would scale past that ceiling (see "Known limitations" for what would need to change
if it did — e.g. real pagination, an actual DB engine, and finer-grained locking than
the current "rewrite the whole file" strategy allows for concurrent writers).

## Configuration (environment variables)

| Env var | Default | Meaning |
|---|---|---|
| `EMP_MCP_DATA_PATH` | `./data/employees.json` | Mutable working data file. |
| `EMP_MCP_READ_ONLY` | `false` | When `true`, all write tools refuse with a `READ_ONLY` error and make no changes. |
| `EMP_MCP_DEBUG` | `false` | When `true`, unexpected-error messages include a traceback. Keep `false` in normal/demo use. |
| `EMP_MCP_AUDIT_PATH` | `./logs/audit.log` | Path to the audit log file (one JSON line per tool call). |

No secrets are configured anywhere — these are mode flags and paths only.

## Host configuration

```json
{
  "mcpServers": {
    "employee-mcp": {
      "command": "<ABSOLUTE-PATH-TO-YOUR-python.exe>",
      "args": ["-m", "employee_mcp.server"],
      "env": {
        "PYTHONPATH": "<ABSOLUTE-PATH-TO-THIS-REPO-ROOT>",
        "EMP_MCP_DATA_PATH": "<ABSOLUTE-PATH-TO-THIS-REPO-ROOT>\\data\\employees.json",
        "EMP_MCP_AUDIT_PATH": "<ABSOLUTE-PATH-TO-THIS-REPO-ROOT>\\logs\\audit.log",
        "EMP_MCP_READ_ONLY": "false",
        "EMP_MCP_DEBUG": "false"
      }
    }
  }
}
```

Fill in the two placeholders for your own machine (every path must be absolute — a
relative path combined with a host-chosen working directory is the most common cause
of "connected but no tools" / "connection closed" on Windows):

- **`<ABSOLUTE-PATH-TO-YOUR-python.exe>`** — the interpreter you installed this
  project into. If you used a venv (`python -m venv .venv` per Setup above), activate
  it and run `(Get-Command python).Source` in PowerShell, then paste that path, e.g.
  `C:\Users\you\...\Employee_management_MCP_server\.venv\Scripts\python.exe`.
- **`<ABSOLUTE-PATH-TO-THIS-REPO-ROOT>`** — the folder containing this README on
  your machine, e.g. `C:\Users\you\Documents\Employee_management_MCP_server`. It's
  used four times above: once for `PYTHONPATH`, and as the prefix for both data-file
  paths — replace all of them consistently.

`PYTHONPATH` matters specifically when `command` points at a bare interpreter that
this project was never `pip install -e .`'d into — without it, `python -m
employee_mcp.server` can't locate the `employee_mcp` package and the host will show
the server as failed/disconnected. If `command` points at a venv's `python.exe` where
you already ran `pip install -e .`, `PYTHONPATH` is unnecessary — the editable install
makes the package importable from anywhere — but it doesn't hurt to leave it set.

## Debugging with MCP Inspector

```bash
npx @modelcontextprotocol/inspector python -m employee_mcp.server
```

This opens a local web UI (`http://localhost:6274/...`) where you can run `tools/list`,
inspect each tool's JSON Schema, and call tools interactively with a form built from
that schema. To test read-only mode, set `EMP_MCP_READ_ONLY=true` in the Inspector's
"Environment Variables" panel before connecting.

## Human-in-the-loop (HITL) expectation

This server does not gate write tools on its own confirmation step — it expects the
**MCP host** to show the user the write tool's resolved input (the employee fields
about to be created/updated/deactivated) before actually calling it, the way most
MCP-aware chat hosts do for consequential tool calls. `EMP_MCP_READ_ONLY=true` is the
hard backstop for hosts/contexts where that isn't available.

## Email-uniqueness interpretation

The requirement is "email unique across active employees." This server enforces it as:
on create, and on update that changes `email`, reject with `DUPLICATE_EMAIL` if any
*other* employee whose `status == "active"` already holds that email (case-insensitive
compare). Two `inactive`/`on_leave` records may share an email with each other or with
what later becomes an active employee's old email — the constraint only looks at
currently-active holders at the moment of the write.

## Other documented interpretations

- **`list_employees` limit > 100:** clamped to 100, not rejected.
- **Deactivating an already-inactive employee:** succeeds idempotently; the response
  reports `previous_status == new_status == "inactive"` rather than erroring.
- **`update_employee` and `manager_id`:** omitting `manager_id` means "no change."
  There's currently no way to explicitly clear an employee's manager back to `null`
  via `update_employee` (the tool can't distinguish "field omitted" from "field
  explicitly set to null" with this parameter shape) — reassign to a different
  manager, or edit `manager_id` at creation time. A future iteration could resolve
  this with a sentinel/"unset" value if that gap matters for real usage.
- **SDK-level argument validation:** FastMCP validates each tool's arguments (types,
  enums) against its generated JSON Schema *before* our tool code runs. Such failures
  are non-crashing and surfaced as `isError: true` with a message starting
  `VALIDATION_ERROR: invalid arguments for <tool>: ...`, and are still recorded as one
  `logs/audit.log` line with `status: "error"`, `code: "VALIDATION_ERROR"` — a thin
  server-level wrapper (`_AuditedFastMCP` in `server.py`) exists specifically to
  guarantee that, since it doesn't happen for free.

## Audit logging

Every tool call appends exactly one JSON line to `logs/audit.log` (created at
startup if missing) with `timestamp`, `tool`, `args_summary` (PII-light — email
local-part masked, full names omitted), `status` (`ok`/`error`), `latency_ms`, and
on failure a taxonomy `code`. Nothing is ever written to stdout — stdout is reserved
for JSON-RPC frames; all logging goes to this file and/or stderr.

## Error taxonomy

`VALIDATION_ERROR`, `DUPLICATE_EMAIL`, `NOT_FOUND`, `MANAGER_CYCLE`, `READ_ONLY`,
`INTERNAL_ERROR` — see `employee_mcp/errors.py`. Every domain failure message has the
shape `CODE: human-readable detail`, e.g. `NOT_FOUND: no employee with id EMP-999`.

## Tests

```bash
python -m pytest tests/test_smoke.py -v
```

Spins up the real server as a subprocess per test and drives it over stdio with an
`mcp.ClientSession`, exercising protocol discovery, all 6 tools' success/failure
paths, read-only mode, malformed-argument robustness, and audit-log completeness.
This — not "the LLM said it worked" — is the evidence that the transport contract
holds.

## Demo

See `demo/demo_transcript.md` for a captured run of three flows (search/list, create,
org summary) plus one intentional failure path (duplicate email), against the seed
dataset, with the corresponding `logs/audit.log` lines shown alongside each call.

## Known limitations

- No pagination cursor for `list_employees` beyond `limit`/clamp — fine at the small,
  in-memory scale this project targets (low thousands of records at most), would need
  revisiting at larger scale.
- `update_employee` can't clear `manager_id` back to `null` (see above).
- Audit log location defaults to `logs/audit.log` and is configurable via
  `EMP_MCP_AUDIT_PATH`, but nothing enforces a 1:1 pairing with `EMP_MCP_DATA_PATH`
  — server instances that don't override it share the same default file.
- Out of scope by design: no HTTP/SSE transport, no auth/OAuth provider, no
  frontend UI, no real HRIS/payroll integration, no stretch tools
  (`search_by_skill`, `get_direct_reports`, `export_employees_csv`, MCP Resources).