open-india-law-mcp
# open-india-law-mcp
An MCP server that gives any MCP-compatible AI agent (Claude, Claude Code,
Claude Desktop, or any other MCP client) structured, correctly-attributed
access to [Vaquill AI's Open India Law dataset](https://github.com/vaquill-AI/open-india-law):
- 12.8M+ court judgments (Supreme Court + all 25 High Courts)
- 813K+ tribunal/regulator matters
- 1.1M+ legislation provisions (Central + every State/UT), section by section
It does **not** download the dataset. Court judgment files alone run to
tens of GB per court, and the full corpus is ~53.6 GB of judgment text plus
463.6 GB of vector embeddings. Instead it uses DuckDB's `hf://` support to
run filtered SQL directly against the parquet files on the Hugging Face
Hub, pulling only the row groups and columns a query actually needs.
## Why this design ("accessing it rightly")
- **No hardcoded file list.** The dataset can add or rename files between
snapshots, so `list_catalog` asks the HF Hub what actually exists right
now and categorizes it, instead of shipping a guess that goes stale.
- **No guessed schema for judgments.** The README documents exact fields
for legislation and tribunal records, but not for plain court judgments.
`search_judgments` / `get_judgment` introspect the real schema at call
time (`get_schema`) and pick the right columns, rather than assuming
field names that might not exist.
- **CC BY 4.0 is honored automatically.** Every tool response carries an
`attribution` field (credit to Vaquill AI / Open India Law, CC BY 4.0)
and a `caveat` field (point-in-time snapshot, not legal advice, verify
against `source_url`/`source_pdf_url`). An agent doesn't have to
remember the license terms — they travel with the data.
- **Scale-aware by construction.** `search_judgments` requires a `court`
(scanning all 26 courts in one call isn't something a single query
should silently attempt), and every query tool caps results at 200 rows.
The generic `run_sql` tool refuses anything that isn't a single read-only
`SELECT`/`WITH`.
## Install
Requires Python 3.10+.
```bash
git clone <this-repo>
cd open-india-law-mcp
pip install -e .
```
This pulls in `mcp`, `duckdb`, and `huggingface_hub`. The dataset is
public, so no Hugging Face token is required — but if you hit HF rate
limits, set `HF_TOKEN` in your environment and the server will use it.
## Run it — 3 ways
**Before any of these:** run `python smoke_test.py` once. It hits the real
Hugging Face API and DuckDB extension CDN and confirms the whole path
works — nothing in this repo could be verified against live data during
development (the build sandbox blocks both hosts), so this is the first
real test.
### 1. As an MCP server (what Claude Desktop/Code actually run)
```bash
open-india-law-mcp
# equivalent: python -m open_india_law_mcp.server
```
This starts the stdio MCP server and blocks, waiting for a client to
connect over stdin/stdout. You won't see output — that's normal; connect a
client (below) rather than running it bare in a terminal to "test" it.
### 2. Directly from Python — no MCP protocol at all
`@mcp.tool()` registers a function with the server but returns it
unchanged, so every tool is just a plain importable Python function. Skip
the protocol entirely for a script, notebook, or test:
```python
from open_india_law_mcp import server
catalog = server.list_catalog()
hits = server.search_legislation(query="arbitration", state="central", limit=5)
act = server.get_act(act_id="IND_central_1996_26")
```
Full runnable version: [`examples/direct_python.py`](examples/direct_python.py).
### 3. As a real MCP client, in code
To drive it exactly the way Claude Desktop/Code do internally (spawn as a
subprocess, speak MCP over stdio) — useful for testing, or as a template
for a non-Claude agent:
```python
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command="open-india-law-mcp")
async def main():
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("list_catalog", {})
print(result.content[0].text)
asyncio.run(main())
```
Full runnable version: [`examples/mcp_client.py`](examples/mcp_client.py).
## Connecting real MCP clients
### Claude Code (CLI)
```bash
claude mcp add open-india-law -- open-india-law-mcp
```
Or, if it's not on your PATH:
```bash
claude mcp add open-india-law -- python -m open_india_law_mcp.server
```
Check it registered: `claude mcp list`. Remove it: `claude mcp remove open-india-law`.
### Claude Desktop
Edit your config file directly — `~/Library/Application Support/Claude/claude_desktop_config.json`
on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows:
```json
{
"mcpServers": {
"open-india-law": {
"command": "open-india-law-mcp"
}
}
}
```
If you installed into a virtualenv rather than globally, either use that
venv's full path for `command` (`/path/to/venv/bin/open-india-law-mcp`),
or:
```json
{
"mcpServers": {
"open-india-law": {
"command": "python",
"args": ["-m", "open_india_law_mcp.server"],
"cwd": "/path/to/open-india-law-mcp"
}
}
}
```
Restart Claude Desktop after editing the config.
### MCP Inspector (interactive debugging, no client needed)
```bash
npx @modelcontextprotocol/inspector open-india-law-mcp
```
Opens a browser UI where you can call each tool by hand and see the raw
JSON — the fastest way to poke at this without writing any code.
### Any other MCP client
This is a standard stdio server; point any MCP-compatible client at the
`open-india-law-mcp` command the same way you would point it at any other.
## Tools
| Tool | What it does |
| --- | --- |
| `list_catalog(refresh=False)` | Lists every court/regulator/state file that currently exists, with its `hf://` URI. **Call this first.** |
| `get_schema(file_uri)` | Real column names + types for a file, from `list_catalog`. |
| `run_sql(sql, limit=20)` | Read-only DuckDB SQL against any file(s) from the catalog. Full flexibility, capped at 200 rows. |
| `search_legislation(query, state="central", act_id=None, in_force_only=True, limit=20)` | Section-level search of one jurisdiction's Acts. |
| `get_act(act_id, state=None)` | Every section of one Act, in order. `state` is inferred from `act_id` if omitted. |
| `search_regulations(body, query=None, in_force_only=True, limit=20)` | Section-level search of one regulator's instruments (SEBI, RBI, ...). |
| `search_judgments(query, court, year_from=None, year_to=None, limit=20)` | Full-text search over one court's judgment chunks. `court` is required. |
| `get_judgment(case_id, court)` | Reassembles a full judgment from its chunks. |
Plus a static resource, `open-india-law://about`, with the license and
caveats in one place for clients that want to show it up front.
## How DuckDB reads parquet straight off the Hugging Face Hub
Nothing here is server-specific — it's plain DuckDB, so you can reproduce
any query from the `duckdb` CLI with zero Python:
```bash
duckdb -c "
INSTALL httpfs; LOAD httpfs;
SELECT act_id, section_number, section_title
FROM read_parquet('hf://datasets/vaquill/open-india-law/in_central_legislation.parquet')
WHERE text ILIKE '%arbitration%'
LIMIT 5;
"
```
**The `hf://` URI.** DuckDB's `httpfs` extension recognizes
`hf://datasets/<repo>/<path>` and resolves it to the Hub's actual
CDN/resolve URL under the hood — you never see or construct that URL
yourself. `catalog.hf_uri()` in this repo just builds the `hf://` string;
DuckDB does the resolution.
**Why this doesn't download whole files.** Parquet stores its schema and
per-row-group statistics (min/max, null counts) in a *footer* at the end
of the file. `httpfs` does an HTTP range request for that footer first,
then — for each column/predicate in your query — issues further range
requests only for the row groups whose stats can't rule them out. A
`WHERE year = 2023` on a 2 GB file might read a few MB. This is exactly
what lets `search_legislation`/`search_judgments` filter multi-GB files in
place instead of streaming them wholesale.
**Free-text search doesn't get this for free.** `ILIKE '%arbitration%'`
has no column statistic that can prove a row group doesn't contain the
word — DuckDB has to actually read every row group it can't rule out by
some *other* predicate. That's why `search_judgments` requires a `court`
(one file, not 26) and accepts `year_from`/`year_to`: the year filter
prunes row groups first, and only the survivors get scanned for text.
**Caching within a connection.** `duck.py` enables `enable_object_cache`
(keeps parsed parquet footers in memory) and `enable_http_metadata_cache`
(keeps ETag/size lookups in memory) on every connection it creates. Since
each worker thread reuses one long-lived connection, calling `get_schema`
then `search_legislation` against the same file only fetches that footer
once. There's no cross-process or on-disk cache — a fresh server process
starts cold.
**Authentication.** The dataset is public, so no token is required. If you
hit Hub rate limits, set `HF_TOKEN` in the environment the server runs in
— `duck.py` picks it up and registers it as a DuckDB secret
(`CREATE SECRET hf_token (TYPE HUGGINGFACE, TOKEN ...)`) automatically.
**Discovery vs. querying are two different Hub calls.** `list_catalog`
uses `huggingface_hub.HfApi.list_repo_files` (a small metadata API call,
cached for 15 minutes in `catalog.py`) to find out what files exist at
all. Actually reading a file's contents is a separate, unrelated path —
DuckDB's own HTTP client via `httpfs`, not `huggingface_hub`. The Python
package `huggingface_hub` is only ever used for that one discovery call.
## Performance notes
- Narrow before you search: pick one court/state/regulator file, and add a
year range when the tool supports it. That's the difference between a
sub-second query and one that has to scan a large fraction of a
multi-GB file.
- Tribunal decisions (CAT, ITAT, NCLT, ...) are ~2.1M scanned PDFs with no
text layer per the source dataset — they aren't in the judgment parquet
files and this server doesn't attempt OCR. Use `list_catalog` /
`run_sql` against whatever tribunal metadata files do exist in the repo.
## License
This server's code: MIT (add your own `LICENSE` file if you want one).
The **data** it queries is CC BY 4.0 — see `attribution.py` and the
`attribution`/`caveat` fields returned by every tool. The underlying legal
text is Government of India material reproduced under s.52(1)(q) of the
Copyright Act 1957. None of this is legal advice.
TDQS
Scored across 8 tools
Each tool targets a clearly distinct function: catalog discovery, schema inspection, raw SQL, legislation search, act retrieval, regulation search, judgment search, and judgment reassembly. The search_* and get_* pairs are cleanly separated, and run_sql is explicitly framed as a lower-level alternative rather than a competing high-level search tool.
All tool names follow a consistent verb_noun snake_case pattern: list_catalog, get_schema, run_sql, search_legislation, get_act, search_regulations, search_judgments, get_judgment. The naming convention is uniform and predictable.
Eight tools is well-scoped for a legal research server: three low-level data-access tools and five domain-specific search/retrieval tools. Each tool earns its place and the count is neither thin nor bloated.
Legislation has both search and full-act retrieval, judgments have search and full-judgment reassembly, and regulations have search but no dedicated get_regulation tool analogous to get_act. That is a minor gap, though an agent can work around it using run_sql.