A Model Context Protocol (MCP) server that exposes Ironclad as a tool surface to Claude — letting attorneys and legal-ops engineers ask Claude to look up a workflow, search the executed-contract repository, pull a specific clause type, summarize a workflow’s metadata, or annotate a record, all from a Claude conversation rather than the Ironclad UI. The scaffold is at apps/web/public/artifacts/mcp-server-ironclad-legal/ and ships read-mostly by design: drafts inside active workflows are typically privileged work product, so the server truncates document bodies by default and forces an explicit second tool call to retrieve the full text.
When to use
Reach for this when your in-house team is already on Ironclad and you can name three or more recurring queries that attorneys run by clicking through the Ironclad UI several times a week — typical examples: “list every active MSA over $500K,” “pull the indemnification clause from the last twenty closed deals,” “show me the workflows that have been waiting on the counterparty for more than five business days.” Those queries are mechanical: identify a contract type, filter by a property, return a metadata field. They are exactly the shape of work that compresses well into a Claude-tool conversation.
The economic argument: a Stage 4 Optimized legal-ops team that runs the equivalent of 200 such queries a week, at roughly four minutes per query end-to-end (open Ironclad, run search, filter, copy result, paste into matter notes), spends about 13 hours a week on UI navigation. Compressing that to ~30 seconds per Claude turn puts the time at under two hours. The remaining hours go back to substantive review work — which is where the team’s marginal hour is actually scarce.
When NOT to use
Skip this if your team’s volume of these recurring queries is under roughly twenty a week — the setup cost (legal review of the privilege posture, security review of the bearer token’s blast radius, and the sandbox-to-production validation cycle) does not pay back at that volume. Click through the Ironclad UI; revisit when volume grows.
Skip this if your tenant is on a tier or region whose public API surface has not been validated against the scaffold’s assumed base path (https://ironcladapp.com/public/api/v1/). The scaffold is runtime-untested; running it against an unverified base URL produces 404s that masquerade as “missing data” inside Claude conversations, which is exactly the failure mode that erodes trust in MCP-mediated legal tooling.
Skip this if your matter-management policy treats all workflow contents — drafts, redlines, audit logs, comments — as privileged without exception. The server’s truncate-by-default posture handles the common case, but a strict-privilege regime needs an additional privilege-tag enforcement layer (item 5 on the bundle’s TODO list) before any deployment, including read-only.
Finally, skip this if you do not yet have an AI policy for legal teams that covers Claude access to contract data. Stand the policy up first; then this server.
Setup
Setup is documented in detail at apps/web/public/artifacts/mcp-server-ironclad-legal/README.md. Summary:
Clone the bundle into a private repo. Run pip install -e . inside the bundle’s virtualenv.
Provision an Ironclad API token in the admin console (Admin → API Keys → Create) with read scope on workflows, records, and documents. Add comment-write scope only if you intend to use add_comment. Provision the underlying service-account role narrowly — the bearer token sees everything that role can see.
Set environment variables: IRONCLAD_API_TOKEN, IRONCLAD_TRUNCATE_AT (default 4000 chars per document body in summary responses), IRONCLAD_DEFAULT_WORKFLOW_TYPES (e.g. msa,nda,sow,dpa).
Register with Claude Desktop via the JSON snippet in the README.
Sanity-check by asking Claude to summarize a known workflow ID, then confirming the response is metadata-only with _truncated_at markers on any body field, then asking for the full document body and confirming it arrives only after the explicit get_document call.
The two-step retrieval is the point — if step 5 returns a full document body inline on the first call, the truncation guard is misconfigured and you should stop and fix it before exposing the server to anyone beyond the engineer who wired it up.
What it exposes
The server registers nine tools, grouped by the privilege model:
Object reads (read-only):get_workflow, get_record, get_document. Each returns the requested object’s metadata; only get_document returns full body text, and only when called explicitly.
Search (read-only):search_records (free-text against the executed-contract repository), list_workflows (filtered by status and type).
Legal helpers (read-only):clauses_by_type returns extracted clauses of a specific type (e.g. indemnification, liability_cap, termination) from a workflow’s documents; expiring_contracts returns records approaching renewal or expiration in a window.
Audit-class (truncate-by-default):summarize_workflow returns a metadata-only summary plus document IDs and titles; document bodies in the summary are truncated to IRONCLAD_TRUNCATE_AT chars with a _truncated_at marker.
Light writes (privileged):add_comment appends a comment to a record. The only write path on purpose. Comments inside Ironclad are themselves discoverable — write nothing here you would not write directly in the Ironclad UI.
The dispatch logic, with the truncation helper and the metadata-only audit logger, lives in apps/web/public/artifacts/mcp-server-ironclad-legal/src/ironclad_legal_mcp/server.py.
Privilege model
Three concrete posture choices, each with a guard in the scaffold:
Read-mostly. No delete_*, no draft edits, no workflow-stage transitions, no signer changes. The single write path is add_comment. Guard: the dispatch in server.py simply does not register write tools beyond comments. Adding any state-changing tool requires an explicit code change with a privilege review.
Truncate-by-default.summarize_workflow truncates document bodies to IRONCLAD_TRUNCATE_AT (default 4000 chars) and tags the response with _truncated_at so Claude knows to issue a follow-up get_document call when the user explicitly asks. Guard: the truncate_body() helper in server.py is the single chokepoint; widening it changes the privilege posture for every call site at once.
Search query metadata is not persisted. The audit logger records timestamp, user, tool name, and result count — never the query string itself. Guard: the log_invocation() helper has no query parameter; surfacing one would require a code change reviewed against the privilege policy.
Combined, these three choices mean Claude can navigate the contract repository, surface the metadata an attorney needs to make a decision, and document an action with a comment — but cannot inadvertently exfiltrate privileged work product or create a discoverable record of the team’s review priorities. The privilege posture is the product; the tools are the surface.
Cost reality
Three line items, all real:
Claude subscription. Claude Desktop or Claude Code with MCP enabled. Pro at $20/user/month or Team at $25–30/user/month covers most in-house legal team setups; very heavy users may justify Max.
Server hosting. Self-hosted Python process. Run it locally per attorney for development, or on a small internal VM (1 vCPU / 1 GB RAM is fine for sub-100-call/day volume) behind your VPN for shared use. Roughly $5–20/month on a hyperscaler, free if you already have internal Kubernetes capacity.
Ironclad API quota. Ironclad rate-limits per-tenant; a team running 200 queries/week stays well inside default quotas, but a team that builds an automation that scans the entire repository nightly will hit limits fast. The TODO list in the bundle’s README flags exponential-backoff retries as a pre-production task — burn through the quota once and you will understand why.
The unbudgeted line item is legal review time. Plan for two to four hours of in-house counsel time on the privilege posture before any production deployment, and another one to two hours per quarter on re-review as Ironclad ships features that change the API surface.
What success looks like
Watch three numbers move:
UI-time-per-query, measured by sampling: pick five recurring queries the team runs weekly, time them in Ironclad UI before rollout, time the same five via Claude conversation after rollout, divide. Target: 5x or better. Below 2x and the setup cost is not paying back.
Truncation-trigger rate, observable in the audit log: how often does an attorney follow a summarize_workflow call with an explicit get_document? The right band is roughly 20–50%. Above 70% means the truncation cap is too aggressive and attorneys are getting blocked; below 10% means they are accepting metadata that does not actually answer the question.
Comments added per week.add_comment is the only write path, and it is the only signal that an attorney acted on what Claude surfaced. A flat or zero count two months after rollout means the tool is being used as a lookup-only convenience, which is fine, but does not justify the privilege-review cost.
Versus the alternatives
Three real choices, each with a distinct tradeoff:
Ironclad’s native AI features. Ironclad ships clause-extraction and AI-summarization features inside the product. Pick those if your workflow stays inside Ironclad and the answers belong to the record. Pick this MCP server if the answer needs to land in a Claude conversation that also reaches into matter-management notes, your AI-policy guardrails, the rest of your tool surface — that is, if the integration with Claude’s reasoning is the value, not the contract lookup itself.
Vendor legal AI (Harvey, EvenUp, etc.). Those vendors ship pre-trained legal-domain models on top of their own ingestion pipelines. Pick a vendor if you need privileged-by-default workflows, attorney-grade retrieval evaluation, and you have the budget (mid-five-figures and up annually). Pick this MCP server if your model preference is Claude, your ingestion is Ironclad-native, and your team is small enough that a vendor’s per-seat pricing does not pencil out.
Status quo: attorneys click through the Ironclad UI. This is the honest baseline. The MCP server beats it only when query volume is high enough to amortize the privilege-review and setup cost. Below ~20 queries/week per attorney, the status quo wins.
Watch-outs
The bundle’s README enumerates the full list. Three failure modes are worth surfacing here, each paired with the specific guard that mitigates it:
Privilege leak via inadvertent body inclusion. A naive implementation of summarize_workflow would inline the document body. Guard: summarize_workflow routes every body field through truncate_body(), which caps at IRONCLAD_TRUNCATE_AT and tags the response with _truncated_at. Widening this requires editing one helper, which is the single chokepoint a privilege reviewer needs to audit.
Search query logging that reveals legal review strategy. Logging the query string of search_records would create a discoverable record showing what the team is looking for — itself privileged metadata. Guard: log_invocation() accepts only tool name and result count; the query string is never written to logs. Restoring it requires a code change reviewed against the privilege policy.
Scaffold’s lack of OAuth refresh. The scaffold uses a static Ironclad bearer token, which cannot be revoked granularly when an attorney leaves the firm. Guard (open): item 2 on the bundle’s TODO list flags OAuth-with-refresh as a pre-production task. Until that is implemented, rotate the token on every personnel change and treat the static-token deployment as a development-only posture.
Stack
Self-hosted Python MCP server (the scaffold uses the official mcp SDK, httpx, pydantic) speaking to the Ironclad public API on the backend; Claude Desktop or Code on the front end. Optional: structured logging via python-json-logger piped to your matter-management audit trail; Sentry or OpenTelemetry export, with query strings and document bodies scrubbed before 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())