Build a Credit Check AI Agent With MCP and a Test Key
A small Python agent that talks to our MCP server over plain HTTP with a pncs_test_ key: the handshake, the tool call, a loop you can hand to any model, and the guardrails that stop it from guessing a purpose or paying twice.
On this page
This is a build guide for a small credit check AI agent: about a hundred lines of Python that talk to our MCP server with a test API key, run a pre-check on a phone number, and hand the same tools to whatever model you use. No SDK, no framework. Just requests and JSON-RPC, so you can see every byte that goes over the wire.
Why skip the official MCP client libraries? You don’t have to. They’re good. But our server is stateless and answers plain JSON (no event stream, no session to keep alive), so the raw protocol fits on one screen, and once you’ve seen it the libraries stop feeling like magic. If you’d rather connect an off-the-shelf assistant instead of writing code, pulling a credit report with Claude covers that path, and the launch post for our MCP server explains what the server is for.
What you need: an API plan, a test key from API / Developer (it starts with pncs_test_), and Python 3.10 or newer. Test keys are free and return sample people, so nothing in this post costs a cent until you swap in a live key.
The 30-second version of MCP
The Model Context Protocol is JSON-RPC 2.0 with a fixed vocabulary. A client says initialize, asks tools/list for what the server offers, and runs a tool with tools/call. Our server lives at https://phonenumbercreditscore.com/mcp, takes POST only, and checks a Bearer token on every request. That token can be an OAuth access token (what assistants like Claude or Cursor get when someone connects them) or an API key, which is what we’ll use. The header is the ordinary RFC 6750 kind.
Here’s the whole transport:
import os, uuid, requests
MCP_URL = "https://phonenumbercreditscore.com/mcp"
HEADERS = {
"Authorization": f"Bearer {os.environ['PNCS_KEY']}", # pncs_test_...
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-06-18",
}
def post(body):
r = requests.post(MCP_URL, json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json() if r.status_code == 200 else None # 202 = no body
def rpc(method, params=None):
msg = post({"jsonrpc": "2.0", "id": str(uuid.uuid4()),
"method": method, "params": params or {}})
if "error" in msg:
raise RuntimeError(f"{msg['error']['code']}: {msg['error']['message']}")
return msg["result"]
def notify(method):
post({"jsonrpc": "2.0", "method": method})
The Accept header lists both types because the transport spec asks clients to. You’ll only ever get application/json back from us. A bad or revoked key gets a 401 with a WWW-Authenticate header, which raise_for_status() will turn into a loud exception. Good. You want that one loud.
Handshake, then look around
init = rpc("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "precheck-agent", "version": "0.1"},
})
notify("notifications/initialized")
print(init["serverInfo"]["title"])
print(init["instructions"])
tools = rpc("tools/list")["tools"]
print([t["name"] for t in tools])
Two things are worth reading in that output.
The instructions string is the server telling the model how to behave: every lookup needs a permissible purpose the user actually has, never guess one, say the price before a paid call, and reuse past lookups instead of paying twice. We’ll feed it to the model as a system prompt later. We’ll also enforce the important parts in code, because instructions are a request, not a guarantee.
The tool list, with an API key, is everything: lookup_phone, unlock_tradelines, get_lookup, list_lookups, get_usage, get_balance, get_account, list_requests, check_approval and sandbox_numbers. An OAuth connection only sees the tools its permissions allow. A key is the master key, which matters later.
One lookup, by hand
Before any model gets involved, call the tool yourself:
res = rpc("tools/call", {"name": "lookup_phone", "arguments": {
"phone": "(555) 555-0142",
"purpose": "credit_application",
"reference": "app-7731",
"idempotency_key": "app-7731:5555550142",
}})
print(res["content"][0]["text"].split("\n\n")[0]) # one-line summary
lookup = res["structuredContent"]
print(lookup["status"], (lookup["score"] or {}).get("value"))
lookup_phone takes phone (any format, 10 digits), purpose, and optionally score_model (fico4, the default, or vantage4), include_tradelines, a name, email, address or date of birth to confirm the match, income, reference and idempotency_key. purpose is one of consent, credit_application or rental_application.
Every result comes back twice: as text (a one-line summary, then the JSON), which is what a model reads, and as structuredContent, which is what your code reads. In there you’ll find person (name and current address), phone_details (line_type, carrier, caller_id_name, a 0 to 100 fraud_score), score with its value, band and factors, and profile with credit usage, on-time payments, accounts, total balance, inquiries and collections. On a live key that’s a real FICO® Score 4 from TransUnion data, pulled as a soft inquiry. On a test key it’s a sample person who doesn’t exist, which is exactly who you want to be debugging against.
One quiet default to know about: if you leave out purpose, the server uses the account’s default purpose. Convenient for a script. Risky for an agent. We’ll close that gap below.
Giving the tools to a model
Every major model API wants roughly the same thing for a tool: a name, a description and a JSON Schema for the input. MCP already gives you all three, so the conversion is a dictionary comprehension. I’d also hand the model fewer tools than the server offers. A pre-check agent has no business reading request logs.
ALLOWED = {"lookup_phone", "get_lookup", "list_lookups", "sandbox_numbers"}
model_tools = [
{"name": t["name"], "description": t["description"],
"input_schema": t["inputSchema"]}
for t in tools if t["name"] in ALLOWED
]
def run_agent(question, ask_model, run, max_turns=8):
messages = [{"role": "user", "content": question}]
for _ in range(max_turns):
reply = ask_model(system=init["instructions"],
messages=messages, tools=model_tools)
messages.append(reply.message)
if not reply.tool_calls:
return reply.text
for call in reply.tool_calls:
result = call_tool(call.name, dict(call.arguments), run)
messages.append(reply.tool_result(call.id, result))
raise RuntimeError("agent hit max_turns; check the log")
ask_model is the seam. Message and tool-result shapes differ between providers, so wrap yours in a small adapter that returns message, text, tool_calls and a way to build a tool result. Pass the text content of the MCP result back to the model, and set the provider’s error flag when the MCP result has isError: true. Errors from our API (no match, rate limited, a missing purpose) come back as tool results rather than protocol errors precisely so the model can read them and explain them to the person asking.
max_turns is not decoration. A model that misreads an error can retry the same failing call until your patience or your budget runs out. Eight turns is plenty for a pre-check.

The guardrails that make it a credit check AI agent and not a liability
call_tool is where your rules live. The model proposes; this function decides.
import json, logging, time
log = logging.getLogger("precheck")
def call_tool(name, args, run):
if name == "lookup_phone":
if not run.get("purpose"):
return refuse("No permissible purpose on file for this run. "
"Ask the user to record consent or an application.")
args["purpose"] = run["purpose"] # never the model's guess
args["reference"] = run["reference"]
digits = "".join(c for c in args.get("phone", "") if c.isdigit())[-10:]
args["idempotency_key"] = f"{run['reference']}:{digits}"
for attempt in range(3):
t0 = time.monotonic()
res = rpc("tools/call", {"name": name, "arguments": args})
data = res.get("structuredContent") or {}
code = (data.get("error") or {}).get("code")
log.info(json.dumps({
"run": run["reference"], "tool": name, "error": code,
"status": data.get("status"), "lookup_id": data.get("id"),
"cost": data.get("cost"), "ms": round((time.monotonic() - t0) * 1000),
}))
if code not in ("rate_limited", "service_unavailable"):
return res
time.sleep(2 ** attempt)
return res
def refuse(text):
return {"isError": True, "content": [{"type": "text", "text": text}]}
Three decisions in there are worth defending.
The purpose comes from your system, not the model. run is built from your own records: the application someone submitted, the consent checkbox on your intake form. If there’s no purpose on file, the agent can’t look anyone up, no matter how persuasive the conversation gets. Under 15 U.S.C. § 1681b the business needs a permissible purpose for every report it gets, and a language model filling in an enum is not a business having one. The legal side of this is covered in the FCRA and AI agents, and permissible purpose under the FCRA covers the basics.
The idempotency key is deterministic. Application reference plus the 10 digits. If the model calls lookup_phone twice for the same applicant (they do this; it’s not personal), the second call returns the first answer with “no new charge” in the summary. Same thing if your process crashes and restarts. A random key per call would defeat the point. (If the model changes the arguments on the second try, say by adding a first name, the same key gets an idempotency_mismatch error instead. Also the right answer.) We hold the answer for 24 hours, and a 429 or 5xx frees the key instead of storing the failure, so retrying with the same key after a rate limit is safe. That’s why the retry loop above can be so dumb.
The log has no personal data. Tool name, error code, status, lookup ID, cost, timing. No name, no address, no score. If someone needs the details later, get_lookup with the ID returns the lookup exactly as it was, for free, and the person asking has to be allowed to see it. Logs end up in a lot of places. Credit files shouldn’t.
A fourth rule I’d add once you’re live: before a paid call, have the agent check list_lookups. It returns IDs, status, the last four digits, purpose, reference and cost, with no personal details, so it’s cheap to scan for “did we already run app-7731?” The server’s instructions already nudge the model to do this. A line of code that checks your own database first is more reliable than a nudge.
Breaking it on purpose with sandbox numbers
On a test key, a handful of numbers act out the awkward cases instead of returning a sample person. Any area code works; only the last four digits matter. The agent can even ask for the list itself with sandbox_numbers.
| Number ends in | What comes back | What your agent should do |
|---|---|---|
| 0404 | Error no_match, free |
Say nobody was found; suggest checking the number with the applicant |
| 0409 | Error address_mismatch, free |
Retry without the address, or ask for the current one |
| 0422 | status: thin_file, no score |
Not a decline. Route to manual review |
| 0423 | status: frozen, no person or phone details |
Explain the freeze; the applicant lifts it with TransUnion |
| 0429 | Error rate_limited |
Back off and retry with the same idempotency key |
| 0503 | Error service_unavailable |
Same, with longer waits, then give up politely |
Here’s a crude harness. It doesn’t involve the model at all, which is the point: your plumbing should survive these before you find out how a model narrates them.
CASES = {"0404": "no_match", "0422": "thin_file",
"0423": "frozen", "0429": "rate_limited", "0503": "service_unavailable"}
run = {"purpose": "consent", "reference": "sandbox-test"}
for last4, expected in CASES.items():
res = call_tool("lookup_phone", {"phone": f"312555{last4}"}, run)
data = res.get("structuredContent") or {}
got = (data.get("error") or {}).get("code") or data.get("status")
print(last4, "ok" if got == expected else f"FAILED: {got}")
The thin file and frozen cases trip up more code than the errors do, because they’re successes with holes in them. score is None on a thin file and person is None on a frozen one. If anything downstream does lookup["score"]["value"] without checking, 0422 will find it. Our posts on thin credit files and credit freezes versus locks are useful for writing the agent’s explanations, too.
Then run the full agent against a sample number and read the transcript. Does it say what a live call would cost before calling? Does it stop and ask when your run has no purpose? Does it make up a score when it gets an error? Better to learn that on a free key.
Going live without surprises
Swap pncs_test_... for a live key and the same code costs real money: $7.50 for each lookup that finds someone, $2 more if you add tradelines, nothing for a no-match. That’s on top of the API plan ($49 a month or $149 a year). The credit score API guide has the REST side if you want the same lookups outside of MCP.
Set an account spending limit before the first live run. Agents loop. Loops spend.
Then think about whether this agent should hold a key at all. An API key works with every tool and has no per-connection limits, which is fine for a script running on your server and less fine for an agent other people talk to. If you connect through OAuth instead, the connection gets only the permissions someone ticked on the consent page, can be test-only, can have its own monthly spending limit, and can require a person to approve each paid lookup. With approvals on, lookup_phone answers with an approval_required error whose approval object includes an approve_url. Show the link to the user, poll check_approval with the approval_id, and call lookup_phone again with that same approval_id once it’s approved. It runs once, and the approval expires after 24 hours. The approvals launch post has the details, and the connection guide on our API page shows the consent screen.
Last, decide where results go. The agent’s transcript now contains a credit profile, and that transcript goes wherever your model provider and your own logging send it. We’ll cover that properly in a post about what an AI assistant sees when it pulls a credit report. For now: keep the system prompt short, keep the tool set small, and don’t paste transcripts into tickets.
If you get stuck, list_requests (the one tool we kept away from the model) shows the last seven days of calls with status and error codes, which usually answers “what did my agent actually do?” faster than reading the transcript. The compliance page is worth a read before the first live applicant, too.


