# 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.

Source: https://phonenumbercreditscore.com/blog/build-credit-check-ai-agent  
Author: Sam Whitaker, Developer Content Lead  
Published: 2026-03-25  
Topic: API & Developers  
Publisher: Phone Number Credit Score (https://phonenumbercreditscore.com/)

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](https://phonenumbercreditscore.com/blog/pull-credit-report-with-claude) covers that path, and the [launch post for our MCP server](https://phonenumbercreditscore.com/blog/mcp-server-credit-lookups) 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](https://modelcontextprotocol.io/introduction) 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](https://datatracker.ietf.org/doc/html/rfc6750) kind.

Here's the whole transport:

```python
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

```python
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:

```python
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.

```python
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.

![A monitor in a dark room showing code in an editor, with a warm lamp glowing beside it](https://phonenumbercreditscore.com/media/blog/01a13c7d-6580-7d96-950a-7e3efc3e89e6-1200.webp)

## 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.

```python
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](https://www.law.cornell.edu/uscode/text/15/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](https://phonenumbercreditscore.com/blog/fcra-ai-agents), and [permissible purpose under the FCRA](https://phonenumbercreditscore.com/blog/permissible-purpose-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.

```python
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](https://phonenumbercreditscore.com/blog/thin-credit-file) and [credit freezes versus locks](https://phonenumbercreditscore.com/blog/credit-freeze-vs-credit-lock) 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](https://phonenumbercreditscore.com/blog/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](https://phonenumbercreditscore.com/blog/ai-credit-lookup-approvals) has the details, and the [connection guide on our API page](https://phonenumbercreditscore.com/api) 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](https://phonenumbercreditscore.com/blog/ai-credit-data-privacy). 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](https://phonenumbercreditscore.com/compliance) is worth a read before the first live applicant, too.
