Python MCP client — walkthrough
A short, explainable script that drives the Proofarc MCP server the way
Claude Desktop / claude.ai actually drive it: using the official mcp
Python SDK, talking the streamable-HTTP protocol, with a real session
handshake.
Reference implementation: scripts/mcp-client-demo.py.
Use this when you want to:
- See the wire-level flow that Claude takes (initialize → list-tools → call-tool).
- Build your own server-side LLM agent that drives Proofarc via MCP.
- Verify the remote MCP endpoint after a deploy without setting up Claude Desktop.
Running it
You need the mcp Python SDK installed. Easiest: use the mcp-agent
project's virtualenv (we keep it pinned there already).
# Once, to bootstrap the venv if you haven't yet
cd mcp-agent
python3.12 -m venv .venv
.venv/bin/pip install -e '.[test]'
# Run the demo (any time)
PROOFARC_PASSWORD='your-admin-password' \
.venv/bin/python ../scripts/mcp-client-demo.py \
--url https://app.proofarc.ai \
--username admin \
--project "User Management Platform"
Expected output: 7 numbered sections, ending with "Session closed cleanly."
What each section does
1. Platform login
resp = httpx.post(f"{url}/api/auth/login",
json={"username": ..., "password": ...})
token = resp.json()["token"]
Standard platform login. Nothing MCP-specific. The token you get here is the caller's identity. The MCP server doesn't hold platform creds in HTTP mode — it just forwards your bearer to every backend call. Whatever permissions your admin user has, the MCP tools have. RBAC stays server-side, where it belongs.
Why this matters: if you're building a server-side LLM agent, your agent gets a JWT for each user it acts on behalf of. The MCP server is stateless; it doesn't know or care who you are beyond the bearer you present.
2. Connecting to MCP
async with streamablehttp_client(
"https://app.proofarc.ai/mcp",
headers={"Authorization": f"Bearer {jwt}"}) as (read, write, get_session_id):
...
streamablehttp_client is a context manager that opens an HTTP/SSE
connection to the MCP endpoint. It returns three things:
read— a stream of incomingSessionMessages from the server.write— a stream you push outgoingSessionMessages into.get_session_id()— a callable that returns the session ID the server minted duringinitialize.
You don't normally interact with read and write directly — they're
the plumbing ClientSession rides on.
3. MCP handshake — initialize
async with ClientSession(read, write) as session:
init = await session.initialize()
This is the protocol moment that bare curl can't easily reproduce.
initialize does four things:
- Negotiates protocol version (
2025-11-25here, but the SDK and server pick the latest both support). - Exchanges capabilities (does the client support sampling? prompts? roots? — yes/no on each).
- Exchanges
clientInfo/serverInfo(name + version, for telemetry and debugging). - Mints the session ID that subsequent JSON-RPC requests must carry
in the
mcp-session-idheader. The SDK tucks this in for you.
After initialize succeeds you have a stateful session keyed by the
server. If the server restarts mid-flight, the session is gone and you'd
need to handshake again.
4. List tools
tools = (await session.list_tools()).tools
for t in tools:
print(t.name, t.description, t.inputSchema)
Returns metadata for every tool the server advertises — for Proofarc, the 144 tools listed in the tools reference. Each entry includes:
name— the identifier you use to call it.description— human-readable, the LLM uses this to decide which tool fits a user request.inputSchema— JSON Schema for the arguments.
This is what an LLM client like Claude actually consumes. Claude's prompt is internally augmented with this catalog so it knows what buttons exist.
5. List resource templates
templates = (await session.list_resource_templates()).resourceTemplates
Resources are URI-addressable read-only data the LLM can pull on demand. Proofarc exposes one template:
proofarc://execution/{execution_id}
The LLM doesn't fetch every execution proactively. It calls a tool that returns an execution id; when the user asks for detail, the LLM fetches that specific resource URI.
6. Call a tool — list_projects
result = await session.call_tool("list_projects", arguments={})
projects = _extract_payload(result)
The return value (CallToolResult) has two layers:
content— a list ofTextContent/ImageContentblocks. For JSON-returning tools the SDK puts the JSON string here in a single text block.structuredContent— newer SDKs expose the typed return value here, wrapped under{"result": ...}for FastMCP-served tools.
The helper _extract_payload() in the demo script handles both shapes.
For Proofarc, every tool returns plain JSON (lists or dicts) — no
images, no binary blobs.
7. Call a tool with arguments
# Pass a project NAME (resolver handles it) or an id — both work.
result = await session.call_tool(
"list_project_applications",
arguments={"project": "payments"})
Arguments are a Python dict that's validated against the tool's
inputSchema server-side. If you pass a wrong type, the SDK raises
before sending; if a required field is missing, the server returns an
error inside CallToolResult.isError rather than throwing.
When a name doesn't resolve cleanly
Tools that take names (project, environment, scenario, test, pipeline)
do not raise on ambiguity. They return a structured prompt:
result = await session.call_tool(
"run_by_tag",
arguments={"tags": ["smoke"], "project": "payments"})
payload = _extract_payload(result)
if isinstance(payload, dict) and "ask_user" in payload:
print(payload["ask_user"]) # "Which environment? Available: …"
for c in payload["candidates"]:
print(f" - {c['name']} (id={c['id']})")
# Pick one, then retry the call with the additional argument.
See Progressive narrowing for the full contract.
Reading a resource
Not in the demo script (to keep it short), but the pattern is:
content = await session.read_resource("proofarc://execution/123")
# content.contents is a list of TextResourceContents / BlobResourceContents
for c in content.contents:
print(c.text) # for text resources — JSON in our case
Adapting the script
Easy extensions:
- Run a scenario end-to-end — call
create_api_scenario, thenexecute_scenario, then pollget_execution_statusuntil terminal, thensummarize_execution. Replaces the curl polling inscripts/mcp-demo.sh. - Build a TUI — wrap each tool call in a
prompt_toolkitmenu so operators can pick from a list. - Embed in a LangChain / LlamaIndex agent — both frameworks now
accept MCP servers as a tool source. Point them at
https://app.proofarc.ai/mcpwith the bearer.
Troubleshooting
| Symptom | Cause |
|---|---|
httpx.HTTPStatusError: 401 ... missing_bearer | Auth header missing or wrong scheme. Must be Authorization: Bearer <jwt>. |
421 Invalid Host header | The MCP server's DNS-rebinding allowlist doesn't include the hostname you used. Check PROOFARC_MCP_ALLOWED_HOSTS env in the pod. |
Bad Request: Missing session ID (from curl) | You skipped initialize. The SDK handles this automatically. |
JSONDecodeError in the script | The tool returned text that isn't JSON. Inspect result.content raw. |
Tool returns {"isError": true, ...} | Server-side validation or platform error. Check the platform's request log for the underlying HTTP error. |
See also
- MCP overview — what's exposed, transports, design intent.
- Remote MCP (HTTP/SSE) — endpoint details and curl-level diagnostics.
- Claude Desktop setup — the no-code way to drive the same tools.
- Demo prompt — the one-prompt hero flow.