Content
# Law Firm Document MCP Server
MCP server for AI-driven document research and summarization over a law firm document management system (DMS). Documents are unstructured
## Getting started
> **Offline environment.** This server runs fully air-gapped. All models are internal. No Anthropic API or public internet access required.
### 1. Prerequisites
- Python 3.11+
- Claude CLI or Claude Desktop
- Access to your internal LLM API endpoint
### 2. Configure environment variables
```bash
export DMS_BASE_URL="https://your-dms-host/api" # DMS API base URL
export DMS_API_KEY="your-dms-api-key" # DMS API key — never put in config.toml
export LLM_API_KEY="your-internal-llm-key" # key for internal LLM API (if required)
```
> `LLM_API_KEY` is passed to the Anthropic SDK via `ANTHROPIC_API_KEY`. If your internal model endpoint does not require auth, set it to any non-empty placeholder: `export ANTHROPIC_API_KEY=placeholder`
### 3. Configure config.toml
Open `config.toml`. All listed fields are required — Pydantic fails loud at import if any are missing.
```toml
[api]
base_url = "https://your-dms-host/api"
[models]
fast_model = "your-internal-fast-model-id" # model ID as known by your internal endpoint
large_model = "your-internal-large-model-id" # model ID as known by your internal endpoint
base_url = "https://your-internal-llm-api" # internal LLM API base URL (required for offline)
[search]
default_limit = 50
max_limit = 200
[selection]
max_search_limit = 200
max_files_for_summary = 10
tokens_per_file = 64
tokens_overhead = 256
default_profile = "researcher" # must match a [[profiles]] name below
preview_chars = 1000 # used by /SummaryPreview
[[profiles]]
name = "researcher" # default profile
description = "General legal research"
system_prompt = "You are a legal research assistant. Select documents most likely to be relevant to the research keyword."
```
Add more `[[profiles]]` blocks for different legal specializations.
### 4. Implement the API client
Subclass `LawFirmAPI` from `src/lawfirm_mcp/api.py` and assign an instance to `client` in `src/lawfirm_mcp/server.py`. Each abstract method has a TODO with the expected HTTP contract and an example return value. See [Implementing the API client](#implementing-the-api-client) below.
### 5. Connect to Claude
**Claude CLI:**
```bash
claude mcp add lawfirm \
-e DMS_BASE_URL=https://your-dms-host/api \
-e DMS_API_KEY=your-dms-key \
-e ANTHROPIC_API_KEY=your-llm-key \
-- uv run --project /path/to/MCP lawfirm-mcp
```
**Claude Desktop** — add to the MCP server config file:
```json
{
"mcpServers": {
"lawfirm": {
"command": "uv",
"args": ["run", "--project", "/path/to/MCP", "lawfirm-mcp"],
"env": {
"DMS_BASE_URL": "https://your-dms-host/api",
"DMS_API_KEY": "your-dms-key",
"ANTHROPIC_API_KEY": "your-llm-key"
}
}
}
}
```
### 6. Run your first query
Start Claude CLI:
```bash
claude
```
Then prompt:
```
Using the civil_law profile, find and summarize documents about "<keyword>" under scope <case-root-uuid>.
```
Or more explicit (invoke a prompt directly):
```
/SummaryContext <keyword> 550e8400-e29b-41d4-a716-446655440001 civil_law
```
**What happens under the hood:**
```
Claude (large model, CLI)
→ calls select_files(keyword="<keyword>", identifier="550e8400-e29b-41d4-a716-446655440001", profile="civil_law")
→ server searches DMS — up to max_search_limit files under that scope
→ server sends file metadata to fast model with profile system prompt
→ fast model returns top N file IDs ranked by estimated relevance
→ calls get_file_content for each selected file
→ synthesizes structured summary with citations (filename + file_id)
→ outputs a Markdown report with fixed `## Scope`, `## Findings`, `## Metrics` sections
```
The two-model split is transparent. The fast model runs inside `select_files` on the server; Claude in the CLI is the large summarization model. No extra orchestration needed from your side.
---
## What it does
Exposes DMS capabilities as MCP tools so an AI assistant (e.g. Claude) can:
1. Search documents by keyword within a directory subtree (scope = directory file_id; case-root file_id = whole case)
2. Browse files by directory file_id (recursive) or single file_id
3. Read document content — full or first N chars (`size` param) for cheap relevance previews
4. Synthesize summaries citing source filenames and file_ids
All scope is expressed via file_id (UUID). There is no `case` argument anywhere — the DMS resolves the owning case from the file_id.
The AI determines which files are relevant — there is no relevance score. Two selection mechanisms:
- **Metadata-based** (`select_files`) — fast model ranks candidates from filenames + excerpts. Used by `/SummaryKeyword` and `/SummaryContext`.
- **Content-based** (`get_file_content` with `size`) — large model reads first N chars of each file and judges from content itself. Used by `/SummaryPreview`. Use when filenames are uninformative.
## Tools
| Tool | Description |
|---|---|
| `search_files` | `(keyword, identifier, limit?, exclude_ids?)` — keyword search scoped to the directory subtree at `identifier`; returns deduplicated file metadata |
| `list_directory` | `(identifier, exclude_ids?)` — `identifier` is a file UUID; DMS resolves it as a directory entry (recursive) or a single file |
| `get_file_content` | `(file_id, size?)` — extracted text; pass `size` for first N chars only (cheap preview, same wire cost) |
| `select_files` | `(keyword, identifier, profile?, exclude_ids?)` — search + LLM-based file selection using a named profile; deduplicates before selection |
**Exclusion (`exclude_ids`).** All search/list tools accept an optional `exclude_ids: list[UUID]`. Each entry can be a single-file UUID (drop that file) or a directory UUID (drop the directory itself plus every file and subdirectory recursively under it). The DMS expands the subtree server-side — exclusion is not a client-side post-filter. Use this to regenerate a summary that drops files the previous run included.
### Resources
| URI | Description |
|---|---|
| `profiles://list` | List all selection profiles (name + description) defined in config.toml |
**Deduplication.** Both `search_files` and `select_files` remove duplicates from the DMS response on `(filename, directory)` — same file stored under multiple IDs is collapsed to one. Removed count surfaces as `duplicates_removed` in the response.
**Citations.** Summaries cite each source inline as `filename (file_id)`. No uncited claims.
### Report format (all prompts, fixed schema)
Every summarization prompt produces a Markdown document with three top-level sections in fixed order:
1. **`## Scope`** — prompt name, keyword (if any), identifier, profile, selection_method, excluded (UUIDs dropped at DMS layer, or `none`), generated_at (ISO 8601 UTC).
2. **`## Findings`** — the actual summary. Every factual claim inline-cites `filename (file_id)`.
3. **`## Metrics`** — three sub-blocks:
- **Token & content cost** — `selection_input_tokens`, `selection_output_tokens` (from `select_files`; 0 if not used), `content_chars_fetched` (sum of `total_chars` across `get_file_content` calls). Summary-stage tokens are not tracked server-side.
- **File audit** — every scanned file categorized into exactly one of nine fixed labels:
`DIRECT_DOMAIN_MATCH`, `INDIRECT_DOMAIN_MATCH`, `NO_DOMAIN_MATCH`, `LIKELY_PERSONAL`, `LIKELY_ENTERTAINMENT`, `INSUFFICIENT_CONTEXT`, `LOW_CONFIDENCE_MATCH`, `SYSTEM_OR_TEMP_FILE`, `NEGATIVE_KEYWORD_MATCH`. All nine categories listed even when empty.
- **Summary counts** — `files_scanned`, `files_used`, `files_skipped`, `duplicates_removed`.
Output prints to the terminal/chat. Markdown structure is stable so it can be piped to a file or rendered by a frontend later. `selection_method` per prompt: `/Summary` = `direct_read`, `/SummaryKeyword` and `/SummaryContext` = `metadata`, `/SummaryPreview` = `content_preview`.
## Summarization scenarios
Three prompts cover the three intended scenarios. Pick the one that matches your goal.
All prompts take an `identifier` (file UUID — directory entry or single file). The `profile` argument is optional and defaults to `config.selection.default_profile`. All file IDs in the system are UUIDs.
| Prompt | Parameters | Use when |
|---|---|---|
| `/Summary` | `identifier`, `profile?`, `exclude?` | General summary of files at a scope. No keyword. |
| `/SummaryKeyword` | `keyword`, `identifier`, `profile?`, `exclude?` | Keyword filters via metadata; summary is broad ("What are the documents about X?"). |
| `/SummaryContext` | `keyword`, `identifier`, `profile?`, `exclude?` | Keyword-focused via metadata — every point in the summary relates directly to the keyword. |
| `/SummaryPreview` | `keyword`, `identifier`, `profile?`, `exclude?` | Content-based selection — reads first N chars of each file. Use when filenames are uninformative. |
`exclude` is a comma-separated list of UUIDs (file or directory). Passed through to the DMS via `exclude_ids`; a directory UUID drops the whole subtree.
`identifier` is always a file_id. The DMS resolves whether it points at a directory entry (returns all files under it) or a single file (returns just that file). The owning case is resolved by the DMS — pass a case-root file_id to scope to a whole case.
**Positional argument order for every `/Summary*` prompt:**
```
/SummaryContext <keyword> <identifier> [profile] [exclude]
^required ^required ^optional ^optional, comma-separated UUIDs
```
`/Summary` (no keyword) is the same minus `<keyword>`. Skipping `[profile]` while supplying `[exclude]` is not possible — `[exclude]` is positional, so pass `[profile]` explicitly (e.g. `researcher`) when you only want to set `[exclude]`.
The examples below use these stable UUIDs so cross-example references are unambiguous:
| Role in examples | UUID |
|-------------------------------------------------|------------------------------------------|
| Case-root directory (whole case) | `550e8400-e29b-41d4-a716-446655440001` |
| Subdirectory inside the case (e.g. `Contracts/`)| `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
| Single file A (e.g. `MSA_2023_executed.pdf`) | `7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f` |
| Single file B | `8d5e0e31-b9c4-5d2e-af30-2b3c4d5e6f70` |
| Noisy subdirectory (e.g. `Archive/2019/`) | `9e6f1f42-cad5-6e3f-bf41-3c4d5e6f7081` |
| Noise file X (irrelevant after first run) | `0a317265-836a-9e4f-f277-9e45b0827267` |
| Noise file Y (irrelevant after first run) | `1b428376-9470-af50-0388-af56c1938378` |
Examples:
```bash
# ---- Basic invocations (default profile = researcher) ----
# General overview of an entire case
/Summary 550e8400-e29b-41d4-a716-446655440001
# Overview of just one subdirectory
/Summary 6ba7b810-9dad-11d1-80b4-00c04fd430c8
# Overview of a single document
/Summary 7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f
# ---- Keyword filtering ----
# Broad: "what do the contract-related docs say in general?"
/SummaryKeyword contract 550e8400-e29b-41d4-a716-446655440001
# Focused: "what specifically do they say about termination?"
/SummaryContext termination 550e8400-e29b-41d4-a716-446655440001
# Same keyword scoped to one subdir
/SummaryContext termination 6ba7b810-9dad-11d1-80b4-00c04fd430c8
# ---- Profile selection ----
# Legal-research lens
/SummaryContext liability 550e8400-e29b-41d4-a716-446655440001 civil_law
# Security-engagement lens (authorized pentest)
/SummaryContext credentials 550e8400-e29b-41d4-a716-446655440001 pentester
# Adversary-emulation lens (red team / threat modeling)
/SummaryContext domain_trust 550e8400-e29b-41d4-a716-446655440001 apt_emulation
# Compare two profiles on the same scope+keyword to see selection deltas
/SummaryContext access 550e8400-e29b-41d4-a716-446655440001 pentester
/SummaryContext access 550e8400-e29b-41d4-a716-446655440001 apt_emulation
# ---- Content-preview workflow (uninformative filenames) ----
# Use when filenames look like "doc_001.pdf", "scan_20240612.pdf", etc.
/SummaryPreview termination 6ba7b810-9dad-11d1-80b4-00c04fd430c8 civil_law
# ---- Exclusion: file, directory, mix ----
# Drop one specific file
/Summary 550e8400-e29b-41d4-a716-446655440001 researcher 7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f
# Drop a whole subdirectory (DMS expands the subtree)
/Summary 550e8400-e29b-41d4-a716-446655440001 researcher 9e6f1f42-cad5-6e3f-bf41-3c4d5e6f7081
# Drop two files + a subdirectory in one call
/SummaryContext termination 550e8400-e29b-41d4-a716-446655440001 civil_law 7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f,8d5e0e31-b9c4-5d2e-af30-2b3c4d5e6f70,9e6f1f42-cad5-6e3f-bf41-3c4d5e6f7081
# Default profile + exclude — keep `profile` empty by omitting,
# but exclude is positional, so pass profile explicitly:
/SummaryKeyword contract 550e8400-e29b-41d4-a716-446655440001 researcher 9e6f1f42-cad5-6e3f-bf41-3c4d5e6f7081
# ---- Regenerate workflow ----
# 1. Run once
/SummaryContext termination 550e8400-e29b-41d4-a716-446655440001 civil_law
# 2. Inspect the report's File audit + Findings sections.
# Decide some files were noise (e.g. fee_calculation_dispute_2024-08.xlsx and meeting_minutes).
# Note their file_ids from the report.
# 3. Re-run with those excluded — different selection, different summary:
/SummaryContext termination 550e8400-e29b-41d4-a716-446655440001 civil_law 0a317265-836a-9e4f-f277-9e45b0827267,1b428376-9470-af50-0388-af56c1938378
# 4. Still unhappy? Switch profile for a fresh lens:
/SummaryContext termination 550e8400-e29b-41d4-a716-446655440001 researcher 0a317265-836a-9e4f-f277-9e45b0827267,1b428376-9470-af50-0388-af56c1938378
```
### Direct tool examples (power users / ad-hoc queries)
Prompts cover scripted scenarios. For exploration the AI can call the tools directly. Examples of the JSON inputs the AI sends (handy for designing your own prompts or debugging):
```jsonc
// List everything under a case (recursive)
list_directory({"identifier": "550e8400-e29b-41d4-a716-446655440001"})
// List a single file's metadata (DMS resolves identifier as a file, not dir)
list_directory({"identifier": "7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f"})
// List, but drop an archive subdirectory
list_directory({
"identifier": "550e8400-e29b-41d4-a716-446655440001",
"exclude_ids": ["9e6f1f42-cad5-6e3f-bf41-3c4d5e6f7081"]
})
// Keyword search across a whole case
search_files({
"keyword": "termination",
"identifier": "550e8400-e29b-41d4-a716-446655440001"
})
// Search a subdir, ask for fewer hits
search_files({
"keyword": "settlement",
"identifier": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"limit": 20
})
// Search, exclude two known-noisy files
search_files({
"keyword": "credentials",
"identifier": "550e8400-e29b-41d4-a716-446655440001",
"exclude_ids": ["7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f", "8d5e0e31-b9c4-5d2e-af30-2b3c4d5e6f70"]
})
// Read full content of one document
get_file_content({"file_id": "7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f"})
// Cheap preview — first 1000 chars only (used by /SummaryPreview)
get_file_content({"file_id": "7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f", "size": 1000})
// LLM-based selection: ask the fast model to rank candidates
select_files({
"keyword": "domain trust",
"identifier": "550e8400-e29b-41d4-a716-446655440001",
"profile": "apt_emulation"
})
// Same call, second attempt — exclude what the first run already picked
select_files({
"keyword": "domain trust",
"identifier": "550e8400-e29b-41d4-a716-446655440001",
"profile": "apt_emulation",
"exclude_ids": ["7c4f9d20-a8b3-4c1d-9e2f-1a2b3c4d5e6f", "8d5e0e31-b9c4-5d2e-af30-2b3c4d5e6f70"]
})
```
The AI can also read the profiles resource to discover what profile names are available before calling `select_files` or one of the `/Summary*` prompts:
```
profiles://list
→ [
{"name": "researcher", "description": "Default general legal research"},
{"name": "civil_law", "description": "General civil law and litigation research"},
{"name": "pentester", "description": "Authorized penetration tester ..."},
{"name": "apt_emulation", "description": "Adversary emulation ..."}
]
```
### Two-model workflow (large scopes)
For large scopes where scanning all files is too expensive, the prompt instructs the AI to call `select_files` (fast-model LLM ranking from metadata) before reading content:
```
Call select_files(keyword="<keyword>", identifier="550e8400-e29b-41d4-a716-446655440001", profile="civil_law")
→ returns: {"selected_file_ids": ["550e8400-e29b-41d4-a716-446655440001", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", ...], "duplicates_removed": 0, ...}
```
This happens transparently inside `/SummaryKeyword` and `/SummaryContext` — no separate stage-2 prompt.
### Content-preview workflow (uninformative filenames)
When filenames give no signal, `/SummaryPreview` skips `select_files` and instead reads the first `selection.preview_chars` chars of every file under the scope:
```
For each file_id in list_directory(...):
get_file_content(file_id=<id>, size=1000) # cheap preview, same wire cost
→ AI judges relevance from content snippets
→ get_file_content(file_id=<id>) for each relevant file (full read)
```
Higher wire cost than metadata selection (N partial fetches vs. 1 metadata scan), but works when filenames are opaque.
---
### Selection profiles
Profiles set the legal expertise the fast model uses when selecting files. Defined in `config.toml`:
```toml
[[profiles]]
name = "civil_law"
description = "General civil law and litigation research"
system_prompt = "You are a legal research assistant specializing in civil law..."
[[profiles]]
name = "contract_review"
description = "Contract and commercial law research"
system_prompt = "You are a legal research assistant specializing in contract law..."
[[profiles]]
name = "pentester"
description = "Authorized penetration tester surveying documents for security-relevant material"
system_prompt = "You are an authorized penetration tester reviewing documents for an engagement..."
[[profiles]]
name = "apt_emulation"
description = "Adversary emulation profile modeling an APT-style threat actor (red team / threat modeling use)"
system_prompt = "You are an adversary-emulation analyst modeling APT document triage for red-team planning..."
```
All scenario prompts accept an optional `profile` parameter. Reference profiles by `name`. If omitted, `config.selection.default_profile` is used. The validator fails loud at config load if `default_profile` doesn't match an existing `[[profiles]]` entry.
## Project structure
```
MCP/
├── pyproject.toml
├── config.toml # Tuneable settings (limits, timeouts, defaults)
├── src/lawfirm_mcp/
│ ├── __init__.py
│ ├── config.py # Config loader — exposes typed `config` singleton
│ ├── models.py # Pydantic models: DMS data types + tool input schemas
│ ├── api.py # LawFirmAPI abstract base class — subclass and implement this
│ ├── selector.py # LLM-based file selection via messages.create
│ ├── utils.py # Helper functions (no MCP decorators)
│ └── server.py # FastMCP server: tools, resources, prompts, and client instantiation
└── tests/
├── conftest.py # Shared fixtures (stub_client, fake_anthropic)
├── stub_api.py # In-memory StubAPI(LawFirmAPI) with fake-but-realistic data
├── stub_selector.py # Optional fake selector (skips real LLM call)
├── run_stub_server.py # Entry point for MCP Inspector: wires stubs into server.client
├── test_config.py # Config loader + validators
├── test_models.py # Pydantic input model contracts
├── test_utils.py # Pure helpers
├── test_selector.py # File selection (anthropic client mocked)
├── test_stub_api.py # StubAPI itself
└── test_server.py # Tools + prompts end-to-end against StubAPI
```
## Local testing (no real DMS)
`tests/` ships an in-memory stub DMS plus an automated pytest suite that exercises every module. Use the stub to drive the server through MCP Inspector or to write your own scripts; use pytest to verify everything still works after a code change.
### Automated test suite
```bash
uv sync # install dev deps (pytest + pytest-asyncio) once
uv run pytest # run all tests (~80 tests, <1s)
uv run pytest -v # verbose, one line per test
uv run pytest tests/test_server.py -k exclude # filter to subset
```
Coverage per module:
| Test file | Validates |
|---|---|
| `test_config.py` | TOML loads, `default_profile` validator, `server.instructions` mentions every tool |
| `test_models.py` | UUID format accept/reject, `extra='forbid'`, limit bounds, `size > 0`, `exclude_ids` validation |
| `test_utils.py` | `dedup_files`, `file_info_to_dict`, `handle_error`, `summary_report_instruction` (3 sections + 9 categories + scope echo), `selection_instruction` |
| `test_selector.py` | `get_profile` success/fail, `_format_file_list`, `select_files` with `anthropic.AsyncAnthropic` monkey-patched (no network call), bad-UUID rejection from model output |
| `test_stub_api.py` | StubAPI itself: keyword matching (filename/excerpt/content), single-file vs directory resolution, `exclude_ids` subtree expansion |
| `test_server.py` | Every tool returns expected JSON shape, every prompt renders expected text, `exclude` echo, error path returns `"Error: ..."` |
The `fake_anthropic` fixture in `tests/conftest.py` replaces `anthropic.AsyncAnthropic` so selector tests never hit the network. The `stub_client` fixture wires `StubAPI` into `server.client` for tool tests.
### Manual testing via MCP Inspector
Use the stub to drive the live server from a browser UI. Two modes:
| Mode | Stubbed | Needs |
|---|---|---|
| Stub DMS only | `LawFirmAPI` → fake data | `ANTHROPIC_API_KEY` + reachable LLM endpoint (selector hits real model) |
| Stub DMS + selector | both | Nothing — fully offline, no LLM call |
**1. Stub data shipped:** five files across `Case_Smith/Contracts`, `Case_Smith/Evidence`, and `Case_Smith/Archive/2019` (a `MASTER SERVICES AGREEMENT`, an invoice, a witness statement, a network diagram, and a binary archive). Stable UUIDs match the README examples — see `tests/stub_api.py`. `exclude_ids` expands directory UUIDs to the subtree under them, exactly like the real DMS contract.
**2. Run the stub server:**
```bash
# Stub DMS, real Anthropic selector (needs LLM endpoint)
uv run python -m tests.run_stub_server
# Fully offline (stub DMS + stub selector — no LLM call at all)
STUB_SELECTOR=1 uv run python -m tests.run_stub_server
```
**3. Drive it with MCP Inspector** (browser UI, click each tool/prompt by hand):
```bash
npx @modelcontextprotocol/inspector uv run python -m tests.run_stub_server
```
The Inspector opens in your browser. Use the **Tools** tab to invoke `search_files`, `list_directory`, `get_file_content`, `select_files` with the stub UUIDs. Use the **Prompts** tab to render `/Summary`, `/SummaryKeyword`, `/SummaryContext`, `/SummaryPreview` and see the generated instruction text. Use the **Resources** tab to read `profiles://list`.
**4. Coverage matrix:**
| Component | Validated by |
|---|---|
| `config.py` / `config.toml` | Server boots = TOML parsed + Pydantic validators passed |
| `models.py` | Inspector rejects malformed UUIDs in the input form |
| `api.py` abstract contract | `StubAPI` is a real subclass — fails at import if signatures drift |
| `selector.py` | `select_files` tool returns ranked file_ids + token counts |
| `server.py` tools | Each tool invokable in Inspector with stub data |
| `server.py` prompts | Each `/Summary*` renders an instruction string referencing the right tools |
| `utils.py` | `## Scope / ## Findings / ## Metrics` template appears in prompt output |
**5. Wire your real client when ready** — swap `server.client = StubAPI(...)` for your `MyDMSAPI.from_env()` per [Implementing the API client](#implementing-the-api-client).
## Configuration
All tuneable values live in `config.toml` at the project root:
```toml
[server]
instructions = "You are a document research assistant..." # general AI role and tool-use guidance
[api]
base_url = "https://your-dms-host/api" # overridden by DMS_BASE_URL env var
timeout_seconds = 30
[search]
default_limit = 50
max_limit = 200
[selection]
max_search_limit = 200 # files scanned by fast model (metadata only, no content)
max_files_for_summary = 10 # files forwarded to the large summarization model
tokens_per_file = 64 # token budget per selected ID in fast model response
tokens_overhead = 256 # fixed tool_use envelope overhead
default_profile = "researcher" # profile used when caller does not specify one
preview_chars = 1000 # chars fetched per file for /SummaryPreview relevance check
[models]
fast_model = "your-fast-model-id" # stage 1: file selection via select_files
large_model = "your-large-model-id" # stage 2: summarization
base_url = "https://your-internal-llm-api" # internal LLM API endpoint (required)
[[profiles]]
name = "civil_law"
description = "General civil law and litigation research"
system_prompt = "You are a legal research assistant specializing in civil law..."
[[profiles]]
name = "contract_review"
description = "Contract and commercial law research"
system_prompt = "You are a legal research assistant specializing in contract law..."
[[profiles]]
name = "pentester"
description = "Authorized penetration tester surveying documents for security-relevant material"
system_prompt = "You are an authorized penetration tester reviewing documents for an engagement..."
[[profiles]]
name = "apt_emulation"
description = "Adversary emulation profile modeling an APT-style threat actor (red team / threat modeling use)"
system_prompt = "You are an adversary-emulation analyst modeling APT document triage for red-team planning..."
```
Config loads from the project root `config.toml`. Override the path with the `DMS_CONFIG_PATH` env var if needed.
## Implementing the API client
The DMS interface is defined as an abstract base class in `src/lawfirm_mcp/api.py`.
**Step 1 — subclass `LawFirmAPI` and implement the three required methods:**
```python
# src/lawfirm_mcp/my_dms.py
from lawfirm_mcp.api import LawFirmAPI
from lawfirm_mcp.models import FileInfo, DirectoryListing, FileContent
class MyDMSAPI(LawFirmAPI):
async def search_files(self, keyword, identifier, limit=50, exclude_ids=None):
async with self._client() as client:
response = await client.get(
"/search",
params={
"q": keyword,
"identifier": identifier,
"limit": limit,
"exclude": ",".join(exclude_ids or []),
},
)
response.raise_for_status()
return [FileInfo(**item) for item in response.json()["results"]]
async def list_directory(self, identifier, exclude_ids=None):
# identifier is a file UUID; DMS resolves it to a directory entry or single file
# exclude_ids: directory UUIDs must drop the full subtree at the DMS layer
...
async def get_file_content(self, file_id):
...
```
`self._client()` returns a pre-configured `httpx.AsyncClient` with auth headers and base URL set. `self._headers()` can be overridden if your DMS uses a different auth scheme.
**Step 2 — set `client` in `server.py`:**
```python
from lawfirm_mcp.my_dms import MyDMSAPI
client = MyDMSAPI.from_env()
```
Or instantiate directly:
```python
client = MyDMSAPI(username="user", auth_token="token", base_url="https://dms.example.com")
```
`from_env()` reads `DMS_USERNAME`, `DMS_API_KEY`, and `DMS_BASE_URL` from environment variables.
**Methods to implement (3 required):**
| Method | Purpose |
|---|---|
| `search_files(keyword, identifier, limit, exclude_ids)` | Keyword search within the directory subtree at `identifier`; `exclude_ids` drops files/subtrees at DMS layer |
| `list_directory(identifier, exclude_ids)` | List files for a directory ID (recursive) or single file ID; `exclude_ids` drops files/subtrees at DMS layer |
| `get_file_content(file_id)` | Full document text endpoint |
See each method's docstring in `api.py` for the expected HTTP contract and return types.
Connection Info
You Might Also Like
Train-in-Silence
The first Task-Aware MCP server and automated VRAM calculator for LLM...
stacklit
108,000 lines of code. 4,000 tokens of index. One command makes any repo...
AppClaw
AI-powered mobile automation agent — describe what you want in plain...
pdf-mcp
Production-ready MCP server for PDF processing with intelligent caching....
kotadb
Local-only code intelligence API for AI developer workflows (Bun +...
gemini-api-docs-mcp
A remote HTTP MCP server for searching Google Gemini API documentation.