Skip to main content
Glama
Sumanharapanahalli

threat-model-mcp

README.md
# threat-model-mcp

Serve **any STRIDE threat model** to **any MCP-capable LLM** — Claude Code, Gemini CLI,
a local Ollama / LM Studio client, or a custom agent — and export it to the **Microsoft
Threat Modeling Tool**.

Point the server at your threat register. Then ask an agent *"what are the critical
threats?"*, *"how do I mitigate the auth bypass?"*, or *"which threats touch the
database?"* and it answers from your register instead of guessing.

```bash
pip install -e .
threat-model-mcp --data ./my-threat-model.json
```

The server ships with **no threat data of its own**. Your model is yours; nothing is
bundled, inferred, or phoned home. A worked example is included so you have something to
point at on day one.

---

## Why this exists

Threat models live in spreadsheets that nobody reads. An LLM that can *query* the
register — rather than being handed a 40-page PDF and asked to remember it — turns the
model into something you consult during code review, incident response, or design.

And because the register is structured, the same data can be exported straight into the
Microsoft Threat Modeling Tool, so it stays usable by people who don't work in a chat
window.

---

## Install

Requires Python >= 3.10. The only runtime dependency is the `mcp` SDK.

```bash
git clone <this-repo> threat-model-mcp
cd threat-model-mcp
pip install -e .
```

Verify:

```bash
python -m pytest                                              # the full test suite
threat-model-mcp --data threat_model_mcp/examples/example-model.json --check
```

`--check` validates the dataset, prints a summary, and exits without serving. Use it in
CI to catch a malformed register before an agent ever sees it.

---

## Point it at your data

The dataset is **required** — there is no implicit default, because silently serving
somebody else's threat model would be worse than a startup failure.

```bash
threat-model-mcp --data ./my-model.json      # explicit flag
THREAT_MODEL_PATH=./my-model.csv threat-model-mcp   # or the environment
```

Both `.json` and `.csv` work. A CSV register — the natural output of a spreadsheet
review — is normalised on load, so there is no build step and no generated artifact to
keep in sync.

The smallest dataset that works:

```json
{
  "meta": { "name": "My Service" },
  "threats": [
    {
      "id": "T-1",
      "stride": "Spoofing/EoP",
      "component": "Auth service",
      "description": "Tokens are decoded but never verified",
      "severity": "Critical",
      "mitigation": "Verify the signature against the IdP JWKS"
    }
  ]
}
```

Or the same thing as CSV:

```csv
ID,STRIDE,Element,Threat,Risk,Mitigation
T-1,Spoofing/EoP,Auth service,Tokens are decoded but never verified,Critical,Verify the signature against the IdP JWKS
```

Only `id`, `stride`, and one of `title`/`description` are required. See
[`docs/DATA-SCHEMA.md`](docs/DATA-SCHEMA.md) for the full contract, and
[`threat_model_mcp/examples/`](threat_model_mcp/examples/) for a complete worked model.

### Your domain fields survive

Any column or key the schema doesn't recognise is preserved in the threat's `extra`
dict, searchable and returned by the tools. A medical-device team can carry
`FDA_Objective`, `IEC62443_FR` and `Safety_14971`; an appsec team carries `OWASP` and
`CWE`; nothing is hardcoded and nothing is dropped.

Boolean-ish values (`yes`/`no`/`true`/`false`) become real booleans, and
`coverage_summary()` counts them automatically:

```json
"by_extra": { "handles_pii": 3 }
```

The server has never heard of `handles_pii`. It counted it because it is a flag.

---

## Register it with an LLM

The server speaks MCP over **stdio**, so any compliant client can spawn it.

### Claude Code

```bash
claude mcp add threat-model \
  --env THREAT_MODEL_PATH=/abs/path/to/my-model.json \
  -- python -m threat_model_mcp.server
```

Or drop a `.mcp.json` in your project (an example ships in this repo) and run `claude`
in that directory:

```json
{
  "mcpServers": {
    "threat-model": {
      "command": "python",
      "args": ["-m", "threat_model_mcp.server"],
      "env": { "THREAT_MODEL_PATH": "./my-model.json" }
    }
  }
}
```

### Gemini CLI

Add the same block to `~/.gemini/settings.json` (or a project `.gemini/settings.json`),
then run `gemini`.

### Any other MCP client

```jsonc
{
  "command": "python",
  "args": ["-m", "threat_model_mcp.server", "--data", "/abs/path/to/my-model.json"]
}
```

The client performs the standard handshake, calls `tools/list`, and invokes any tool via
`tools/call`. The server's name and its `instructions` — the description the LLM reads to
decide *when* to reach for these tools — are generated from your dataset's own metadata,
including any caveats you attached to it.

---

## Tool reference

| Tool | Description |
|------|-------------|
| `list_threats()` | Compact list of every threat (id, title, STRIDE, severity, component, status). |
| `get_threat(threat_id)` | Full record for one id, including its `extra` fields. Unknown id → `{"error": ...}`. |
| `threats_by_stride(category)` | Threats whose STRIDE set **contains** the category. Accepts full names or shorthand (`EoP`, `DoS`, `I`, ...). |
| `threats_by_severity(level)` | Threats at a severity level (case-insensitive). |
| `threats_by_component(component)` | Threats whose component matches a substring. |
| `search_threats(query)` | Free-text search across the core fields **and every `extra` value**. |
| `get_mitigations(threat_id)` | Mitigation, severity, priority, and the threat's whole `extra` bag. |
| `coverage_summary()` | Counts by STRIDE, severity, priority, group, status; the highest-severity ids; and `by_extra` flag counts. |
| `export(fmt)` | Serialise the register: `json` \| `csv` \| `md` \| `tm7`. |
| `model_info()` | Your `meta` block: name, description, scope, method, standards, status, caveats. |

**STRIDE is multi-valued.** A threat can carry several categories, so it answers to each
of them, and the per-STRIDE counts in `coverage_summary()` sum to *more* than the total.
That is correct, not a bug.

**Every tool is read-only.** The server never writes to your dataset.

---

## Microsoft Threat Modeling Tool export

`export("tm7")` emits a `.tm7` model — the data-flow diagram plus every threat as a
threat instance — for the official Microsoft Threat Modeling Tool.

The diagram is **data, not code**. Declare it in your dataset's optional `meta.dfd`
block:

```json
"dfd": {
  "elements": [
    { "id": "EI1", "kind": "external", "label": "Browser client", "x": 40, "y": 40 },
    { "id": "P1",  "kind": "process",  "label": "P1 API gateway" },
    { "id": "DS1", "kind": "store",    "label": "DS1 Notes database" }
  ],
  "flows": [
    { "source": "EI1", "target": "P1", "label": "HTTPS request" },
    { "source": "P1", "target": "DS1", "label": "read/write" }
  ]
}
```

Coordinates are optional — anything without `x`/`y` is auto-laid-out on a grid, banded by
kind (externals on top, processes in the middle, stores at the bottom). **If you omit the
`dfd` block entirely**, one Process element is inferred per distinct `component` value, so
the export still works on a bare register.

Each threat is attached to an element by matching its `component` against the element ids
and labels; set `extra.dfd_element` to route it explicitly.

> **Caveat.** The Threat Modeling Tool is a Windows desktop app with no CLI or API, and
> the `.tm7` format is `DataContractSerializer` XML whose stencil GUIDs reference the
> loaded KnowledgeBase. This emitter produces a well-formed model of the correct shape
> using the stock SDL generic type ids, but it is **authored from the schema and not
> tool-validated**. Open the output in TMT and *Save As* to let the tool normalise it
> before relying on it.

---

## Use it as a library

`ThreatModel` has no dependency on the `mcp` SDK, so you can query a register from any
Python program:

```python
from threat_model_mcp import ThreatModel

model = ThreatModel.from_path("my-model.csv")
print(model.coverage_summary())
print(model.threats_by_stride("EoP"))
open("model.tm7", "w").write(model.export("tm7"))
```

---

## Contributing

See [`CONTRIBUTING.md`](CONTRIBUTING.md). The short version: query logic goes in
`model.py`, `server.py` stays a thin wrapper, and nothing writes to stdout — that is the
MCP stdio channel.

## License

[MIT](LICENSE).