経済的な論拠は具体的です。8〜12 件の案件を同時に管理する e-discovery チームは、毎週の相当な時間を Relativity からステータス数値を取得することに費やします。ワークスペースのダッシュボードを開き、案件ビューを切り替え、review set の進捗を確認し、次の production のために保存済み検索の定義を確認する、といった作業です。この規模のフルタイムの legal-ops マネージャーが費やす時間の保守的な見積もり(管理された調査ではなく作業の構造に基づく)は 1 日 45 分です。これらの検索を 30 秒の Claude との会話に圧縮すると、運用上のオーバーヘッドは 1 日 5 分未満になります。回収された時間は、実質的な調整作業に充てられます。外部弁護士へのレビュー進捗の報告、review set の割り当て調整、production スケジュールの準備などです。
このサーバーは新メンバーのオンボーディングにも役立ちます。新しいメンバーが案件チームに加わったとき、Claude にワークスペースの保存済み検索と review set の一覧を求める方が、Relativity の UI のウォークスルーをスケジュールするよりも速いです。案件のメタデータの全体像—ワークスペースが何件あるか、どのような review set が存在するか、主要な検索は何か—が Claude の会話ですぐにアクセスできるようになります。
デプロイが、ケースデータの送信または処理方法を制限する保護命令の対象となっている場合はスキップしてください。Relativity のワークスペース名、案件の割り当て、review set 名自体が守秘義務の対象となる場合があります。これらのメタデータを Claude セッションを通じて送信する前に、担当弁護士に対して、デプロイが適用される保護命令および会社またはクライアントが採用しているデータ処理プロトコルと整合していることを確認してください。
Relativity インスタンスが Relativity Server 2022 より古い場合、またはテナントが REST API のベースパスを検証していない場合はスキップしてください。scaffold は RelativityOne(2024〜2025)向けに文書化された REST API の規則をターゲットにしています。古いオンプレミスバージョンのパスは異なります。検証されていないベースパスに対して実行すると、404 エラーまたは不正な形式のレスポンスが生成され、Claude 内では「データが見つかりません」として表示されます。これは MCP を介した法的ツールへの信頼を損なう典型的な障害モードです。最初の使用前に Relativity 管理者とパスを確認してください。
e-discovery メタデータへの Claude アクセスをカバーする AI ポリシーがまだない場合はスキップしてください。ポリシーは次の点を扱う必要があります:どの案件が対象か、誰が MCP 経由でクエリできるか、Claude セッションはどのように記録されるか。まずポリシーを確立してください。
Claude のサブスクリプション。 MCP が有効な Claude Desktop または Claude Code。Pro($20/ユーザー/月)または Team($25〜30/ユーザー/月)は、ほとんどの legal-ops チームのセットアップをカバーします。10 件のアクティブな案件にわたって週に 200 件の運用メタデータクエリを実行するチームはこれらのプランで十分です。非常に大量のデプロイ(例えば、毎時の自動案件ステータスレポート)は Max を正当化する場合があります。
# mcp-server-relativity-ediscovery
An MCP server for legal-ops and e-discovery teams using RelativityOne or Relativity Server. Exposes workspace listings, review-set metadata, and saved-search summaries as Claude tools — read-only by design. The server surfaces case-level and review-set-level metadata that attorneys and legal-ops managers query repeatedly through the Relativity UI, without exposing document text or review coding decisions.
> **STATUS: scaffold — not runtime-tested.** The code below is structurally
> complete and follows the official `mcp` Python SDK conventions, but it
> has not been executed against a live Relativity tenant. The Relativity
> REST API surface (paths, response shapes, ArtifactTypeIDs for custom
> RDOs) varies by instance version, tenant tier, and installed
> applications. Treat this as a starting point you adapt to your
> environment before any production use. Consult your Relativity
> administrator and, for any legal-data posture decision, qualified counsel.
## What it exposes
### Workspace tools (read-only)
- `list_workspaces` — paginated list of accessible workspaces with name, ArtifactID, status, matter, and document count
- `get_workspace_summary` — single-workspace metadata: document count, file size, active users, creation date
### Review-set tools (read-only)
- `get_review_set_metadata` — review-set object metadata: name, document count, status, coding decisions summary, reviewer assignments (NOT document content or coding values per document)
### Saved-search tools (read-only)
- `get_saved_search_summary` — saved-search definition metadata: name, conditions, fields, owner (NOT the document results of running the search)
- `list_saved_searches` — paginated list of saved searches in a workspace with names, owners, and modification dates
The server **does not** expose document text, review coding values per document, production sets, audit logs, or any write operations. The design principle: Claude can navigate the case structure and surface operational metadata; attorneys drive every substantive review decision.
## Setup
### 1. Install
```bash
git clone <wherever you put this>
cd mcp-server-relativity-ediscovery
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e .
```
### 2. Obtain API credentials from Relativity
#### Option A — OAuth2 client credentials (recommended for production)
In Relativity: Home → Authentication → OAuth2 Client Manager → New.
- **Name:** anything descriptive (e.g. `mcp-ediscovery-server`)
- **Flow Grant Type:** Client Credentials
- **Context User:** a service account in the Relativity Administrators group (or a user with read access to all intended workspaces)
Save. Copy the **Client ID** and **Client Secret**. Set `RELATIVITY_AUTH_MODE=oauth2` (see §3 below).
The token endpoint follows the pattern: `https://<your-tenant>.relativity.one/Relativity/Identity/connect/token`
The access token is short-lived (~1 hour). The server's `auth_headers()` helper fetches a fresh token on each server start; add OAuth2 token refresh (TODO #2 below) before long-running deployments.
#### Option B — Basic authentication (development / short-lived use)
Use an existing Relativity username and password. The credentials are Base64-encoded and sent as an HTTP Basic header. This is convenient for local development but unsuitable for shared or long-lived deployments because the credentials cannot be revoked granularly and because rotating a user password disrupts all sessions sharing the credential.
Set `RELATIVITY_AUTH_MODE=basic` (see §3 below).
### 3. Configure environment
```bash
# Required for all auth modes
export RELATIVITY_HOST="https://your-tenant.relativity.one" # no trailing slash
export RELATIVITY_AUTH_MODE="oauth2" # or "basic"
# Required for oauth2 mode
export RELATIVITY_CLIENT_ID="your-oauth2-client-id"
export RELATIVITY_CLIENT_SECRET="your-oauth2-client-secret"
# Required for basic mode
export RELATIVITY_USERNAME="serviceaccount@yourfirm.com"
export RELATIVITY_PASSWORD="your-password"
# Optional tuning
export RELATIVITY_DEFAULT_WORKSPACE_ID="" # if set, skips workspace picker for single-matter deployments
export RELATIVITY_PAGE_SIZE="50" # default page size for list_workspaces, list_saved_searches
```
#### `RELATIVITY_HOST`
The base URL of your RelativityOne tenant or on-prem Relativity Server. No trailing slash. For RelativityOne cloud this is typically `https://<tenant>.relativity.one`; for on-prem it is the hostname your Relativity Server was installed on.
#### `RELATIVITY_AUTH_MODE`
`oauth2` (recommended) or `basic`. Controls how `auth_headers()` constructs the Authorization header. In `oauth2` mode the server POSTs to the Identity token endpoint at startup and uses the returned Bearer token. In `basic` mode it Base64-encodes `RELATIVITY_USERNAME:RELATIVITY_PASSWORD`.
#### `RELATIVITY_CLIENT_ID` / `RELATIVITY_CLIENT_SECRET`
OAuth2 client credentials from the OAuth2 Client Manager. Required when `RELATIVITY_AUTH_MODE=oauth2`.
#### `RELATIVITY_USERNAME` / `RELATIVITY_PASSWORD`
Relativity login credentials. Required when `RELATIVITY_AUTH_MODE=basic`. The account must have read access to all workspaces you intend to surface. Provision a service account; do not use a named attorney's credentials.
#### `RELATIVITY_DEFAULT_WORKSPACE_ID`
If set, `list_saved_searches` and `get_review_set_metadata` default to this workspace's ArtifactID when no `workspace_id` argument is passed. Useful for single-matter deployments.
#### `RELATIVITY_PAGE_SIZE`
Default page size for paginated list operations. Default: 50. Reduce if your Relativity instance rate-limits aggressively on large result sets.
### 4. Register with Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"relativity-ediscovery": {
"command": "python",
"args": ["-m", "relativity_ediscovery_mcp.server"],
"env": {
"RELATIVITY_HOST": "https://your-tenant.relativity.one",
"RELATIVITY_AUTH_MODE": "oauth2",
"RELATIVITY_CLIENT_ID": "your-client-id",
"RELATIVITY_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
Restart Claude Desktop. You should see 5 tools registered under `relativity-ediscovery`.
### 5. Sanity check
Ask Claude: "List the workspaces I have access to in Relativity." Confirm you receive a list of workspace names with ArtifactIDs and that no error appears. Then ask: "Give me the saved searches in workspace {ArtifactID}." Confirm the response contains search names and owners only — no document text or coding values should appear. If the response includes document body content, something in your Relativity configuration is surfacing document data through the metadata endpoints; stop and investigate before sharing the server beyond the engineer who set it up.
## Security model
**Read-only.** The server registers zero write operations. No documents are modified, no coding decisions are recorded, no productions are triggered. The blast radius of a compromised credential is limited to read access over whatever workspaces the service account can reach.
**Metadata only, not document content.** `get_review_set_metadata` returns review-set-level counts and status fields, not per-document review values. `get_saved_search_summary` returns the search definition (conditions, fields list, owner), not the search results (the documents it returns). This posture matters because a saved search's existence and conditions can themselves be attorney work product — the query string is never logged (see `log_invocation()` in `server.py`).
**Service account scoping.** The credentials you provision see every workspace accessible to the Context User (OAuth2) or the login account (Basic). Scope the service account to only the workspaces the MCP deployment is intended to cover. In Relativity this means adding the service account to workspace groups with read-only permissions and explicitly removing it from workspaces it should not reach.
**Credential rotation.** For OAuth2 deployments, the client secret can be rotated in the OAuth2 Client Manager without changing the service account. Rotate on any personnel change. For Basic auth deployments, the password rotation also affects any other system using the same account — a strong reason to prefer OAuth2 for shared environments.
**This server touches data that may be subject to litigation hold, privilege assertions, and protective orders.** Consult qualified counsel before deploying in any active matter to confirm the deployment is consistent with your firm's or client's data-handling protocols and any governing protective orders.
## Known limits and TODOs (before production use)
1. Validate all API base paths against your specific Relativity version. The path prefixes (`Relativity.Rest/api/relativity-environment/v1/workspace/` for the Workspace Manager; `Relativity.Rest/api/Relativity.ObjectManager/v1/workspace/{id}/object/` for the Object Manager) follow the RelativityOne REST API conventions as of 2024–2025, but on-prem Relativity Server versions may differ. Confirm against your Relativity administrator's API documentation before first use.
2. Implement OAuth2 token refresh. The current scaffold fetches a token once at startup. Tokens expire (~1 hour). For deployments longer than one session, add a refresh loop that re-fetches the token when a 401 is returned.
3. Add request-level retries with exponential backoff. Relativity rate-limits REST clients; burst queries (e.g. listing all saved searches across dozens of workspaces) will hit 429s without backoff.
4. Pin the review-set ArtifactTypeID for your tenant. `get_review_set_metadata` queries for objects of the `Relativity.ReviewSet` type. The numeric ArtifactTypeID for this object type is tenant-specific (it is assigned when the Review application is installed). The scaffold uses a name-based lookup at startup to resolve the ID; validate this lookup resolves correctly in your environment before relying on it.
5. Wire structured logging via `python-json-logger` and route to your matter audit trail. The current scaffold logs to stderr only.
6. Add Sentry or OpenTelemetry export — but scrub query strings, search conditions, and workspace names before transmission if those are privileged under your matter's protective order.
7. Write integration tests against a Relativity sandbox workspace before any production matter deployment.
"""
relativity-ediscovery-mcp — MCP server for Relativity / RelativityOne e-discovery metadata.
Exposes workspace listings, review-set metadata, and saved-search summaries
as Claude tools. Read-only by design — no document content, no coding values
per document, no write operations.
STATUS: scaffold — not runtime-tested. Validate all base paths, ArtifactTypeIDs,
and response field names against your Relativity instance before use. On-prem
Relativity Server versions may use different base paths than RelativityOne cloud.
Run as: python -m relativity_ediscovery_mcp.server
"""
from __future__ import annotations
import base64
import logging
import os
from datetime import datetime, timezone
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) -----
RELATIVITY_HOST = os.environ.get("RELATIVITY_HOST", "").rstrip("/")
AUTH_MODE = os.environ.get("RELATIVITY_AUTH_MODE", "oauth2").lower()
CLIENT_ID = os.environ.get("RELATIVITY_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("RELATIVITY_CLIENT_SECRET", "")
USERNAME = os.environ.get("RELATIVITY_USERNAME", "")
PASSWORD = os.environ.get("RELATIVITY_PASSWORD", "")
DEFAULT_WORKSPACE_ID = os.environ.get("RELATIVITY_DEFAULT_WORKSPACE_ID", "")
PAGE_SIZE = int(os.environ.get("RELATIVITY_PAGE_SIZE", "50"))
# Relativity REST API base prefixes (RelativityOne cloud, 2024-2025 conventions).
# Validate these against your specific instance version — on-prem paths may differ.
WS_MANAGER_BASE = "/Relativity.Rest/api/relativity-environment/v1/workspace"
OBJ_MANAGER_BASE = "/Relativity.Rest/api/Relativity.ObjectManager/v1/workspace"
SEARCH_MANAGER_BASE = (
"/Relativity.Rest/api/Relativity.Services.Search.ISearchModule"
"/Keyword%20Search%20Manager"
)
# ArtifactTypeID for Document objects in Relativity (fixed across all instances).
DOC_ARTIFACT_TYPE_ID = 10
# Cache the OAuth2 token and the resolved review-set ArtifactTypeID.
_bearer_token: str | None = None
_review_set_artifact_type_id: int | None = None
# Privilege-aware audit logger: NEVER log query strings, search conditions,
# workspace names, or document counts in a way that could reveal legal strategy.
# Only log tool name, timestamp, and result count.
audit_log = logging.getLogger("relativity_ediscovery_mcp.audit")
logging.basicConfig(level=logging.INFO)
def require_config() -> None:
if not RELATIVITY_HOST:
raise RuntimeError("RELATIVITY_HOST env var is required")
if AUTH_MODE == "oauth2" and not (CLIENT_ID and CLIENT_SECRET):
raise RuntimeError(
"RELATIVITY_CLIENT_ID and RELATIVITY_CLIENT_SECRET are required "
"when RELATIVITY_AUTH_MODE=oauth2"
)
if AUTH_MODE == "basic" and not (USERNAME and PASSWORD):
raise RuntimeError(
"RELATIVITY_USERNAME and RELATIVITY_PASSWORD are required "
"when RELATIVITY_AUTH_MODE=basic"
)
def log_invocation(tool: str, result_count: int | None = None) -> None:
"""Metadata-only audit record. Never includes query strings or workspace names."""
audit_log.info(
"tool=%s ts=%s results=%s",
tool,
datetime.now(timezone.utc).isoformat(),
result_count if result_count is not None else "n/a",
)
async def fetch_oauth2_token() -> str:
"""Fetch a Bearer token from the Relativity Identity endpoint.
Tokens expire in ~1 hour. This scaffold fetches once at startup; add
a refresh loop (TODO #2 in README) for long-running deployments.
"""
token_url = f"{RELATIVITY_HOST}/Relativity/Identity/connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "SystemUserInfo",
},
)
r.raise_for_status()
return r.json()["access_token"]
async def auth_headers() -> dict[str, str]:
"""Return authentication headers for the configured auth mode.
X-CSRF-Header is required by all Relativity REST endpoints regardless of
auth method. Per the Relativity docs, any non-empty value works; '-' is
the conventional placeholder.
"""
global _bearer_token
base = {
"Content-Type": "application/json",
"X-CSRF-Header": "-",
}
if AUTH_MODE == "oauth2":
if _bearer_token is None:
_bearer_token = await fetch_oauth2_token()
base["Authorization"] = f"Bearer {_bearer_token}"
else:
encoded = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode()
base["Authorization"] = f"Basic {encoded}"
return base
async def rel_get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""HTTP GET against the Relativity REST API."""
headers = await auth_headers()
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(
f"{RELATIVITY_HOST}{path}",
headers=headers,
params=params,
)
r.raise_for_status()
return r.json()
async def rel_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
"""HTTP POST against the Relativity REST API."""
headers = await auth_headers()
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
f"{RELATIVITY_HOST}{path}",
headers=headers,
json=body,
)
r.raise_for_status()
return r.json()
async def resolve_review_set_artifact_type_id(workspace_id: int) -> int:
"""Resolve the ArtifactTypeID for the Relativity Review Set object type.
Review Sets are Relativity Dynamic Objects (RDOs) installed by the Review
application. Their ArtifactTypeID is assigned when the application is
installed and is not fixed across instances (unlike Documents = 10).
This helper queries the Object Type Manager to find the numeric ID by name.
If the lookup fails (e.g. the Review application is not installed, or the
object type has a different name in your tenant), the scaffold falls back
to raising a descriptive error rather than silently querying the wrong type.
"""
global _review_set_artifact_type_id
if _review_set_artifact_type_id is not None:
return _review_set_artifact_type_id
obj_type_base = "/Relativity.Rest/api/Relativity.ObjectManager/v1"
# Query object types in the workspace by name. The Relativity Review Set
# object type is typically named "Review Set" in the UI.
result = await rel_post(
f"{obj_type_base}/workspace/{workspace_id}/object/queryslim",
{
"request": {
"objectType": {"artifactTypeID": 25}, # 25 = Object Type in Relativity
"fields": [{"name": "Name"}, {"name": "ArtifactTypeID"}],
"condition": "'Name' == 'Review Set'",
},
"start": 1,
"length": 1,
},
)
objects = result.get("Objects", [])
if not objects:
raise RuntimeError(
"Could not resolve ArtifactTypeID for 'Review Set' object type in "
f"workspace {workspace_id}. Confirm the Review application is installed "
"and that the object type name matches exactly."
)
# The ArtifactTypeID is surfaced as a field value in the response.
# Field order matches the request's `fields` array (Name=0, ArtifactTypeID=1).
artifact_type_id = int(objects[0]["Values"][1])
_review_set_artifact_type_id = artifact_type_id
return artifact_type_id
# ----- Server + tool registry -----
server = Server("relativity-ediscovery")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="list_workspaces",
description=(
"List Relativity workspaces accessible to the service account, "
"paginated. Returns workspace name, ArtifactID, status, matter, "
"and document count. Use ArtifactID values in subsequent tool calls."
),
inputSchema={
"type": "object",
"properties": {
"start": {
"type": "integer",
"description": "1-based page start index (default: 1)",
"default": 1,
},
"length": {
"type": "integer",
"description": "Number of workspaces to return (default: page size from env)",
},
},
},
),
Tool(
name="get_workspace_summary",
description=(
"Get metadata for a single Relativity workspace: document count, "
"total file size, creation date. Returns operational stats only — "
"no document content."
),
inputSchema={
"type": "object",
"properties": {
"workspace_id": {
"type": "integer",
"description": "Workspace ArtifactID (from list_workspaces)",
}
},
"required": ["workspace_id"],
},
),
Tool(
name="get_review_set_metadata",
description=(
"Get metadata for a Relativity review set: name, document count, "
"status, and reviewer assignments. Does NOT return per-document "
"coding decisions or document text — review-set-level counts only."
),
inputSchema={
"type": "object",
"properties": {
"workspace_id": {
"type": "integer",
"description": "Workspace ArtifactID. Defaults to RELATIVITY_DEFAULT_WORKSPACE_ID if set.",
},
"review_set_id": {
"type": "integer",
"description": "ArtifactID of the review set to retrieve.",
},
},
"required": ["review_set_id"],
},
),
Tool(
name="get_saved_search_summary",
description=(
"Get the definition of a Relativity saved search: name, conditions, "
"field list, owner, and modification date. Returns the search "
"definition only — NOT the document results of executing the search. "
"Search conditions are not logged (privilege-aware posture)."
),
inputSchema={
"type": "object",
"properties": {
"workspace_id": {
"type": "integer",
"description": "Workspace ArtifactID. Defaults to RELATIVITY_DEFAULT_WORKSPACE_ID if set.",
},
"search_artifact_id": {
"type": "integer",
"description": "ArtifactID of the saved search.",
},
},
"required": ["search_artifact_id"],
},
),
Tool(
name="list_saved_searches",
description=(
"List saved searches in a Relativity workspace. Returns search "
"names, ArtifactIDs, owners, and last-modified dates. Use "
"get_saved_search_summary to read a specific search's conditions."
),
inputSchema={
"type": "object",
"properties": {
"workspace_id": {
"type": "integer",
"description": "Workspace ArtifactID. Defaults to RELATIVITY_DEFAULT_WORKSPACE_ID if set.",
},
"length": {
"type": "integer",
"description": "Max results to return (default: page size from env)",
},
},
},
),
]
# ----- Tool dispatch -----
def _resolve_workspace_id(arguments: dict[str, Any]) -> int:
"""Resolve workspace_id from arguments or the DEFAULT_WORKSPACE_ID env var."""
ws_id = arguments.get("workspace_id") or DEFAULT_WORKSPACE_ID
if not ws_id:
raise ValueError(
"workspace_id is required (or set RELATIVITY_DEFAULT_WORKSPACE_ID)"
)
return int(ws_id)
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
require_config()
# ---- list_workspaces ----
if name == "list_workspaces":
start = arguments.get("start", 1)
length = arguments.get("length", PAGE_SIZE)
# Workspace Manager: GET /Relativity.Rest/api/relativity-environment/v1/workspace/
# Returns a paged list of workspaces. The /summary helper gives doc counts.
result = await rel_get(
WS_MANAGER_BASE,
params={"start": start, "length": length},
)
workspaces = result.get("Results", result.get("Workspaces", [result]))
log_invocation("list_workspaces", len(workspaces) if isinstance(workspaces, list) else 1)
return [TextContent(type="text", text=str({"workspaces": workspaces, "_start": start}))]
# ---- get_workspace_summary ----
if name == "get_workspace_summary":
workspace_id = int(arguments["workspace_id"])
# Workspace Manager summary helper: GET /{workspaceID}/summary
# Returns document count and total file size for the workspace.
summary = await rel_get(f"{WS_MANAGER_BASE}/{workspace_id}/summary")
log_invocation("get_workspace_summary", 1)
return [TextContent(type="text", text=str(summary))]
# ---- get_review_set_metadata ----
if name == "get_review_set_metadata":
workspace_id = _resolve_workspace_id(arguments)
review_set_id = int(arguments["review_set_id"])
# Review Sets are RDOs. Query the Object Manager with the review set's
# ArtifactTypeID (resolved from the object type registry at first call).
artifact_type_id = await resolve_review_set_artifact_type_id(workspace_id)
# Fetch metadata fields for the specific review set.
# We request operational metadata only: Name, Status, document count fields.
# The exact field names depend on the Review application version in your tenant.
result = await rel_post(
f"{OBJ_MANAGER_BASE}/{workspace_id}/object/read",
{
"request": {
"Object": {"ArtifactID": review_set_id},
"Fields": [
{"Name": "Name"},
{"Name": "Status"},
{"Name": "Total Documents"},
{"Name": "Reviewed Documents"},
{"Name": "Reviewers"},
],
}
},
)
# Build a metadata-only response. Strip any fields that surface
# per-document coding values — those are privileged review work product.
review_set_meta = {
"artifact_id": review_set_id,
"workspace_id": workspace_id,
"artifact_type_id": artifact_type_id,
"fields": result.get("Object", {}).get("FieldValues", []),
"_note": (
"Review-set-level metadata only. Per-document coding values "
"are not surfaced by this tool."
),
}
log_invocation("get_review_set_metadata", 1)
return [TextContent(type="text", text=str(review_set_meta))]
# ---- get_saved_search_summary ----
if name == "get_saved_search_summary":
workspace_id = _resolve_workspace_id(arguments)
search_artifact_id = int(arguments["search_artifact_id"])
# Keyword Search Manager: ReadSingleAsync returns the saved search DTO
# with name, conditions, field list, owner, and modification metadata.
# Search conditions can reveal attorney review strategy — they are returned
# to the Claude session but are never written to logs (see log_invocation).
result = await rel_post(
f"{SEARCH_MANAGER_BASE}/ReadSingleAsync",
{
"workspaceArtifactID": workspace_id,
"searchArtifactID": search_artifact_id,
},
)
# Return the DTO as-is; it does not include document results.
# log_invocation omits the search conditions intentionally.
log_invocation("get_saved_search_summary", 1)
return [TextContent(type="text", text=str(result))]
# ---- list_saved_searches ----
if name == "list_saved_searches":
workspace_id = _resolve_workspace_id(arguments)
length = arguments.get("length", PAGE_SIZE)
# Keyword Search Manager: QueryAsync returns a paged list of saved search
# folder items. An empty Condition string returns all accessible searches.
result = await rel_post(
f"{SEARCH_MANAGER_BASE}/QueryAsync",
{
"workspaceArtifactID": workspace_id,
"query": {"Condition": "", "Sorts": []},
"length": length,
},
)
results = result.get("Results", [])
# Return names, ArtifactIDs, and modification dates — not conditions.
slim = [
{
"ArtifactID": item.get("ArtifactID"),
"Name": item.get("Name"),
"Owner": item.get("Owner"),
"SystemLastModifiedBy": item.get("SystemLastModifiedBy"),
"SystemLastModifiedOn": item.get("SystemLastModifiedOn"),
}
for item in results
]
log_invocation("list_saved_searches", len(slim))
return [TextContent(type="text", text=str({"saved_searches": slim, "total_count": result.get("TotalCount")}))]
raise ValueError(f"Unknown tool: {name}")
# ----- Entrypoint -----
async def main() -> None:
require_config()
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())