Un serveur Model Context Protocol (MCP) qui expose Ironclad comme surface d’outils à Claude — permettant aux avocats et aux ingénieurs legal-ops de demander à Claude de rechercher un workflow, d’interroger le référentiel de contrats exécutés, d’extraire un type de clause spécifique, de résumer les métadonnées d’un workflow ou d’annoter un enregistrement, le tout depuis une conversation Claude plutôt que depuis l’UI Ironclad. Le scaffold se trouve dans apps/web/public/artifacts/mcp-server-ironclad-legal/ et est conçu principalement en lecture : les brouillons dans les workflows actifs sont généralement des travaux en cours privilégiés, de sorte que le serveur tronque les corps de documents par défaut et impose un deuxième appel d’outil explicite pour récupérer le texte complet.
Quand l’utiliser
Optez pour ce serveur quand votre équipe interne utilise déjà Ironclad et que vous pouvez nommer trois requêtes récurrentes ou plus que les avocats exécutent en cliquant plusieurs fois par semaine dans l’UI Ironclad — exemples typiques : « lister chaque MSA actif de plus de 500 000 $ », « extraire la clause d’indemnisation des vingt derniers deals conclus », « me montrer les workflows en attente du côté de la contrepartie depuis plus de cinq jours ouvrés ». Ces requêtes sont mécaniques : identifier un type de contrat, filtrer sur une propriété, renvoyer un champ de métadonnées. C’est exactement la forme de travail qui se compresse bien dans une conversation Claude-outil.
L’argument économique : une équipe legal-ops de niveau 4 Optimisé qui exécute l’équivalent de 200 telles requêtes par semaine, à environ quatre minutes par requête de bout en bout (ouvrir Ironclad, lancer la recherche, filtrer, copier le résultat, coller dans les notes de dossier), consacre environ 13 heures par semaine à la navigation dans l’UI. Ramener cela à ~30 secondes par tour Claude porte le temps à moins de deux heures. Les heures restantes retournent au travail de revue substantielle — là où l’heure marginale de l’équipe est réellement rare.
Quand NE PAS l’utiliser
Passez votre chemin si le volume de ces requêtes récurrentes est inférieur à environ vingt par semaine — le coût de setup (revue juridique de la posture de privilège, revue de sécurité du rayon d’action du bearer token, et le cycle de validation sandbox-vers-production) ne se rentabilise pas à ce volume. Cliquez dans l’UI Ironclad ; revenez quand le volume augmente.
Passez votre chemin si votre tenant est sur un tier ou une région dont la surface API publique n’a pas été validée contre le chemin de base supposé du scaffold (https://ironcladapp.com/public/api/v1/). Le scaffold n’est pas testé en exécution ; l’exécuter contre une URL de base non vérifiée produit des 404 qui se déguisent en « données manquantes » dans les conversations Claude, ce qui est exactement le mode d’échec qui érode la confiance dans les outils juridiques médiatisés par MCP.
Passez votre chemin si votre politique de gestion des dossiers traite tous les contenus de workflow — brouillons, redlines, journaux d’audit, commentaires — comme privilégiés sans exception. La posture de troncature par défaut du serveur couvre le cas courant, mais un régime de privilège strict nécessite une couche supplémentaire d’application des tags de privilège (point 5 de la liste TODO du bundle) avant tout déploiement, y compris en lecture seule.
Enfin, passez votre chemin si vous n’avez pas encore de politique IA pour les équipes juridiques couvrant l’accès de Claude aux données contractuelles. Établissez d’abord la politique ; ensuite ce serveur.
Setup
Le setup est documenté en détail dans apps/web/public/artifacts/mcp-server-ironclad-legal/README.md. Résumé :
Clonez le bundle dans un dépôt privé. Exécutez pip install -e . dans le virtualenv du bundle.
Provisionnez un token API Ironclad dans la console d’administration (Admin → API Keys → Create) avec la portée lecture sur workflows, enregistrements et documents. Ajoutez la portée écriture de commentaires uniquement si vous prévoyez d’utiliser add_comment. Provisionnez le rôle du compte de service sous-jacent de façon étroite — le bearer token voit tout ce que ce rôle peut voir.
Définissez les variables d’environnement : IRONCLAD_API_TOKEN, IRONCLAD_TRUNCATE_AT (par défaut 4000 caractères par corps de document dans les réponses résumées), IRONCLAD_DEFAULT_WORKFLOW_TYPES (par ex. msa,nda,sow,dpa).
Enregistrez-le auprès de Claude Desktop via l’extrait JSON dans le README.
Vérification de cohérence en demandant à Claude de résumer un ID de workflow connu, puis en confirmant que la réponse ne contient que des métadonnées avec des marqueurs _truncated_at sur tout champ de corps, puis en demandant le corps complet du document et en confirmant qu’il n’arrive qu’après l’appel explicite get_document.
La récupération en deux étapes est l’essentiel — si l’étape 5 renvoie un corps de document complet en ligne au premier appel, le garde de troncature est mal configuré et vous devez vous arrêter et le corriger avant d’exposer le serveur à quiconque au-delà de l’ingénieur qui l’a câblé.
Ce qu’il expose
Le serveur enregistre neuf outils, regroupés par modèle de privilège :
Lectures d’objets (lecture seule) :get_workflow, get_record, get_document. Chacun renvoie les métadonnées de l’objet demandé ; seul get_document renvoie le texte complet du corps, et uniquement lorsqu’il est appelé explicitement.
Recherche (lecture seule) :search_records (texte libre contre le référentiel de contrats exécutés), list_workflows (filtré par statut et type).
Helpers juridiques (lecture seule) :clauses_by_type renvoie les clauses extraites d’un type spécifique (par ex. indemnification, liability_cap, termination) depuis les documents d’un workflow ; expiring_contracts renvoie les enregistrements approchant du renouvellement ou de l’expiration dans une fenêtre donnée.
Classe audit (troncature par défaut) :summarize_workflow renvoie un résumé de métadonnées uniquement plus les IDs et titres de documents ; les corps de documents dans le résumé sont tronqués à IRONCLAD_TRUNCATE_AT caractères avec un marqueur _truncated_at.
Écritures légères (privilégiées) :add_comment ajoute un commentaire à un enregistrement. C’est le seul chemin d’écriture intentionnellement. Les commentaires dans Ironclad sont eux-mêmes découvrables — n’écrivez rien ici que vous n’écririez pas directement dans l’UI Ironclad.
La logique de dispatch, avec le helper de troncature et le logger d’audit de métadonnées uniquement, se trouve dans apps/web/public/artifacts/mcp-server-ironclad-legal/src/ironclad_legal_mcp/server.py.
Modèle de privilège
Trois choix de posture concrets, chacun avec un garde dans le scaffold :
Principalement en lecture. Pas de delete_*, pas d’éditions de brouillons, pas de transitions d’étapes de workflow, pas de changements de signataires. Le seul chemin d’écriture est add_comment. Garde : le dispatch dans server.py n’enregistre tout simplement pas d’outils d’écriture au-delà des commentaires. L’ajout d’un outil modifiant l’état nécessite une modification de code explicite avec une revue de privilège.
Troncature par défaut.summarize_workflow tronque les corps de documents à IRONCLAD_TRUNCATE_AT (4000 caractères par défaut) et balise la réponse avec _truncated_at afin que Claude sache émettre un appel get_document de suivi quand l’utilisateur le demande explicitement. Garde : le helper truncate_body() dans server.py est le point d’étranglement unique ; l’élargir change la posture de privilège pour chaque site d’appel en une fois.
Les métadonnées de requête de recherche ne sont pas persistées. Le logger d’audit enregistre l’horodatage, l’utilisateur, le nom de l’outil et le nombre de résultats — jamais la chaîne de requête elle-même. Garde : le helper log_invocation() n’a pas de paramètre query ; en exposer un nécessiterait une modification de code revue contre la politique de privilège.
Combinés, ces trois choix permettent à Claude de naviguer dans le référentiel contractuel, de faire remonter les métadonnées dont un avocat a besoin pour prendre une décision, et de documenter une action avec un commentaire — mais il ne peut pas exfiltrer par inadvertance du travail en cours privilégié ni créer un enregistrement découvrable des priorités de revue de l’équipe. La posture de privilège est le produit ; les outils sont la surface.
Coûts réels
Trois postes, tous réels :
Abonnement Claude. Claude Desktop ou Claude Code avec MCP activé. Pro à 20 $/utilisateur/mois ou Team à 25-30 $/utilisateur/mois couvre la plupart des configurations d’équipes juridiques internes ; les utilisateurs très intensifs peuvent justifier Max.
Hébergement du serveur. Processus Python auto-hébergé. Exécutez-le localement par avocat pour le développement, ou sur une petite VM interne (1 vCPU / 1 Go de RAM suffit pour un volume inférieur à 100 appels/jour) derrière votre VPN pour un usage partagé. Environ 5-20 $/mois sur un hyperscaleur, gratuit si vous avez déjà de la capacité Kubernetes interne.
Quota API Ironclad. Ironclad limite par tenant ; une équipe exécutant 200 requêtes/semaine reste bien dans les quotas par défaut, mais une équipe qui construit une automatisation scannant l’intégralité du référentiel chaque nuit atteindra rapidement les limites. La liste TODO dans le README du bundle signale les tentatives de répétition avec backoff exponentiel comme tâche de pré-production — épuisez le quota une fois et vous comprendrez pourquoi.
Le poste non budgété est le temps de revue juridique. Prévoyez deux à quatre heures de temps d’avocat interne sur la posture de privilège avant tout déploiement en production, et une à deux heures supplémentaires par trimestre pour la re-revue à mesure qu’Ironclad publie des fonctionnalités qui changent la surface API.
À quoi ressemble le succès
Regardez trois chiffres évoluer :
Temps-UI-par-requête, mesuré par échantillonnage : choisissez cinq requêtes récurrentes que l’équipe exécute chaque semaine, chronométrez-les dans l’UI Ironclad avant le déploiement, chronométrez les mêmes cinq via conversation Claude après le déploiement, divisez. Cible : 5x ou mieux. En dessous de 2x, le coût de setup ne se rentabilise pas.
Taux de déclenchement de troncature, observable dans le journal d’audit : à quelle fréquence un avocat fait-il suite à un appel summarize_workflow par un get_document explicite ? La bonne fourchette est environ 20-50 %. Au-dessus de 70 % signifie que le plafond de troncature est trop agressif et que les avocats sont bloqués ; en dessous de 10 % signifie qu’ils acceptent des métadonnées qui ne répondent pas réellement à la question.
Commentaires ajoutés par semaine.add_comment est le seul chemin d’écriture, et c’est le seul signal qu’un avocat a agi sur ce que Claude a mis en lumière. Un chiffre plat ou nul deux mois après le déploiement signifie que l’outil est utilisé comme simple commodité de consultation, ce qui est acceptable, mais ne justifie pas le coût de la revue de privilège.
Face aux alternatives
Trois vrais choix, chacun avec un compromis distinct :
Les fonctionnalités IA natives d’Ironclad. Ironclad fournit des fonctionnalités d’extraction de clauses et de résumé IA directement dans le produit. Choisissez celles-ci si votre workflow reste dans Ironclad et que les réponses appartiennent à l’enregistrement. Choisissez ce serveur MCP si la réponse doit atterrir dans une conversation Claude qui atteint également les notes de gestion de dossiers, vos guardrails de politique IA, le reste de votre surface d’outils — c’est-à-dire si l’intégration avec le raisonnement de Claude est la valeur, pas la consultation contractuelle elle-même.
IA juridique vendeur (Harvey, EvenUp, etc.). Ces vendeurs fournissent des modèles pré-entraînés dans le domaine juridique sur leurs propres pipelines d’ingestion. Choisissez un vendeur si vous avez besoin de workflows privilégiés par défaut, d’une évaluation de récupération de qualité avocat, et si vous avez le budget (milieu cinq chiffres et plus annuellement). Choisissez ce serveur MCP si votre préférence de modèle est Claude, que votre ingestion est native Ironclad, et que votre équipe est suffisamment petite pour que la tarification par siège d’un vendeur ne soit pas viable.
Statu quo : les avocats cliquent dans l’UI Ironclad. C’est la référence honnête. Le serveur MCP la surpasse uniquement quand le volume de requêtes est suffisamment élevé pour amortir le coût de revue de privilège et de setup. En dessous de ~20 requêtes/semaine par avocat, le statu quo l’emporte.
Points de vigilance
Le README du bundle énumère la liste complète. Trois modes d’échec valent d’être mis en avant ici, chacun associé au garde spécifique qui l’atténue :
Fuite de privilège via inclusion involontaire du corps. Une implémentation naïve de summarize_workflow inlinerait le corps du document. Garde : summarize_workflow route chaque champ body via truncate_body(), qui plafonne à IRONCLAD_TRUNCATE_AT et balise la réponse avec _truncated_at. L’élargir nécessite d’éditer un seul helper, qui est le point d’étranglement unique qu’un réviseur de privilège doit auditer.
Logging des requêtes de recherche révélant la stratégie de revue juridique. Logger la chaîne de requête de search_records créerait un enregistrement découvrable montrant ce que l’équipe recherche — métadonnées elles-mêmes privilégiées. Garde : log_invocation() n’accepte que le nom de l’outil et le nombre de résultats ; la chaîne de requête n’est jamais écrite dans les logs. La restaurer nécessite une modification de code revue contre la politique de privilège.
Manque de rafraîchissement OAuth dans le scaffold. Le scaffold utilise un bearer token Ironclad statique, qui ne peut pas être révoqué de façon granulaire quand un avocat quitte le cabinet. Garde (ouvert) : le point 2 de la liste TODO du bundle signale OAuth-avec-rafraîchissement comme tâche de pré-production. Jusqu’à ce que cela soit implémenté, faites tourner le token à chaque changement de personnel et traitez le déploiement à token statique comme une posture de développement uniquement.
Stack
Serveur MCP Python auto-hébergé (le scaffold utilise le SDK officiel mcp, httpx, pydantic) dialoguant avec l’API publique Ironclad en backend ; Claude Desktop ou Code en frontend. Optionnel : logging structuré via python-json-logger pipé vers votre piste d’audit de gestion de dossiers ; export Sentry ou OpenTelemetry, avec les chaînes de requête et les corps de documents épurés avant transmission.
# mcp-server-ironclad-legal
An MCP server tuned for in-house legal teams using Ironclad CLM. Exposes workflows, records, and documents as Claude tools, plus two legal-helper queries (`clauses_by_type`, `expiring_contracts`), an audit-class summary (`summarize_workflow`), and one privileged write (`add_comment`). Read-mostly by design — drafts inside active workflows are typically privileged content, so the server defaults to metadata-only responses with explicit drill-down.
> **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 Ironclad tenant. Treat it as a
> starting point you adapt to your tenant's workflow types, custom-field
> conventions, and clause-extraction model. The Ironclad public API
> surface (paths, response shapes, association labels) varies by tenant
> tier and feature flags.
## What it exposes
### Object-read tools (read-only)
- `get_workflow(workflow_id)` — workflow metadata, current step, participants
- `get_record(record_id)` — executed-contract record (post-signature repository entry)
- `get_document(document_id, version?)` — full document body for a specific version (explicit drill-down only)
### Search tools (read-only)
- `search_records(query, limit?)` — search the executed-contract repository
- `list_workflows(status?, type?)` — active workflows by status and workflow type
### Legal helpers (read-only)
- `clauses_by_type(workflow_id, clause_type)` — extracted clauses of a specific type (e.g. `indemnification`, `liability_cap`, `termination`) from a workflow's documents
- `expiring_contracts(window_days=90)` — records approaching renewal or expiration in the window, sorted by next-action date
### Audit-class tool (truncate-by-default)
- `summarize_workflow(workflow_id)` — metadata-only summary: counterparty, type, status, step history, participants, associated documents (IDs + titles only). Document body fetch is a separate explicit `get_document` call.
### Light writes (privileged)
- `add_comment(record_id, body)` — append a comment to a record. Comments inside Ironclad are themselves discoverable; this tool is the only write path on purpose.
The server **does not** expose `delete_*`, draft edits, workflow-stage transitions, or signer changes. Privileged content (drafts in active workflows, audit logs, redlines) flows through truncate-by-default responses; the user must request the body via an explicit second tool call. The principle: Claude can navigate, summarize, and annotate; the attorney drives every state-changing decision.
## Setup
### 1. Install
```bash
git clone <wherever you put this>
cd mcp-server-ironclad-legal
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e .
```
### 2. Create an Ironclad API token
In Ironclad: Admin → API Keys → Create. Grant read scope on workflows, records, and documents. Grant comment-write scope only if you intend to use `add_comment`. Copy the token.
The token is bearer-style — it bypasses Ironclad's per-user role permissions, so it sees everything the API key's role can see. Provision a service-account role with the narrowest scope your team can tolerate, not the broadest.
### 3. Configure environment
```bash
export IRONCLAD_API_TOKEN="ic_..."
export IRONCLAD_TRUNCATE_AT="4000" # chars per body in summary responses
export IRONCLAD_DEFAULT_WORKFLOW_TYPES="msa,nda,sow,dpa" # narrows list_workflows
```
`IRONCLAD_TRUNCATE_AT` is the character cap for any document-body field returned by `summarize_workflow`. Bodies above this length are truncated and the response includes a `_truncated_at` marker so Claude knows to issue a follow-up `get_document` call when the user explicitly asks.
### 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": {
"ironclad-legal": {
"command": "python",
"args": ["-m", "ironclad_legal_mcp.server"],
"env": {
"IRONCLAD_API_TOKEN": "ic_...",
"IRONCLAD_TRUNCATE_AT": "4000",
"IRONCLAD_DEFAULT_WORKFLOW_TYPES": "msa,nda,sow,dpa"
}
}
}
}
```
Restart Claude Desktop. You should see ~9 tools registered under `ironclad-legal`.
### 5. Sanity-check
Ask Claude: "Summarize workflow {known-id}." Confirm the response is metadata-only (no document body inline) and contains a `_truncated_at` marker on any body field. Then ask Claude: "Now get me the full body of document {id} from that workflow." Confirm the body is returned only after the explicit second call. Tune `IRONCLAD_TRUNCATE_AT` until the metadata responses are useful for navigation but not large enough to ship privileged drafts inadvertently.
## Watch-outs
- **Drafts inside active workflows are typically privileged.** A draft MSA mid-redline is attorney work product. The truncate-by-default posture in `summarize_workflow` is the guard — never widen it without an explicit per-team policy review. If your team's matter-management policy treats some draft classes as non-privileged, configure `IRONCLAD_TRUNCATE_AT=999999` per-deployment, not per-tool.
- **The audit log is itself privileged content.** Ironclad's audit log records who looked at what when — surfacing it through an MCP tool would let Claude reason over privileged metadata. This server intentionally does not expose audit-log queries. If you need audit-log access, build a separate tool with a documented legal-hold exception path, not through this scaffold.
- **Search query metadata reveals legal strategy.** "Find all MSAs with uncapped indemnification" is a query whose existence — even without results — discloses the team's review priorities. The dispatch logs only timestamps, user IDs, and result counts (never the query string itself). Do not patch the dispatch to log query text without a privilege review; doing so creates a discoverable record of legal strategy.
- **Bearer tokens bypass per-user permissions.** Anyone with access to the MCP client sees every record the API key's role can reach. Provision the service-account role narrowly and document it with your security team.
- **Clause extraction is model-dependent.** Ironclad's clause extraction may miss non-standard clause headings or clauses inside attachments. `clauses_by_type` returns whatever Ironclad surfaces — never use it as the sole source for compliance attestations.
## Limits and TODOs (before production use)
- [ ] Validate the Ironclad public API base path against your tenant's actual endpoint — some tiers use a regional subdomain.
- [ ] Implement OAuth-with-refresh in place of the static bearer token. Static tokens cannot be revoked granularly when an attorney leaves; OAuth refresh + per-user delegation is the production posture.
- [ ] Add request-level retries with exponential backoff (Ironclad rate-limits aggressively under burst load).
- [ ] Wire structured logging via `python-json-logger` and pipe to your matter-management audit trail.
- [ ] Add a privilege-tag enforcement layer: refuse to return any document marked `privileged: true` in custom properties, even via explicit `get_document`, unless the user passes a documented `--privilege-acknowledged` flag.
- [ ] Write integration tests against an Ironclad sandbox.
- [ ] Add Sentry / OpenTelemetry export — but scrub query strings and document bodies before transmission.
"""
ironclad-legal-mcp — MCP server tuned for in-house legal teams using Ironclad.
Exposes object-read tools (workflows, records, documents), search tools,
two legal helpers (clauses_by_type, expiring_contracts), one audit-class
summary (summarize_workflow, truncate-by-default), and one privileged
write (add_comment). Read-mostly by design — drafts inside active
workflows are typically privileged work product, so document bodies are
truncated by default and the user must request the full body via an
explicit second tool call.
STATUS: scaffold — not runtime-tested. Adapt the workflow type names,
clause-type vocabulary, custom-property paths, and (in particular) the
public API base path to your tenant before use. Some Ironclad tiers use
a regional subdomain.
Run as: python -m ironclad_legal_mcp.server
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta, 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) -----
IRONCLAD_API_TOKEN = os.environ.get("IRONCLAD_API_TOKEN")
TRUNCATE_AT = int(os.environ.get("IRONCLAD_TRUNCATE_AT", "4000"))
DEFAULT_WORKFLOW_TYPES = [
s.strip()
for s in os.environ.get("IRONCLAD_DEFAULT_WORKFLOW_TYPES", "").split(",")
if s.strip()
]
API_BASE = "https://ironcladapp.com/public/api/v1"
# Privilege-aware logger: NEVER include query strings, document bodies, or
# clause text in log records. Only metadata: timestamp, user, tool name,
# result count. Surfacing query text would create a discoverable record
# of legal review strategy.
audit_log = logging.getLogger("ironclad_legal_mcp.audit")
def require_config() -> None:
if not IRONCLAD_API_TOKEN:
raise RuntimeError("IRONCLAD_API_TOKEN env var is required")
def auth_headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {IRONCLAD_API_TOKEN}",
"Content-Type": "application/json",
}
def log_invocation(tool: str, result_count: int | None = None) -> None:
"""Metadata-only audit record. Never includes query text or body."""
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",
)
def truncate_body(body: str | None) -> dict[str, Any]:
"""Return a body field that is truncated to TRUNCATE_AT chars, with a
`_truncated_at` marker so Claude knows to issue a follow-up
explicit get_document call when the user asks for the full text."""
if body is None:
return {"text": None, "_truncated_at": None}
if len(body) <= TRUNCATE_AT:
return {"text": body, "_truncated_at": None}
return {
"text": body[:TRUNCATE_AT],
"_truncated_at": TRUNCATE_AT,
"_full_length": len(body),
"_hint": "Call get_document(document_id, version) for the full body.",
}
# ----- Ironclad HTTP helpers -----
async def ic_get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{API_BASE}{path}", headers=auth_headers(), params=params)
r.raise_for_status()
return r.json()
async def ic_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(f"{API_BASE}{path}", headers=auth_headers(), json=body)
r.raise_for_status()
return r.json()
# ----- Server + tool registry -----
server = Server("ironclad-legal")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="get_workflow",
description=(
"Fetch workflow metadata, current step, and participant list "
"by workflow ID. Does not include document bodies — use "
"summarize_workflow for that, then get_document for full text."
),
inputSchema={
"type": "object",
"properties": {"workflow_id": {"type": "string"}},
"required": ["workflow_id"],
},
),
Tool(
name="get_record",
description=(
"Fetch an executed-contract record (post-signature repository "
"entry) by record ID. Returns metadata + linked document IDs."
),
inputSchema={
"type": "object",
"properties": {"record_id": {"type": "string"}},
"required": ["record_id"],
},
),
Tool(
name="get_document",
description=(
"Fetch the full body of a document at a specific version. "
"This is the explicit drill-down call the user must request "
"after seeing a truncated body in summarize_workflow."
),
inputSchema={
"type": "object",
"properties": {
"document_id": {"type": "string"},
"version": {"type": "string"},
},
"required": ["document_id"],
},
),
Tool(
name="search_records",
description=(
"Search the executed-contract repository by free-text query. "
"Query string is NOT logged; only timestamp, user, and "
"result count are recorded."
),
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 25},
},
"required": ["query"],
},
),
Tool(
name="list_workflows",
description=(
"List active workflows, optionally filtered by status (e.g. "
"'in_review', 'awaiting_signature') and type (e.g. 'msa', "
"'nda'). When type is omitted, IRONCLAD_DEFAULT_WORKFLOW_TYPES "
"is used if set."
),
inputSchema={
"type": "object",
"properties": {
"status": {"type": "string"},
"type": {"type": "string"},
"limit": {"type": "integer", "default": 50},
},
},
),
Tool(
name="clauses_by_type",
description=(
"Return extracted clauses of a specific type (e.g. "
"'indemnification', 'liability_cap', 'termination', "
"'governing_law') from the documents attached to a workflow. "
"Backed by Ironclad's clause-extraction model — coverage is "
"best-effort, not authoritative for compliance attestations."
),
inputSchema={
"type": "object",
"properties": {
"workflow_id": {"type": "string"},
"clause_type": {"type": "string"},
},
"required": ["workflow_id", "clause_type"],
},
),
Tool(
name="expiring_contracts",
description=(
"Return executed-contract records approaching renewal or "
"expiration within window_days, sorted by next-action date "
"ascending."
),
inputSchema={
"type": "object",
"properties": {"window_days": {"type": "integer", "default": 90}},
},
),
Tool(
name="summarize_workflow",
description=(
"Metadata-only summary of a workflow: counterparty, type, "
"status, step history, participants, document IDs + titles. "
"Document bodies are truncated to IRONCLAD_TRUNCATE_AT chars "
"with a _truncated_at marker. Use get_document for full text."
),
inputSchema={
"type": "object",
"properties": {"workflow_id": {"type": "string"}},
"required": ["workflow_id"],
},
),
Tool(
name="add_comment",
description=(
"Append a comment to an executed-contract record. The only "
"write path exposed by this server. Comments are themselves "
"discoverable inside Ironclad — write nothing here you would "
"not write directly in the Ironclad UI."
),
inputSchema={
"type": "object",
"properties": {
"record_id": {"type": "string"},
"body": {"type": "string"},
},
"required": ["record_id", "body"],
},
),
]
# ----- Tool dispatch -----
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
require_config()
if name == "get_workflow":
data = await ic_get(f"/workflows/{arguments['workflow_id']}")
log_invocation("get_workflow", 1)
return [TextContent(type="text", text=str(data))]
if name == "get_record":
data = await ic_get(f"/records/{arguments['record_id']}")
log_invocation("get_record", 1)
return [TextContent(type="text", text=str(data))]
if name == "get_document":
params: dict[str, Any] = {}
if v := arguments.get("version"):
params["version"] = v
data = await ic_get(f"/documents/{arguments['document_id']}", params or None)
log_invocation("get_document", 1)
return [TextContent(type="text", text=str(data))]
if name == "search_records":
body = {
"query": arguments["query"],
"limit": arguments.get("limit", 25),
}
result = await ic_post("/records/search", body)
# Log result count only — never the query string itself.
log_invocation("search_records", len(result.get("results", [])))
return [TextContent(type="text", text=str(result))]
if name == "list_workflows":
params: dict[str, Any] = {"limit": arguments.get("limit", 50)}
if status := arguments.get("status"):
params["status"] = status
wf_type = arguments.get("type")
if wf_type:
params["type"] = wf_type
elif DEFAULT_WORKFLOW_TYPES:
params["type"] = ",".join(DEFAULT_WORKFLOW_TYPES)
result = await ic_get("/workflows", params)
log_invocation("list_workflows", len(result.get("workflows", [])))
return [TextContent(type="text", text=str(result))]
if name == "clauses_by_type":
wf = await ic_get(
f"/workflows/{arguments['workflow_id']}",
{"include": "documents,clauses"},
)
clause_type = arguments["clause_type"].lower()
# Filter the workflow's surfaced clauses by type. Body of each
# clause is left intact (clauses are short; truncation is for
# full document bodies). If a downstream tenant has very long
# clauses, fold truncate_body() over the `text` field here.
clauses = []
for doc in wf.get("documents", []):
for c in doc.get("clauses", []):
if c.get("type", "").lower() == clause_type:
clauses.append(
{
"document_id": doc.get("id"),
"document_title": doc.get("title"),
"clause_type": c.get("type"),
"text": c.get("text"),
"page": c.get("page"),
}
)
log_invocation("clauses_by_type", len(clauses))
return [TextContent(type="text", text=str({"clauses": clauses}))]
if name == "expiring_contracts":
window_days = arguments.get("window_days", 90)
cutoff = datetime.now(timezone.utc) + timedelta(days=window_days)
body = {
"filters": [
{
"property": "next_action_date",
"operator": "LTE",
"value": cutoff.isoformat(),
},
{
"property": "status",
"operator": "IN",
"values": ["active", "auto_renewing"],
},
],
"sort": [{"property": "next_action_date", "direction": "ASC"}],
"limit": 200,
}
result = await ic_post("/records/search", body)
log_invocation("expiring_contracts", len(result.get("results", [])))
return [TextContent(type="text", text=str(result))]
if name == "summarize_workflow":
wf = await ic_get(
f"/workflows/{arguments['workflow_id']}",
{"include": "documents,participants,history"},
)
# Build a metadata-only summary. Any document body present is
# passed through truncate_body() so the response includes a
# _truncated_at marker that Claude reads as a hint to call
# get_document explicitly when the user asks for the full text.
summary = {
"id": wf.get("id"),
"type": wf.get("type"),
"status": wf.get("status"),
"counterparty": wf.get("counterparty"),
"current_step": wf.get("current_step"),
"participants": [
{"id": p.get("id"), "role": p.get("role"), "email": p.get("email")}
for p in wf.get("participants", [])
],
"step_history": wf.get("history", []),
"documents": [
{
"id": d.get("id"),
"title": d.get("title"),
"version": d.get("version"),
"body_preview": truncate_body(d.get("body")),
}
for d in wf.get("documents", [])
],
}
log_invocation("summarize_workflow", len(summary["documents"]))
return [TextContent(type="text", text=str(summary))]
if name == "add_comment":
body = {
"body": arguments["body"],
"created_at": datetime.now(timezone.utc).isoformat(),
}
result = await ic_post(
f"/records/{arguments['record_id']}/comments", body
)
log_invocation("add_comment", 1)
return [
TextContent(
type="text",
text=f"Added comment {result.get('id')} to record {arguments['record_id']}",
)
]
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())