📅 Day 28⏱ 55 min🔥 Ascend🖥️ Client

Building MCP Clients
from Scratch

Every Claude Desktop and Claude Code is an MCP client. Now learn to build your own: web dashboards, CLI tools, custom agents, and monitoring systems that speak the MCP protocol directly — using Python SDK and TypeScript SDK.

Building an MCP client unlocks the full power of the ecosystem. You can build a custom web UI that lets non-technical users access your MCP tools, a CI/CD bot that invokes tools on every commit, or a monitoring agent that polls resources and alerts on anomalies — all without Claude Desktop or Claude Code.
📋 Today's topics
🏗️ Architecture

MCP Client Architecture

An MCP client connects to one or more MCP servers, discovers available tools and resources, and invokes them. The client is responsible for the conversation loop — deciding which tools to call, passing results back, and handling errors. Claude Desktop and Claude Code do this automatically; when you build a custom client, you control this loop.

1. Connect
Open transport (stdio subprocess or HTTP SSE connection), send initialize request, receive server capabilities
2. Discover
Call tools/list and resources/list to enumerate available tools and their schemas
3. Execute
Call tools/call with tool name and arguments, receive result, handle errors
4. Loop / Close
Continue until task is complete, then close the connection gracefully
🐍 Python Client

Python Client with fastmcp

The fastmcp.Client is a high-level async Python MCP client. It handles connection lifecycle, JSON-RPC framing, and result parsing. Use it in scripts, Lambda functions, or any Python service that needs to call MCP tools programmatically.

from fastmcp import Client
from fastmcp.client.transports import SSETransport, StdioTransport
import asyncio, json

async def run_mcp_workflow():
    # Option 1: Connect to a remote SSE server
    transport = SSETransport(
        "https://api.example.com/mcp",
        headers={"Authorization": "Bearer YOUR_KEY"}
    )

    # Option 2: Connect to a local stdio server
    # transport = StdioTransport("python", ["-m", "my_mcp_server"])

    async with Client(transport) as client:
        # Discover what's available
        tools = await client.list_tools()
        print(f"Available tools: {[t.name for t in tools]}")

        # Call a tool
        result = await client.call_tool(
            "search_documents",
            {"query": "AWS security best practices", "max_results": 5}
        )
        print(f"Results: {result}")

        # Read a resource
        resource = await client.read_resource("schema://database/users")
        print(f"Schema: {resource}")

asyncio.run(run_mcp_workflow())
📘 TypeScript Client

TypeScript Client SDK

The official @modelcontextprotocol/sdk TypeScript package is the reference client implementation. Use it for Node.js servers, Lambda functions, web backends (Next.js API routes), and browser-compatible clients.

import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"

async function mcpWorkflow() {
  const transport = new SSEClientTransport(
    new URL("https://api.example.com/mcp"),
    { headers: { Authorization: `Bearer ${process.env.MCP_KEY}` } }
  )
  const client = new Client(
    { name: "my-client", version: "1.0.0" },
    { capabilities: {} }
  )
  await client.connect(transport)

  // List tools
  const { tools } = await client.listTools()
  console.log("Tools:", tools.map(t => t.name))

  // Call a tool
  const result = await client.callTool({
    name: "search_documents",
    arguments: { query: "IAM policies", maxResults: 5 }
  })
  console.log("Result:", result.content[0].text)

  await client.close()
}
mcpWorkflow().catch(console.error)
🤖 Agentic Client

Agentic Client — Auto Tool Selection

An agentic MCP client uses an LLM to decide which tools to call and in what order. The loop: (1) describe the goal to the LLM, (2) LLM picks a tool and args, (3) client calls the tool, (4) result goes back to LLM, (5) repeat until done. This is exactly what Claude Code does internally.

from fastmcp import Client
import anthropic, json

async def agentic_mcp_client(goal: str, mcp_server_url: str):
    claude = anthropic.Anthropic()

    async with Client(SSETransport(mcp_server_url)) as mcp:
        # Convert MCP tools to Anthropic tool format
        mcp_tools = await mcp.list_tools()
        anthropic_tools = [{
            "name": t.name,
            "description": t.description,
            "input_schema": t.inputSchema
        } for t in mcp_tools]

        messages = [{"role": "user", "content": goal}]

        while True:
            response = claude.messages.create(
                model="claude-sonnet-4-5", max_tokens=4096,
                tools=anthropic_tools, messages=messages
            )
            if response.stop_reason == "end_turn":
                return response.content[0].text  # Done!

            # Execute tool calls
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = await mcp.call_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": str(result)
                    })
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
🔑
This IS how Claude Code works internally

Claude Code runs this exact agentic loop — connecting to your MCP servers, converting tools to Anthropic format, feeding tool results back into the conversation until the task is complete. Building it yourself gives you full control over the loop logic, logging, and error recovery.

⚛️ Web Dashboard

Web Dashboard MCP Client (React)

Build a React UI that lets non-technical users invoke MCP tools through a friendly interface. The React app communicates with a thin Node.js proxy that handles the actual MCP connection — browsers can't connect to stdio servers directly.

// Next.js API route — acts as MCP proxy for the React frontend
// pages/api/mcp-tool.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import type { NextApiRequest, NextApiResponse } from "next"

// Reuse connection across requests (connection pooling)
let _client: Client | null = null
async function getClient() {
  if (!_client) {
    _client = new Client({ name: "dashboard", version: "1.0" }, { capabilities: {} })
    const transport = new SSEClientTransport(new URL(process.env.MCP_SERVER_URL!))
    await _client.connect(transport)
  }
  return _client
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { toolName, args } = req.body
  const client = await getClient()
  const result = await client.callTool({ name: toolName, arguments: args })
  res.json({ result: result.content[0].text })
}
⌨️ CLI Client

CLI Tool Client Pattern

#!/usr/bin/env python3
# mcp-cli.py — invoke any MCP tool from the command line
# Usage: python mcp-cli.py search_documents '{"query": "AWS IAM", "max_results": 3}'
import sys, asyncio, json
from fastmcp import Client
from fastmcp.client.transports import SSETransport

async def main():
    tool_name = sys.argv[1]
    tool_args = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}

    server_url = os.environ.get("MCP_SERVER", "http://localhost:8080")
    async with Client(SSETransport(server_url)) as client:
        result = await client.call_tool(tool_name, tool_args)
        print(json.dumps(result, indent=2))

asyncio.run(main())

🧠 Knowledge Check — Day 28

4 questions on building MCP clients

Q1/4
What are the 4 steps an MCP client performs when working with a server?
AAuthenticate, Authorize, Execute, Log
BConnect → Discover → Execute → Close
CRegister, Subscribe, Poll, Disconnect
DHandshake, Request, Stream, Terminate
✅ B. Connect (open transport, send initialize), Discover (list_tools/list_resources), Execute (call_tool/read_resource), Close. This lifecycle applies to both Python and TypeScript MCP clients.
Q2/4
In the agentic MCP client pattern, what happens when Claude returns stop_reason: "end_turn"?
AAn error occurred
BThe task is complete — Claude has finished its reasoning and produced a final answer
CThe rate limit was hit
DContext window is full
✅ B. stop_reason "end_turn" means Claude has finished its agentic loop and produced a final response. stop_reason "tool_use" means it wants to call a tool — the client should execute the tool and feed results back to continue the loop.
Q3/4
Why can't a browser React app connect directly to an stdio MCP server?
ABrowsers don't support JSON
Bstdio uses OS process spawning — not available in browsers. A Node.js proxy is required
CSSE is not supported in React
DBrowsers require OAuth for all connections
✅ B. stdio transport works by spawning a subprocess — a capability browsers don't have. Browsers can connect to SSE/HTTP MCP servers directly, but for stdio servers, you need a Node.js (or Lambda) proxy that spawns the process and forwards the connection.
Q4/4
In the agentic client, how are MCP tools converted to work with the Anthropic API?
AThey are used directly without conversion
BEach MCP tool's name, description, and inputSchema is mapped to Anthropic's tool format
CThey are embedded in the system prompt
DAnthropic SDK has native MCP support
✅ B. MCP tools have name, description, and inputSchema (JSON Schema). The Anthropic tools API expects name, description, and input_schema. You map one to the other in your client code, enabling Claude to select and call MCP tools via the Anthropic API.