Everlaw liefert einen eigenen gehosteten MCP-Server aus, deshalb lautet die interessante Frage nicht mehr, ob Sie Claude an Ihre Verfahren anbinden, sondern was dieser Server Sie nicht fragen lässt. Die Antwort ist das Review-Management: Assignment Groups, das Coding-Schema und wie weit das Review-Team in einem Batch tatsächlich ist. Das Scaffold unter apps/web/public/artifacts/mcp-server-everlaw-ediscovery/ schließt genau diese Lücke und sonst nichts.
Beginnen Sie mit dem offiziellen Server
Everlaw dokumentiert einen gehosteten MCP-Server unter https://api.everlaw.com/v1/mcp — Servername everlaw-mcp, Version 0.1.0, Protokollrevision 2025-11-25, gemäß der am 9. Juli 2026 veröffentlichten Entwicklerreferenz. Es ist ein standardkonformer OAuth-2.0-Autorisierungsserver, der Protected-Resource-Metadaten nach RFC 9728 veröffentlicht; ein konformer Client verbindet sich damit ohne weitere Einrichtung außer der Ressourcen-URL. Jede Aktion läuft mit den Rechten des angemeldeten Nutzers: Der Server sieht nichts, was dieser Nutzer nicht auch in der Everlaw-Weboberfläche sähe.
Er registriert acht Tools. GetProjects, GetProjectBinders, GetProjectMetadataFields, GetProjectProcessedUploads und GetProjectDatasets zählen ein Projekt auf. PostProjectSearch, GetProjectSearchResult und DescribeProjectSearchTerm führen Suchen über 24 Suchterme aus und blättern durch die Treffer, wahlweise mit Metadaten pro Dokument, Download-Links für den Text und KI-extrahierten Werten.
Binden Sie zuerst diesen Server an. Für das Auffinden von Dokumenten ist er besser als alles, was Sie selbst bauen würden: Er erbt die Rechte pro Nutzer, verlangt kein Credential-Provisioning, und Everlaw pflegt ihn.
Die Lücke, die dieses Scaffold schließt
Zwei der Suchterme, die PostProjectSearch akzeptiert, lassen sich aus diesen acht Tools nicht konstruieren. ASSIGNED verlangt eine assignmentGroup.id, eine assignmentId oder eine userId. CODED verlangt eine labelId — die ID einer Kategorie oder eines Codes. Everlaws eigene Referenz zu diesen Termen verweist den Leser auf GetProjectAssignmentGroups, GetProjectCodes, GetProjectUsers und GetProjectGroups, und alle vier sind REST-Operationen, keine Tools des gehosteten Servers.
Die praktische Folge: Ein Agent, der nur am gehosteten Server hängt, findet jedes Dokument mit „Freistellung“ in einem Bates-Bereich, kann Ihnen aber nicht sagen, dass ein Privilege-Batch der zweiten Stufe überhaupt existiert, geschweige denn, wie viel davon uncodiert ist. Fragen zum Review-Status sind die, die eine Legal-Ops-Leitung mehrmals täglich beantwortet, und genau die erreicht der gehostete Server nicht.
Das Scaffold registriert fünf rein lesende Tools über die REST-API, um den Kreis zu schließen. list_assignment_groups liefert Gruppen, Anzahl der Assignments und Bearbeiter-IDs. list_codes liefert Kategorien und Codes samt ihrer Exklusivitätskennzeichen. review_progress liefert die Anzahl geprüfter und ungeprüfter Dokumente mit einem Prozentwert pro Gruppe. list_search_term_reports liefert Reportnamen, Eigentümer und Termanzahl. resolve_assignee_names übersetzt Nutzer-IDs in Reviewer-Namen und ist standardmäßig deaktiviert.
Bewusst registriert es kein Such-Tool, kein Dokumentabruf-Tool und kein Dokumenttext-Tool. Die gibt es bereits auf dem gehosteten Server, ausgeführt mit den Rechten des angemeldeten Nutzers — ein sicherer Ort dafür als ein Organisations-API-Key.
Wie review_progress eine Zahl berechnet, die Everlaw nicht veröffentlicht
Everlaw hat keinen Endpunkt für den Review-Fortschritt. Die Zahlen in src/everlaw_ediscovery_mcp/server.py entstehen, indem der Term ASSIGNED zweimal pro Gruppe ausgeführt wird — einmal mit reviewStatus: "REVIEWED", einmal mit "NOT_REVIEWED" — und numDocs aus jeder Antwort gelesen wird.
Die Entscheidung, die man benennen sollte, ist die Aggregationsebene. Das Scaffold fragt auf ALL_IN_GROUP-Ebene ab, zwei Suchen pro Gruppe, statt pro Assignment. Jeder PostProjectSearch-Aufruf materialisiert ein gespeichertes Suchobjekt, das mit einer app.everlaw.com-URL in der Suchhistorie des Projekts auftaucht, und Everlaw begrenzt die Zahl nutzersichtbarer Objekte, die die API anlegen darf, und antwortet bei Überschreitung mit 422. Eine Gruppe mit 12 Bearbeitern kostet auf Gruppenebene 2 Suchen und auf Assignment-Ebene 24 — für eine Aufschlüsselung, nach der niemand gefragt hat.
Wann Sie es nicht einsetzen sollten
Lassen Sie es, wenn Sie den gehosteten Server noch nicht angebunden haben. Fast jede Frage eines Verfahrensteams ist eine Dokumentfrage, und Credential-Infrastruktur zu bauen, um zuerst die kleinere Kategorie zu bedienen, ist die falsche Reihenfolge.
Lassen Sie es, wenn Ihre Organisation weniger als rund vier parallele Verfahren führt oder Review-Status-Abfragen unter etwa fünfzehn pro Woche bleiben. Die Einrichtung kostet 2 bis 4 Stunden: Ein Organisationsadministrator erzeugt den API-Key, jemand ordnet die vier nötigen Berechtigungen zu, die Rechtsabteilung prüft den Wirkungsradius eines organisationsweiten Credentials, und die vier Verifikationsschritte aus der README.md laufen gegen ein Projekt, dessen Zahlen Sie von Hand nachrechnen können. Bei geringem Volumen amortisiert sich das nicht — nutzen Sie die Dashboards von Everlaw.
Lassen Sie es, wenn Sie keinen Organisationsadministrator dazu bekommen, einen eng gefassten Key bereitzustellen. Ein Everlaw-API-Key ist an kein Nutzerkonto gebunden und gewährt Zugriff auf dem Niveau eines Organisationsadministrators, begrenzt allein durch die ihm erteilten Berechtigungen pro Endpunkt. Ist der einzige erhältliche Key ein ungefasster, wird die Sicherheitsprüfung scheitern — und das zu Recht.
Lassen Sie es, wenn eine Schutzanordnung regelt, wie Verfahrensdaten übertragen oder verarbeitet werden. Namen von Assignment Groups und Coding-Kategorien beschreiben die Review-Strategie. Klären Sie das mit der Rechtsabteilung, bevor Sie sie durch eine Claude-Sitzung leiten.
Fehlermodi und ihre Absicherungen
Ein organisationsweiter Key liest über Verfahren hinweg. Ein einziges Credential erreicht jedes Projekt der Organisation, auch Verfahren unter unterschiedlichen Schutzanordnungen. Absicherung: Setzen Sie EVERLAW_ALLOWED_PROJECTS auf explizite numerische IDs. Verifikationsschritt 2 der README.md fragt ein Projekt außerhalb der Liste ab und erwartet eine Ablehnung, ohne dass ein HTTP-Request abgesetzt wird.
Der 403 ist absichtlich mehrdeutig. Everlaw antwortet identisch mit 403, ob ein Projekt nicht existiert oder der Aufrufer keinen Zugriff hat, damit Projekt-IDs nicht aufzählbar sind. Ein Agent liest das als Tippfehler und versucht es mit einer anderen ID. Absicherung:raise_for_everlaw() schreibt den 403 so um, dass beide Fälle als ununterscheidbar benannt werden und die Berechtigungen des Keys zu prüfen sind, nicht die Zahl.
Geplantes Polling erschöpft die Objektgrenze.review_progress schreibt zwei gespeicherte Suchen pro Gruppe und Aufruf. Stündliches Polling über 10 Gruppen sind 480 gespeicherte Suchen pro Tag, in einem begrenzten Kontingent, und vermüllen die Suchhistorie, mit der das Review-Team arbeitet. Absicherung: Die Tool-Beschreibung sagt es, die README verbietet den Einsatz in einer Polling-Schleife, und der 422 wird in eine Erklärung der Grenze übersetzt statt in einen generischen Fehler.
„Geprüft“ bedeutet je Gruppe etwas anderes. Jede Assignment Group trägt eigene Review-Kriterien, der Prozentwert folgt also der Definition ihres Erstellers und ist kein Synonym für „codiert“. Zwei Gruppen desselben Projekts können bei derselben Zahl Verschiedenes meinen. Absicherung: Jede review_progress-Antwort führt ein _note-Feld, das genau das festhält, und Verifikationsschritt 4 verlangt den Abgleich einer Gruppe mit der Oberfläche, bevor Sie eine Zahl weitergeben.
Rate-Limits gelten pro Credential. Everlaw erzwingt 25 Requests pro Sekunde je authentifiziertem Nutzerkonto und antwortet darüber mit 429. Absicherung: Der Client taktet sich auf 8 Requests pro Sekunde mit einem vierfachen Concurrency-Gate und exponentiellem Backoff, und die README weist an, einem nächtlichen Export-Job einen eigenen Key zu geben.
Die Alternativen und wann sie gewinnen
Der gehostete Server allein gewinnt, sobald die Fragen Dokumente betreffen und nicht Batches. Er ist kostenlos, erbt Rechte und wird vom Anbieter gepflegt. Ergänzen Sie dieses Scaffold erst, wenn Sie die Review-Status-Fragen benennen können, die er nicht beantwortet.
Everlaws eigene Analytics gewinnen für Produktivitätsberichte je Reviewer. GetProjectAnalytics liegt in der Scope-Gruppe SECURITY_READ und verlangt Organisationsadministrator-Zugriff; das Scaffold schließt es bewusst aus, weil Aktivitätsdaten pro Reviewer Aufsichtsfragen aufwerfen, die ein Chat-Tool nicht versehentlich beantworten sollte.
Das Relativity-Gegenstück ist das Muster zum Nachbauen, wenn Sie beide Plattformen betreiben — mit umgekehrtem Trade-off, denn Relativity hat keinen gehosteten MCP-Server, und die gesamte Oberfläche bauen Sie selbst.
Everlaw rechnet nach Datenvolumen ab und nicht nach Seats, an Ihrer Rechnung ändert das hier also nichts. Die Kosten sind die 2 bis 4 Stunden Einrichtung und die dauerhafte Pflicht, ein organisationsweites Credential eng zu halten. Wollen Sie zuerst den konzeptionellen Hintergrund, lesen Sie MCP-Server versus Claude Skill und eDiscovery; bauen Sie das Umfeld auf, behandelt der eDiscovery-Stack die Plattformentscheidungen drumherum.
# everlaw-ediscovery-mcp
A read-only MCP server over the Everlaw REST API that exposes review-management data: assignment groups, the project coding schema, search term reports, and a computed reviewed / not-reviewed rollup per assignment group.
**Read this first.** Everlaw already runs a hosted MCP server at `https://api.everlaw.com/v1/mcp`. If your question is "find me documents," connect that one instead — it authenticates the signed-in user over OAuth, inherits that user's Everlaw permissions exactly, and needs no infrastructure from you. This scaffold is not a replacement for it and does not duplicate its tools.
It exists because of a specific gap. The hosted server registers eight tools — `GetProjects`, `GetProjectBinders`, `GetProjectMetadataFields`, `GetProjectProcessedUploads`, `GetProjectDatasets`, `PostProjectSearch`, `GetProjectSearchResult`, `DescribeProjectSearchTerm` — and its `PostProjectSearch` accepts an `ASSIGNED` term and a `CODED` term. Those two terms require ids (`assignmentGroup.id`, `assignmentId`, `labelId`, `userId`) that none of the eight tools return. Everlaw's own reference points the reader at `GetProjectAssignmentGroups`, `GetProjectCodes`, `GetProjectUsers` and `GetProjectGroups` for them, and those are REST operations rather than tools on the hosted server. This scaffold registers the missing lookups, plus the progress rollup that has no endpoint of its own.
Run both servers side by side: the hosted one for search and documents, this one for the review-management identifiers and status numbers.
> **Not runtime-tested.** Paths, scope groups, permission names and response shapes track the published Everlaw OpenAPI specification (`https://api.everlaw.com/docs/everlaw-openapi.yaml`) as of August 2026. Nobody has run this against a live tenant. Treat first-run output as unverified until you have checked each tool against numbers you can confirm in the Everlaw UI.
## Install
Python 3.11 or newer.
```bash
cd mcp-server-everlaw-ediscovery
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
```
## Environment variables
### `EVERLAW_API_KEY` (required)
An Everlaw organization API key, used as a bearer token. Generate it on the **API keys** tab of the Organizations page in Everlaw; you need Organization Admin rights to do so. Keys look like `everlaw-api.XXXX.YYYYYYYY`.
An API key is not tied to the creator's user account and grants access equivalent to an Organization Admin, bounded by the per-endpoint permissions you grant it. Grant these four and no others:
| Permission | Scope group | Used by |
|---|---|---|
| `GetProjectAssignmentGroups` | `REVIEW_READ` | `list_assignment_groups`, `review_progress` |
| `GetProjectCodes` | `REVIEW_READ` | `list_codes` |
| `PostProjectSearch` | `REVIEW_READ` | `review_progress` |
| `GetProjectSearchTermReports` | `REVIEW_READ` | `list_search_term_reports` |
Do not grant `DATA_WRITE`, `USER_MANAGEMENT_WRITE`, `LEGAL_HOLD_WRITE`, or `SCIM`. No tool here writes anything, and a key that can write is a key that can be made to write.
If your organization has OAuth2 enabled, prefer a client-credentials service account over a long-lived API key: tokens expire in 1800 seconds and requests execute as the bound service account for audit purposes. OAuth2 is disabled in FedRAMP environments, where the API key is the only option.
### `EVERLAW_BASE_URL` (optional, defaults to `https://api.everlaw.com/v1`)
Everlaw runs regional stacks with separate API hosts. UK tenants use `https://api.everlaw.co.uk/v1`. A key issued in one region does not authenticate against another region's host — the symptom is a 401 on every call including `/status`.
### `EVERLAW_ALLOWED_PROJECTS` (strongly recommended)
Comma-separated numeric project ids the agent may touch, e.g. `2,17,204`. Left empty, every project the key can reach is in scope. An organization API key can read across matters governed by different protective orders; the allowlist is what keeps a question about one matter from returning data about another. Find project ids via the hosted server's `GetProjects` tool or in the project URL.
### `EVERLAW_ENABLE_USER_LOOKUP` (optional, defaults to `false`)
Turns on `resolve_assignee_names`. Off by default because `GetProjectUsers` sits in the `USER_MANAGEMENT_READ` scope group, which also covers groups, permissions and invitations — a wider grant than the `REVIEW_READ` every other tool needs. Enable it only if reviewer names, rather than ids, genuinely need to appear in chat transcripts.
## Register with Claude
Claude Desktop — `claude_desktop_config.json`:
```json
{
"mcpServers": {
"everlaw-ediscovery": {
"command": "/absolute/path/to/.venv/bin/python",
"args": ["-m", "everlaw_ediscovery_mcp.server"],
"env": {
"EVERLAW_API_KEY": "everlaw-api.XXXX.YYYYYYYY",
"EVERLAW_BASE_URL": "https://api.everlaw.com/v1",
"EVERLAW_ALLOWED_PROJECTS": "2,17"
}
}
}
}
```
Claude Code — from the repo root:
```bash
claude mcp add everlaw-ediscovery -- /absolute/path/to/.venv/bin/python -m everlaw_ediscovery_mcp.server
```
To connect Everlaw's hosted server alongside it, add its resource URL as a remote MCP server; it advertises RFC 9728 protected-resource metadata, so a standards-compliant client completes the OAuth flow with no manual setup beyond the URL:
```bash
claude mcp add --transport http everlaw-hosted https://api.everlaw.com/v1/mcp
```
## First-run verification
Run these four in order. Each one proves a different failure mode is absent, and each has an answer you can confirm in the Everlaw UI. Do not skip to real questions until all four pass.
1. **Credential and region.** Ask Claude to list assignment groups for a project id you know exists. A 401 means the key or the region host is wrong. A 403 means the key is valid but lacks `GetProjectAssignmentGroups` or read access to that project — Everlaw returns the same 403 for "no such project," so check the permission before you doubt the id.
2. **Allowlist.** Ask for a project id you deliberately left out of `EVERLAW_ALLOWED_PROJECTS`. You should get an allowlist error from this server, with no HTTP request made. If real data comes back, the variable is not being read — check for a typo in the config `env` block.
3. **Coding schema.** Ask for the coding schema. Compare category and code names against the project's coding panel in the Everlaw UI. Mismatched or missing categories mean the key is reading a different project than you think.
4. **Progress arithmetic.** Ask for review progress on one assignment group by id. Open that group in Everlaw and compare the reviewed count. If they disagree, the group's review criteria are not what you assumed — that is the tool reporting correctly, not a bug, but confirm it before quoting the number to anyone.
After step 4, open the project's search history in Everlaw. You should see two new saved searches per group you queried. That is expected and is the cost of the tool; see below.
## What this server does not do
- **No document retrieval and no document text.** The hosted server's `GetProjectSearchResult` does that under the signed-in user's own permissions, which is the right place for it. An organization API key is a blunter instrument and should not be pointed at document content.
- **No per-document coding decisions.** `list_codes` returns the coding schema — which categories and codes exist — not how any document was coded. A reviewer's coding calls on specific documents are work product.
- **No audit log and no user analytics.** `GetProjectAnalytics` and the project event endpoints sit in the `SECURITY_READ` scope group and require org-admin access. Reviewer-by-reviewer activity data raises supervision questions this tool should not answer by accident.
- **No writes of any kind.** Nothing here codes a document, creates an assignment, or modifies a project.
## Known limits — resolve before production use
1. **Nothing here has been run against a live tenant.** Verify each tool's output against the Everlaw UI before anyone relies on a number from it.
2. **`review_progress` creates saved searches.** Two per assignment group per call, each visible in the project's search history with a `https://app.everlaw.com/<searchId>` URL. Everlaw caps the number of user-visible objects the API may create and returns 422 when you hit it. Decide with your review-team lead whether that history noise is acceptable before wiring this to anything scheduled, and never put this tool behind a polling loop.
3. **"Reviewed" is a per-group setting.** Each assignment group defines its own review criteria; the percentage this server returns follows that definition and is not a synonym for "coded" or "QC-complete." Two groups on the same project can mean different things by the same number.
4. **Rate limiting is coarse here.** The client paces itself at 8 requests/second against Everlaw's documented 25 rps per authenticating user account, with a four-way concurrency gate and exponential backoff on 429. That budget is shared with anything else using the same key. If you run this alongside a nightly export job on the same credential, give the export its own key.
5. **Pagination is single-page.** Every list tool sends `limit` and reads the first page; it does not follow the `links.next` cursor. A project with more than 200 assignment groups will silently under-report. Add cursor-following before using this on a project that large.
6. **No caching.** Every question re-queries. The coding schema and assignment-group list change rarely; a short-lived cache would cut most of the traffic, and is the first thing to add if you hit rate limits.
7. **Region and FedRAMP.** OAuth2 is disabled in FedRAMP environments, so those tenants must use an API key. Confirm which stack your matters live on before provisioning credentials.
"""everlaw-ediscovery-mcp — read-only MCP server over the Everlaw REST API.
Complements Everlaw's own hosted MCP server (https://api.everlaw.com/v1/mcp) by
exposing the review-management identifiers that server's search terms require but
none of its tools return.
"""
__version__ = "0.1.0"
"""
everlaw-ediscovery-mcp — a read-only MCP server over the Everlaw REST API.
Exposes the review-management surface Everlaw's own hosted MCP server does not:
assignment groups and their assignments, the project coding schema (categories and
codes), search term reports, and a computed reviewed/not-reviewed progress rollup
per assignment group. Optionally resolves assignee IDs to names.
WHY THIS EXISTS ALONGSIDE THE OFFICIAL SERVER
---------------------------------------------
Everlaw ships a hosted MCP server at https://api.everlaw.com/v1/mcp (server name
everlaw-mcp, version 0.1.0, protocol revision 2025-11-25). It registers eight tools:
GetProjects, GetProjectBinders, GetProjectMetadataFields, GetProjectProcessedUploads,
GetProjectDatasets, PostProjectSearch, GetProjectSearchResult, DescribeProjectSearchTerm.
Two of the search terms PostProjectSearch accepts cannot be constructed from those
eight tools alone:
ASSIGNED needs assignmentGroup.id / assignmentId / userId
CODED needs labelId (a category or code id) and optionally userId / groupId
Everlaw's own documentation for those terms points the reader at
GetProjectAssignmentGroups, GetProjectCodes, GetProjectUsers and GetProjectGroups.
Those are REST operations, not tools on the hosted server. An agent connected only
to the hosted server can search full text and Bates ranges, but cannot answer
"how far through the second-level privilege batch is the review team?" because it
has no way to learn the batch exists or what its id is.
This scaffold fills exactly that gap and nothing else. It deliberately does NOT
register a general search tool, a document-fetch tool, or a document-text tool —
the hosted server already does those, under the signed-in user's own permissions,
which is the safer place for them.
STATUS: scaffold — not runtime-tested. Endpoint paths, scope groups, permission
names, and response shapes track the published Everlaw OpenAPI specification
(https://api.everlaw.com/docs/everlaw-openapi.yaml) as of 2026-08. Project ids,
category names, and assignment-group names are tenant-specific; verify against your
own project before relying on any of it.
Run as: python -m everlaw_ediscovery_mcp.server
"""
from __future__ import annotations
import asyncio
import json
import os
from typing import Any
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
# ----- Configuration (read from env at startup) -----
EVERLAW_API_KEY = os.environ.get("EVERLAW_API_KEY")
# Region matters. Everlaw runs separate stacks and separate API hosts per region;
# a US key does not authenticate against the UK host. Set this to the host that
# matches the tenant your projects live in.
EVERLAW_BASE_URL = os.environ.get("EVERLAW_BASE_URL", "https://api.everlaw.com/v1").rstrip("/")
# Projects the agent may touch at all, as a comma-separated list of numeric ids.
# An Everlaw organization routinely holds matters under protective orders with
# different data-handling terms, and an org-admin key can read all of them. Leaving
# this empty means "every project the key can reach", which you should not ship.
EVERLAW_ALLOWED_PROJECTS = {
s.strip() for s in os.environ.get("EVERLAW_ALLOWED_PROJECTS", "").split(",") if s.strip()
}
# Assignee-name resolution is off by default. Every other tool here needs only the
# REVIEW_READ scope group; GetProjectUsers needs USER_MANAGEMENT_READ, which also
# covers groups, permissions, and invitations across the project. That is a wider
# grant than "show me review progress" justifies, so it is opt-in.
EVERLAW_ENABLE_USER_LOOKUP = os.environ.get("EVERLAW_ENABLE_USER_LOOKUP", "false").lower() == "true"
# Per-call page ceiling. The Everlaw API caps list endpoints at 200 records; 50 is
# enough for a chat answer and keeps a mis-scoped question from pulling a thousand
# assignment rows into the context window.
MAX_LIMIT = 200
DEFAULT_LIMIT = 50
# Everlaw enforces a fixed 25 requests/second per authenticating user account and
# returns 429 above it. review_progress issues 2 searches per group, so a 30-group
# project is 61 requests; without pacing that trips the limit inside three seconds.
REQUESTS_PER_SECOND = 8
_rate_gate = asyncio.Semaphore(4)
def require_config() -> None:
if not EVERLAW_API_KEY:
raise RuntimeError("EVERLAW_API_KEY env var is required")
def auth_headers() -> dict[str, str]:
# Everlaw accepts the organization API key as a bearer token. Keys look like
# everlaw-api.XXXX.YYYYYYYY and carry per-endpoint permissions granted on the
# API keys tab of the Organizations page.
return {
"Authorization": f"Bearer {EVERLAW_API_KEY}",
"Accept": "application/json",
}
def check_project(project_id: int) -> None:
if EVERLAW_ALLOWED_PROJECTS and str(project_id) not in EVERLAW_ALLOWED_PROJECTS:
raise PermissionError(
f"project {project_id} is not in EVERLAW_ALLOWED_PROJECTS. "
"Add it there deliberately if this matter is in scope for agent access."
)
def clamp(limit: int | None) -> int:
if limit is None:
return DEFAULT_LIMIT
return max(1, min(int(limit), MAX_LIMIT))
async def api_get(client: httpx.AsyncClient, path: str, params: dict[str, Any] | None = None) -> Any:
"""GET an Everlaw endpoint, unwrapping the {"data": ...} envelope.
Retries 429 with exponential backoff. Everlaw documents 429 both for the
account-wide 25 rps ceiling and for stricter per-endpoint limits, and both are
transient, so a bounded retry is correct where a 403 is not.
"""
delay = 1.0
for attempt in range(4):
async with _rate_gate:
resp = await client.get(path, params=params, headers=auth_headers(), timeout=60.0)
await asyncio.sleep(1.0 / REQUESTS_PER_SECOND)
if resp.status_code == 429 and attempt < 3:
await asyncio.sleep(delay)
delay *= 2
continue
break
raise_for_everlaw(resp)
return resp.json().get("data")
async def api_post(client: httpx.AsyncClient, path: str, body: dict[str, Any]) -> Any:
delay = 1.0
for attempt in range(4):
async with _rate_gate:
resp = await client.post(path, json=body, headers=auth_headers(), timeout=60.0)
await asyncio.sleep(1.0 / REQUESTS_PER_SECOND)
if resp.status_code == 429 and attempt < 3:
await asyncio.sleep(delay)
delay *= 2
continue
break
raise_for_everlaw(resp)
return resp.json().get("data")
def raise_for_everlaw(resp: httpx.Response) -> None:
"""Translate Everlaw's error envelope into a message an agent can act on.
Everlaw returns {"title": ..., "status": ...} and deliberately returns the same
403 whether a project does not exist or the caller cannot see it, so that project
ids cannot be enumerated. The message below says so, otherwise a model retries a
valid-but-forbidden project id as though it had fat-fingered the number.
"""
if resp.status_code < 400:
return
try:
title = resp.json().get("title", resp.text[:200])
except Exception:
title = resp.text[:200]
if resp.status_code == 403:
raise PermissionError(
f"{title} (403). Everlaw returns this identically for 'project does not exist' "
"and 'key lacks access', so do not retry with a different id — confirm the "
"project id and the key's permissions instead."
)
if resp.status_code == 422:
raise RuntimeError(
f"{title} (422). Everlaw caps the number of user-visible objects the API may "
"create, including saved searches. review_progress creates one search per "
"call; wait for the cap to clear or ask Everlaw Support to raise it."
)
raise RuntimeError(f"Everlaw API error {resp.status_code}: {title}")
# ----- Tool implementations -----
async def list_assignment_groups(client: httpx.AsyncClient, project_id: int, limit: int | None) -> Any:
"""GET /projects/{projectId}/assignmentGroups — REVIEW_READ."""
check_project(project_id)
data = await api_get(
client, f"/projects/{project_id}/assignmentGroups", {"limit": clamp(limit)}
)
groups = []
for g in data or []:
assignments = g.get("assignments") or []
groups.append(
{
"id": g.get("id"),
"name": g.get("name"),
"created": g.get("created"),
"assignment_count": len(assignments),
# Assignee ids only. Names require USER_MANAGEMENT_READ, which this
# server does not hold unless EVERLAW_ENABLE_USER_LOOKUP is set.
"assignee_ids": sorted({a.get("assigneeId") for a in assignments if a.get("assigneeId")}),
}
)
return {
"project_id": project_id,
"assignment_groups": groups,
"_note": (
"Use an id here as assignmentGroup.id in an ASSIGNED search term, either "
"through review_progress or through the official Everlaw MCP server's "
"PostProjectSearch tool."
),
}
async def list_codes(client: httpx.AsyncClient, project_id: int) -> Any:
"""GET /projects/{projectId}/codes — REVIEW_READ."""
check_project(project_id)
data = await api_get(client, f"/projects/{project_id}/codes")
categories = [
{
"id": c.get("id"),
"name": c.get("name"),
"mutually_exclusive": c.get("mutuallyExclusive"),
"codes": [{"id": k.get("id"), "name": k.get("name")} for k in (c.get("codes") or [])],
}
for c in (data or [])
]
return {
"project_id": project_id,
"categories": categories,
"_note": (
"A CODED search term takes labelId, which accepts either a category id or "
"a code id. Category id matches any code in that category."
),
}
async def review_progress(
client: httpx.AsyncClient, project_id: int, assignment_group_id: int | None
) -> Any:
"""Reviewed / not-reviewed counts per assignment group.
Everlaw has no progress endpoint. The counts come from running the ASSIGNED
search term twice per group with reviewStatus REVIEWED and NOT_REVIEWED and
reading numDocs off each response.
The engineering choice worth naming: this aggregates at ALL_IN_GROUP level —
two searches per group — rather than per assignment. Per-assignment detail would
be two searches per assignee, and every PostProjectSearch call materializes a
saved search object that is visible in the project's search history and counts
against Everlaw's cap on API-created user-visible objects (422). A 12-assignee
group costs 2 searches here and 24 the other way, for a number nobody asked for.
"""
check_project(project_id)
groups = await api_get(client, f"/projects/{project_id}/assignmentGroups", {"limit": MAX_LIMIT})
targets = [g for g in (groups or []) if assignment_group_id in (None, g.get("id"))]
if assignment_group_id is not None and not targets:
raise ValueError(f"assignment group {assignment_group_id} not found on project {project_id}")
rows = []
for g in targets:
counts = {}
for status in ("REVIEWED", "NOT_REVIEWED"):
body = {
"term": "ASSIGNED",
"query": {
"assignmentGroup": {
"id": g.get("id"),
"criteria": "ALL_IN_GROUP",
"reviewStatus": status,
}
},
}
result = await api_post(client, f"/projects/{project_id}/search", body)
counts[status] = (result or {}).get("numDocs", 0)
total = counts["REVIEWED"] + counts["NOT_REVIEWED"]
rows.append(
{
"assignment_group_id": g.get("id"),
"name": g.get("name"),
"reviewed": counts["REVIEWED"],
"not_reviewed": counts["NOT_REVIEWED"],
"total": total,
"percent_reviewed": round(100.0 * counts["REVIEWED"] / total, 1) if total else None,
}
)
return {
"project_id": project_id,
"groups": rows,
"_note": (
"'Reviewed' means reviewed per the assignment group's own review criteria, "
"which the group's creator configured — it is not a synonym for 'coded'. "
"Each row cost two saved searches, now visible in the project's search history."
),
}
async def list_search_term_reports(client: httpx.AsyncClient, project_id: int, limit: int | None) -> Any:
"""GET /projects/{projectId}/searchTermReports — REVIEW_READ."""
check_project(project_id)
data = await api_get(
client, f"/projects/{project_id}/searchTermReports", {"limit": clamp(limit)}
)
return {
"project_id": project_id,
"search_term_reports": [
{
"id": r.get("id"),
"name": r.get("name"),
"num_searches": r.get("numSearches"),
"last_updated": r.get("lastUpdated"),
"user_id": r.get("userId"),
}
for r in (data or [])
],
"_note": (
"Names and counts only. Term hit counts are not returned here; a term list "
"negotiated with opposing counsel can itself be work product."
),
}
async def resolve_assignee_names(client: httpx.AsyncClient, project_id: int, limit: int | None) -> Any:
"""GET /projects/{projectId}/users — USER_MANAGEMENT_READ. Opt-in."""
if not EVERLAW_ENABLE_USER_LOOKUP:
raise PermissionError(
"assignee-name lookup is disabled. It needs the USER_MANAGEMENT_READ scope "
"group, which is broader than the REVIEW_READ the other tools use. Set "
"EVERLAW_ENABLE_USER_LOOKUP=true and grant the GetProjectUsers permission "
"only if reviewer names are genuinely needed in chat."
)
check_project(project_id)
data = await api_get(client, f"/projects/{project_id}/users", {"limit": clamp(limit)})
return {
"project_id": project_id,
# Names and ids only — the endpoint also returns per-user permission sets to
# project administrators, which is access-control configuration and does not
# belong in a chat transcript.
"users": [
{"id": u.get("id"), "name": u.get("name"), "email": u.get("email")}
for u in (data or [])
],
}
# ----- MCP wiring -----
server = Server("everlaw-ediscovery")
PROJECT_ID_SCHEMA = {
"type": "integer",
"description": "Everlaw project id. Get it from the official Everlaw MCP server's GetProjects tool, or from the project URL.",
}
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="list_assignment_groups",
description=(
"List review assignment groups on an Everlaw project with assignment counts "
"and assignee ids. Read-only. Use this to get the assignmentGroup.id that an "
"ASSIGNED search term requires — the official Everlaw MCP server has no tool "
"that returns it."
),
inputSchema={
"type": "object",
"properties": {
"project_id": PROJECT_ID_SCHEMA,
"limit": {"type": "integer", "description": f"Max groups, 1-{MAX_LIMIT}. Default {DEFAULT_LIMIT}."},
},
"required": ["project_id"],
},
),
Tool(
name="list_codes",
description=(
"List the coding schema of an Everlaw project: categories, their codes, and "
"whether each category is mutually exclusive. Read-only. Returns the labelId "
"values a CODED search term requires. Returns the schema, not any document's "
"coding decisions."
),
inputSchema={
"type": "object",
"properties": {"project_id": PROJECT_ID_SCHEMA},
"required": ["project_id"],
},
),
Tool(
name="review_progress",
description=(
"Reviewed / not-reviewed document counts and percent complete for one or all "
"assignment groups on an Everlaw project. Read-only, but each group costs two "
"saved searches that appear in the project's search history."
),
inputSchema={
"type": "object",
"properties": {
"project_id": PROJECT_ID_SCHEMA,
"assignment_group_id": {
"type": "integer",
"description": "Restrict to one group. Omit for every group on the project.",
},
},
"required": ["project_id"],
},
),
Tool(
name="list_search_term_reports",
description=(
"List search term reports on an Everlaw project by name, owner, term count, "
"and last-updated date. Read-only. Does not return the terms themselves or "
"their hit counts."
),
inputSchema={
"type": "object",
"properties": {
"project_id": PROJECT_ID_SCHEMA,
"limit": {"type": "integer", "description": f"Max reports, 1-{MAX_LIMIT}. Default {DEFAULT_LIMIT}."},
},
"required": ["project_id"],
},
),
Tool(
name="resolve_assignee_names",
description=(
"Map Everlaw user ids to reviewer names on a project. Read-only and disabled "
"unless EVERLAW_ENABLE_USER_LOOKUP=true, because it needs a wider scope group "
"than every other tool here."
),
inputSchema={
"type": "object",
"properties": {
"project_id": PROJECT_ID_SCHEMA,
"limit": {"type": "integer", "description": f"Max users, 1-{MAX_LIMIT}. Default {DEFAULT_LIMIT}."},
},
"required": ["project_id"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
require_config()
async with httpx.AsyncClient(base_url=EVERLAW_BASE_URL) as client:
try:
if name == "list_assignment_groups":
result = await list_assignment_groups(
client, int(arguments["project_id"]), arguments.get("limit")
)
elif name == "list_codes":
result = await list_codes(client, int(arguments["project_id"]))
elif name == "review_progress":
gid = arguments.get("assignment_group_id")
result = await review_progress(
client, int(arguments["project_id"]), int(gid) if gid is not None else None
)
elif name == "list_search_term_reports":
result = await list_search_term_reports(
client, int(arguments["project_id"]), arguments.get("limit")
)
elif name == "resolve_assignee_names":
result = await resolve_assignee_names(
client, int(arguments["project_id"]), arguments.get("limit")
)
else:
raise ValueError(f"unknown tool: {name}")
except (PermissionError, ValueError, RuntimeError) as exc:
# Surface as a tool error, not an exception. The agent needs to read the
# reason — "not in the allowlist" and "key lacks the permission" call for
# different fixes and neither is solved by retrying.
return [TextContent(type="text", text=json.dumps({"error": str(exc)}, indent=2))]
return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
async def main() -> None:
require_config()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())