If your agent handles data from users in India, the Digital Personal Data Protection Act 2023 applies. Two questions come up on almost every request. Is there personal data in this text that must be masked before it is logged or sent onward, and is this specific processing step allowed without further action.
The RAIL Score MCP server answers both with two tools, over one URL, with no SDK to adopt:
https://mcp.responsibleailabs.ai/mcp
This post uses the official Python mcp SDK.
Setup
python -m venv .venv && source .venv/bin/activate
pip install "mcp>=1.13"
export RAIL_API_KEY=rail_your_key # from https://responsibleailabs.ai/dashboard
The connect helper opens an authenticated Streamable HTTP session and calls a tool. RAIL returns structured findings, never raw personal data, which matters a lot here: the raw Aadhaar or PAN never leaves the server.
import contextlib, os
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
@contextlib.asynccontextmanager
async def rail_session():
headers = {"Authorization": f"Bearer {os.environ['RAIL_API_KEY']}"}
async with streamablehttp_client("https://mcp.responsibleailabs.ai/mcp",
headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
async def call(session, tool, **arguments):
result = await session.call_tool(tool, arguments)
if result.isError:
raise RuntimeError(result.content[0].text)
return result.structuredContent or {}
Step 1: detect and mask Indian personal data
raildpdpscan finds Aadhaar, PAN, GSTIN, passport, voter ID, bank account, IFSC, mobile, and UPI identifiers. Each finding comes back with a masked value, its DPDP section, a severity, and character offsets. The raw value is never returned, so you can redact in place from the offsets alone.
A note on the sample: the Aadhaar below is a synthetic, Verhoeff-valid test number, not a real one. The engine validates IDs (Aadhaar uses the Verhoeff checksum), so invalid numbers are not flagged. That validation is what keeps false positives down.
INBOUND_MESSAGE = (
"Customer Rohan Mehta, Aadhaar 2341 5678 9014, PAN ABCPM4567Q, "
"GSTIN 22AAAAA0000A1Z5, bank IFSC HDFC0001234 account 123456789012, "
"passport M1234567, voter ABC1234567."
)
def redact_in_place(text, findings):
out = text
for f in sorted(findings, key=lambda f: f["position"]["start"], reverse=True):
start, end = f["position"]["start"], f["position"]["end"]
out = out[:start] + f["masked"] + out[end:]
return out
scan = await call(session, "rail_dpdp_scan", text=INBOUND_MESSAGE, mode="mask")
findings = scan["pii_found"]
safe_to_log = redact_in_place(INBOUND_MESSAGE, findings)
We splice from the end of the string so earlier offsets stay valid. Output:
aadhaar S.8(5) critical masked=XXXX XXXX 9014
gstin S.8(5) high masked=[GSTIN]
pan S.8(5) high masked=XXXXX4567X
passport S.8(5) high masked=[PASSPORT]
bank_account S.8(5) high masked=[BANK_ACCOUNT]
ifsc S.8(5) low masked=[IFSC]
voter_id S.8(5) medium masked=[VOTER_ID]
redacted text safe to log:
Customer Rohan Mehta, Aadhaar XXXX XXXX 9014, PAN XXXXX4567X, GSTIN [GSTIN], bank IFSC [IFSC] account [BANK_ACCOUNT], passport [PASSPORT], voter [VOTER_ID].
Seven identifiers found, each tagged with the section it falls under and a severity. Aadhaar comes back as critical. Run this on any text before it lands in your logs, your traces, or a downstream prompt.
Step 2: gate a cross-border transfer
Masking handles what you store. It does not answer whether you are allowed to do the thing you are about to do. raildpdpgate returns allow, block, or require_action for a named processing step, enforcing consent (S.6), child protection (S.9), and cross-border transfer rules (S.16).
gate = await call(session, "rail_dpdp_gate",
activity="transfer_cross_border",
purpose="Process refund via overseas payment processor",
data_categories=["financial", "contact", "government_id"])
Output:
decision=require_action
required: [obtain_consent] S.6: Valid consent is required before processing personal data
for purpose 'Process refund via overseas payment processor'. Consent must be free,
specific, informed, unconditional, and unambiguous.
The gate does not silently allow the transfer. It returns require_action with the exact obligation and the section behind it. Your agent can now pause, request consent, and retry, rather than transferring data it had no basis to move.
Step 3: gate an ordinary step
The same call works for routine processing. Stating the purpose is not optional under S.4 and S.6, so the tool requires it.
gate2 = await call(session, "rail_dpdp_gate",
activity="process_data",
purpose="Update customer contact details on request",
data_categories=["contact"])
Output:
decision=require_action
required: [obtain_consent] S.6: Valid consent is required before processing personal data
for purpose 'Update customer contact details on request'. Consent must be free, specific,
informed, unconditional, and unambiguous.
Same shape, different purpose. Whatever consent model you build, the gate gives you a consistent place to enforce it and a citation to log against the decision.
Putting it together
A practical flow for an agent serving Indian users:
raildpdpscan and store only the masked text.raildpdpgate with the activity and a clear purpose.require_action, collect what the response asks for, then proceed.block, stop and surface the reason.For audit-grade records, raildpdpcompliance adds session tracking, evidence packets, and deadline timers (DSR 90 days, CERT-In 6 hours, DPBI 72 hours), but the two tools above cover the everyday path.
The full runnable script is dpdppiigate.py. Get a key from the dashboard, export it, and run it against the live server. The raw identifiers in your data never leave your boundary unmasked, and every processing decision comes back with the section it rests on.