⌂ Home MCP Track — Building with the Model Context Protocol ⭐ CAPSTONE · MCP Track Complete
5–6 hours 4 Labs Advanced · Production
MCP Track Capstone — Full MCP Ecosystem
Build a 3-Server MCP Ecosystem for UCC Lien Analysis

Connect a DataServer, ComputeServer, and StorageServer into a single Claude-powered host that answers real questions about commercial lien risk — end to end, from SQLite filing data to synthesized risk assessments.

Capstone Project
4 Labs · 5–6 Hours
Domain C · Public Records / UCC Data
MCP Track Complete

What You'll Build

Across four labs, you build a complete, production-shaped MCPModel Context Protocol — an open standard that lets Claude (and any LLM host) connect to external servers that expose tools, resources, and prompts in a uniform way. ecosystem backed by real data. The domain is UCC-9Uniform Commercial Code Article 9 — the US legal framework governing secured transactions. When a lender takes collateral on a loan, they file a UCC-1 financing statement. These are public records. lien filings — public records that commercial lenders, credit analysts, and attorneys use to assess whether a company has outstanding secured debt.

The system answers questions like: "Does ACME Industrial have any open liensLien — a legal claim against an asset used as collateral for a debt. An open lien means the debt has not been satisfied and the secured party can repossess the collateral.? Who are their secured parties? What is their risk score?" — by routing each sub-question to the right specialist server.

Architecture — Animated Data Flow
Claude Host (Orchestrator · anthropic SDK) MCPEcosystemHost DataServer SQLite · FastMCP · HTTP search_filings · get_filing ComputeServer rapidfuzz · risk scoring · HTTP match_entity · score_risk StorageServer ChromaDB · embeddings · HTTP store_entity · find_similar data:: compute:: storage:: DataServer ComputeServer StorageServer

What the Agent Can Do

When you run the finished system, you can ask:

example queries
python host/agent.py "Does ACME Industrial have any open liens? What is their risk?"
[DataServer] Searching filings for "ACME Industrial" → 4 results [DataServer] Filing #FL-2024-001: ACME Industrial LLC — OPEN — Equipment [ComputeServer] Matching "ACME Industrial" against 4 candidates → best: "ACME Industrial LLC" (97.3) [ComputeServer] Risk score: 72/100 — tier: HIGH Factors: multiple open liens, mixed collateral types, recent filing [StorageServer] Storing entity snapshot for ACME Industrial LLC [Host] ACME Industrial LLC has 3 open UCC-9 liens totaling secured debt across equipment and accounts receivable. Risk tier: HIGH (72/100). Secured parties: First National Bank, Pacific Capital Finance, SBA.

Prerequisites

Before starting, verify you have:

MCP01–MCP05Completed
Python 3.11+with pip
Dockerv24+ / Desktop
Anthropic keyANTHROPIC_API_KEY
Ollamaoptional embeds
Install dependencies up front

Run this once in your project root before starting Lab 1:
pip install fastmcp anthropic rapidfuzz chromadb sentence-transformers PyJWT

Time Budget

LabFocusEst. Time
Lab 1DataServer — SQLite filings, full-text search, resources45 min
Lab 2ComputeServer — entity matching, risk scoring45 min
Lab 3StorageServer — ChromaDB vector store, similarity search30 min
Lab 4Orchestrating Host — merge tool lists, ReAct loop60 min
BonusOAuth, Docker Compose full stack45 min

Agent Spec

Read this spec before writing any code. This is the single source of truth that describes every server, tool, resource, data schema, and host behavior. In a Tier 3 workflow, you would pass this file to claude and let it scaffold the project — then verify and iterate.

spec/mcp-ecosystem-spec.md
# MCP Ecosystem Spec — UCC Lien Analysis Agent
version: 1.0.0
domain: Domain C — Public Records / UCC Data Engineering

## Overview
A 3-server MCP ecosystem that lets a Claude host answer questions about
UCC-9 commercial lien filings. The host connects to all servers, merges
their tool lists under namespaced prefixes, and runs a ReAct loop.

---

## Data Schema

### UCC Filing Record
```json
{
  "filing_id": "FL-2024-001",
  "debtor_name": "ACME Industrial LLC",
  "secured_party": "First National Bank",
  "filing_date": "2024-03-15",
  "status": "OPEN",           // OPEN | TERMINATED | AMENDED
  "collateral_type": "Equipment",  // Equipment | Inventory | AR | All Assets
  "state": "CA",
  "amount_usd": 450000
}
```

---

## Server: DataServer
transport: HTTP (FastMCP)
port: 8001
OAuth scope required: mcp:tools:execute:read_only

### Tools
- search_filings(debtor_name: str, limit: int = 10) → list[dict]
    Full-text LIKE search on debtor_name in SQLite. Returns list of filings.
- get_filing(filing_id: str) → dict
    Fetch a single filing by primary key. Raises ValueError if not found.
- list_secured_parties(debtor_name: str) → list[str]
    Returns distinct secured party names for a debtor (all statuses).

### Resources
- filing://{filing_id}
    Returns filing as structured JSON MCP resource (mime: application/json)
- entity://{entity_name}/summary
    Computes inline: total_filings, open_count, terminated_count,
    collateral_types, secured_parties. Returns as JSON resource.

---

## Server: ComputeServer
transport: HTTP (FastMCP)
port: 8002
OAuth scope required: mcp:tools:execute:all (for score_risk)

### Tools
- match_entity(query: str, candidates: list[str], threshold: float = 0.8)
    → list[dict]
    Uses rapidfuzz WRatio. Returns [{name, score}] sorted by score desc.
    Only includes results >= threshold * 100.
- score_risk(debtor_name: str, filing_count: int, open_liens: int,
             collateral_types: list[str]) → dict
    Returns {score: 0-100, tier: "low"|"medium"|"high"|"critical",
             factors: list[str]}
    Scoring: base=open_liens*15, collateral bonus: All Assets+20, AR+10,
    Equipment+5. Cap at 100. Tiers: <25=low, <50=medium, <75=high, >=75=critical.
- deduplicate_names(names: list[str]) → list[dict]
    Cluster names with WRatio >= 85. Returns [{canonical, aliases, count}].

---

## Server: StorageServer
transport: HTTP (FastMCP)
port: 8003
OAuth scope required: mcp:tools:execute:read_only

### Tools
- store_entity(entity_name: str, filing_ids: list[str], metadata: dict)
    → str
    Embeds entity_name with all-MiniLM-L6-v2. Upserts to ChromaDB
    collection "ucc_entities". Returns document ID.
- find_similar(query: str, limit: int = 5) → list[dict]
    Query ChromaDB by embedding. Returns [{entity_name, score, filing_ids,
    metadata}] sorted by similarity desc.
- get_entity_history(entity_name: str) → list[dict]
    Retrieves all stored snapshots for entity_name, sorted by
    metadata.timestamp desc.

---

## Host: MCPEcosystemHost
file: host/agent.py
SDK: anthropic (raw Messages API)
Tool prefix map:
  data::     → DataServer   (port 8001)
  compute::  → ComputeServer (port 8002)
  storage::  → StorageServer (port 8003)

### Behavior
1. Connect to all 3 servers via MCP ClientSession (HTTP transport)
2. Merge tool lists: prefix each tool name with server namespace
3. On user query: send merged tool list to claude-3-5-sonnet-20241022
4. ReAct loop: if tool_use block → strip prefix → route to server → inject result
5. Continue until stop_reason == "end_turn"
6. Print final assistant text response

### System Prompt
"You are a UCC lien analysis assistant. You have access to three specialized
servers: DataServer (search filings), ComputeServer (entity matching and risk
scoring), ComputeServer (deduplication), and StorageServer (semantic search
and entity history). Always: 1) search for the entity first, 2) match the
canonical name, 3) score risk with open lien count, 4) store the result.
Report findings clearly with filing IDs and risk tier."

---

## Directory Layout
ucc-mcp-ecosystem/
  data_server/
    server.py
    seed.sql
    ucc_filings.db       # auto-created on first run
  compute_server/
    server.py
  storage_server/
    server.py
  host/
    agent.py
  docker-compose.yml
  spec/
    mcp-ecosystem-spec.md
  README.md
The spec defines the contract. Labs 1–4 implement it. When something doesn't work, come back to the spec — the spec wins over your intuition about what "should" work.
1

Lab 1 — DataServer

45 min · FastMCP · SQLite · Full-text search · Resources

The DataServer is the single source of truth for raw filing records. It owns a local SQLite database pre-seeded with 20 realistic UCC-9 filings, and it exposes search, fetch, and entity-summary capabilities over HTTP.

Analogy — The Filing Cabinet Clerk

BEFORE: Imagine a government office with a massive filing cabinet full of UCC-1 forms. Every form is paper. Finding anything requires you to know exactly which drawer to open.

PAIN: Without a DataServer, your Claude host would have to ship the entire SQLite file in context — that's thousands of tokens and no guarantee the right record even appears. Worse, every query would scan the entire database on the Claude side, burning tokens and losing precision.

MAPPING: The DataServer is the filing clerk who knows the cabinet. You describe what you're looking for, and the clerk hands you a precise set of matching forms — no browsing, no overload. Claude just asks search_filings("ACME") and the clerk returns exactly the three rows that match.

STEP 1

Create the SQLite schema and seed data

Save this as data_server/seed.sql. It creates the filings table and inserts 20 realistic UCC-9 records.

data_server/seed.sql
CREATE TABLE IF NOT EXISTS filings (
    filing_id       TEXT PRIMARY KEY,
    debtor_name     TEXT NOT NULL,
    secured_party   TEXT NOT NULL,
    filing_date     TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'OPEN',
    collateral_type TEXT NOT NULL,
    state           TEXT NOT NULL DEFAULT 'CA',
    amount_usd      INTEGER
);

INSERT OR IGNORE INTO filings VALUES
  ('FL-2024-001','ACME Industrial LLC','First National Bank','2024-03-15','OPEN','Equipment','CA',450000),
  ('FL-2024-002','ACME Industrial LLC','Pacific Capital Finance','2024-06-02','OPEN','Accounts Receivable','CA',125000),
  ('FL-2024-003','ACME Industrial LLC','SBA','2023-11-10','OPEN','All Assets','CA',800000),
  ('FL-2023-008','Acme Industrial','Western Alliance Bank','2023-04-01','TERMINATED','Equipment','CA',200000),
  ('FL-2024-010','Bright Future Energy Corp','Green Capital LLC','2024-01-22','OPEN','Equipment','TX',320000),
  ('FL-2024-011','Bright Future Energy Corp','Texas Commerce Bank','2024-05-30','OPEN','All Assets','TX',1200000),
  ('FL-2022-015','Hartman Brothers Construction','Union Pacific Bank','2022-08-19','TERMINATED','Equipment','WA',90000),
  ('FL-2023-019','Hartman Brothers Construction','Pacific Builders Credit','2023-12-01','OPEN','Inventory','WA',185000),
  ('FL-2024-020','Summit Ridge Logistics LLC','FedEx Capital','2024-02-14','OPEN','Accounts Receivable','OR',67000),
  ('FL-2024-021','Summit Ridge Logistics LLC','US Bank','2024-04-09','OPEN','Equipment','OR',430000),
  ('FL-2023-025','Consolidated Foods Group Inc','AgriBank','2023-07-30','OPEN','Inventory','IA',550000),
  ('FL-2022-031','Consolidated Foods Group','Midwest Agricultural Finance','2022-01-15','TERMINATED','Inventory','IA',120000),
  ('FL-2024-035','Nexus Tech Solutions','Silicon Valley Bank','2024-03-01','OPEN','All Assets','CA',2500000),
  ('FL-2024-036','Nexus Technologies','Venture Lending','2024-07-11','OPEN','Equipment','CA',300000),
  ('FL-2023-040','Blue Sky Aviation Partners','Chase Bank','2023-09-22','OPEN','Equipment','FL',4800000),
  ('FL-2024-045','Meridian Healthcare Systems','Healthcare Finance Partners','2024-01-08','OPEN','Accounts Receivable','NY',750000),
  ('FL-2022-050','Old Town Restaurant Group','Restaurant Finance Corp','2022-06-14','TERMINATED','Equipment','IL',45000),
  ('FL-2024-055','Pacific Rim Imports LLC','US Bank','2024-04-27','OPEN','Inventory','WA',220000),
  ('FL-2023-060','Delta Construction Partners','Southern Construction Finance','2023-10-03','OPEN','Equipment','GA',890000),
  ('FL-2024-065','Ironwood Manufacturing Inc','Great Lakes Capital','2024-05-15','OPEN','All Assets','MI',1750000);
STEP 2

Build the DataServer

Create data_server/server.py. It creates the database on first run, then exposes tools and resources over HTTP via FastMCPFastMCP — a Python library that makes writing MCP servers as simple as decorating functions. It handles JSON-RPC serialization, transport negotiation, and tool schema generation automatically..

WHAT
Imports, database initialization, and seeding from SQL file on startup.
WHY
FastMCP's @mcp.tool() decorator auto-generates the JSON Schema for Claude from Python type hints — no manual schema writing required.
GOTCHA
SQLite LIKE search is case-insensitive by default for ASCII but NOT for Unicode characters. For production use, add COLLATE NOCASE or use FTS5 virtual tables.
data_server/server.py
"""DataServer — MCP server for UCC filing data (FastMCP + SQLite)."""

import sqlite3
import json
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.resources import Resource

# ── WHAT: create FastMCP instance with server name and HTTP transport
# ── WHY: name becomes the server identifier visible in Claude's tool list
mcp = FastMCP("DataServer", port=8001)

DB_PATH = Path(__file__).parent / "ucc_filings.db"
SEED_SQL = Path(__file__).parent / "seed.sql"


def get_db() -> sqlite3.Connection:
    """Return a thread-safe SQLite connection with dict-style rows."""
    conn = sqlite3.connect(str(DB_PATH))
    conn.row_factory = sqlite3.Row
    return conn


def init_db() -> None:
    """Create schema and seed data if the database doesn't exist yet."""
    with get_db() as conn:
        conn.executescript(SEED_SQL.read_text())
    print(f"[DataServer] Database initialized at {DB_PATH}")


# ── WHAT: search_filings — full-text LIKE search on debtor_name
# ── WHY: the LLM calls this first; broad matching lets it find variants
# ── GOTCHA: always validate `limit` to prevent table scans with limit=99999
@mcp.tool()
def search_filings(debtor_name: str, limit: int = 10) -> list[dict]:
    """Search UCC filings by debtor name using full-text LIKE matching.

    Args:
        debtor_name: Company or entity name to search for (partial match OK)
        limit: Maximum number of results to return (default 10, max 50)

    Returns:
        List of filing records as dicts.
    """
    limit = min(max(1, limit), 50)  # clamp to [1, 50]
    with get_db() as conn:
        rows = conn.execute(
            "SELECT * FROM filings WHERE debtor_name LIKE ? LIMIT ?",
            (f"%{debtor_name}%", limit)
        ).fetchall()
    return [dict(row) for row in rows]


@mcp.tool()
def get_filing(filing_id: str) -> dict:
    """Fetch a single UCC filing by its unique filing ID.

    Args:
        filing_id: The filing primary key (e.g. 'FL-2024-001')

    Returns:
        Complete filing record as a dict.

    Raises:
        ValueError: If no filing with that ID exists.
    """
    with get_db() as conn:
        row = conn.execute(
            "SELECT * FROM filings WHERE filing_id = ?", (filing_id,)
        ).fetchone()
    if row is None:
        raise ValueError(f"No filing found with ID: {filing_id}")
    return dict(row)


@mcp.tool()
def list_secured_parties(debtor_name: str) -> list[str]:
    """Return all distinct secured party names for a debtor (any status).

    Args:
        debtor_name: Debtor name to look up (partial match OK)

    Returns:
        List of unique secured party name strings.
    """
    with get_db() as conn:
        rows = conn.execute(
            "SELECT DISTINCT secured_party FROM filings "
            "WHERE debtor_name LIKE ?",
            (f"%{debtor_name}%",)
        ).fetchall()
    return [row["secured_party"] for row in rows]


# ── WHAT: resource handler for filing://{filing_id} URI scheme
# ── WHY: resources let Claude attach structured data to context without
#         counting against the tool call budget
@mcp.resource("filing://{filing_id}")
def filing_resource(filing_id: str) -> Resource:
    """MCP resource: returns a single filing as a JSON resource."""
    data = get_filing(filing_id)  # reuse tool logic — raises on not found
    return Resource(
        uri=f"filing://{filing_id}",
        name=f"Filing {filing_id}",
        mime_type="application/json",
        content=json.dumps(data, indent=2)
    )


@mcp.resource("entity://{entity_name}/summary")
def entity_summary_resource(entity_name: str) -> Resource:
    """MCP resource: inline-computed entity risk summary."""
    with get_db() as conn:
        rows = conn.execute(
            "SELECT * FROM filings WHERE debtor_name LIKE ?",
            (f"%{entity_name}%",)
        ).fetchall()
    filings = [dict(r) for r in rows]
    summary = {
        "entity_name": entity_name,
        "total_filings": len(filings),
        "open_count": sum(1 for f in filings if f["status"] == "OPEN"),
        "terminated_count": sum(1 for f in filings if f["status"] == "TERMINATED"),
        "collateral_types": list({f["collateral_type"] for f in filings}),
        "secured_parties": list({f["secured_party"] for f in filings}),
    }
    return Resource(
        uri=f"entity://{entity_name}/summary",
        name=f"Entity Summary — {entity_name}",
        mime_type="application/json",
        content=json.dumps(summary, indent=2)
    )


if __name__ == "__main__":
    init_db()
    mcp.run(transport="http", host="0.0.0.0", port=8001)
data_server/server.ts
/**
 * DataServer — MCP server for UCC filing data (TypeScript / @modelcontextprotocol/sdk)
 * Run: npx ts-node server.ts
 * Deps: npm install @modelcontextprotocol/sdk better-sqlite3 @types/better-sqlite3
 */

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import Database from "better-sqlite3";
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
import * as http from "http";

const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(__dirname, "ucc_filings.db");
const SEED_PATH = join(__dirname, "seed.sql");

// ── WHAT: open (or create) the SQLite database and seed on first run
const db = new Database(DB_PATH);
db.exec(readFileSync(SEED_PATH, "utf-8"));
console.log("[DataServer] Database ready at", DB_PATH);

const server = new McpServer({ name: "DataServer", version: "1.0.0" });

// ── WHAT: search_filings — full-text LIKE search
// ── WHY: TypeScript SDK uses zod schemas for parameter validation
server.tool(
  "search_filings",
  "Search UCC filings by debtor name using LIKE matching",
  {
    debtor_name: z.string().describe("Company or entity name (partial OK)"),
    limit: z.number().int().min(1).max(50).default(10),
  },
  async ({ debtor_name, limit }) => {
    const rows = db.prepare(
      "SELECT * FROM filings WHERE debtor_name LIKE ? LIMIT ?"
    ).all(`%${debtor_name}%`, limit);
    return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
  }
);

server.tool(
  "get_filing",
  "Fetch a single UCC filing by filing ID",
  { filing_id: z.string().describe("Filing primary key e.g. FL-2024-001") },
  async ({ filing_id }) => {
    const row = db.prepare("SELECT * FROM filings WHERE filing_id = ?").get(filing_id);
    if (!row) throw new Error(`No filing found with ID: ${filing_id}`);
    return { content: [{ type: "text", text: JSON.stringify(row, null, 2) }] };
  }
);

server.tool(
  "list_secured_parties",
  "Return distinct secured parties for a debtor",
  { debtor_name: z.string() },
  async ({ debtor_name }) => {
    const rows = db.prepare(
      "SELECT DISTINCT secured_party FROM filings WHERE debtor_name LIKE ?"
    ).all(`%${debtor_name}%`) as { secured_party: string }[];
    const parties = rows.map(r => r.secured_party);
    return { content: [{ type: "text", text: JSON.stringify(parties) }] };
  }
);

// ── WHAT: start the HTTP transport on port 8001
const transport = new StreamableHTTPServerTransport({ port: 8001 });
await server.connect(transport);
console.log("[DataServer] Listening on http://localhost:8001");
STEP 3

Run and verify

run data server
cd data_server && python server.py
[DataServer] Database initialized at .../data_server/ucc_filings.db [DataServer] FastMCP server 'DataServer' running on http://0.0.0.0:8001 [DataServer] Tools: search_filings, get_filing, list_secured_parties [DataServer] Resources: filing://{filing_id}, entity://{entity_name}/summary
verify with mcp dev
mcp dev data_server/server.py --transport http --port 8001
What Just Happened?

FastMCP introspected your function signatures and generated MCP-compliant JSON schemas for all three tools. The row_factory = sqlite3.Row means results come back as dict-like objects that serialize cleanly to JSON. Your resources are callable via their URI templates and return structured JSON payloads Claude can read as context.

Lab 1 Checkpoint
  • ucc_filings.db exists with 20 rows
  • search_filings("ACME") returns 4 rows
  • get_filing("FL-2024-001") returns the ACME Equipment filing
  • Server stays alive on port 8001 — leave it running for Lab 4
2

Lab 2 — ComputeServer

45 min · rapidfuzz · Risk Scoring · Deduplication

The ComputeServer handles all fuzzy logic: entity name matching, risk scoring, and deduplication. It has no database of its own — it computes on data passed to it as arguments. This makes it stateless and trivially scalable.

What is rapidfuzz WRatio?

rapidfuzzrapidfuzz — a fast string matching library (Rust-backed Python bindings). Computes multiple fuzzy ratios in a single pass, much faster than fuzzywuzzy. is a fast fuzzy string matching library. WRatioWRatio — "Weighted Ratio". Applies multiple ratio algorithms (simple, partial, token-sort, token-set) and returns the best score 0–100. It handles reordered words, abbreviations, and partial matches gracefully. is its best-of-multiple-algorithms scorer: it tries simple ratio, partial ratio, token sort, and token set, then picks the highest. "ACME Industrial LLC" vs "Acme Industrial" → WRatio: 97. "ACME Industrial LLC" vs "Pacific Rim Imports" → WRatio: 32. This is exactly what you need to normalize debtor names across different filing variants.

WHAT
Three tools: entity matching, risk scoring, and name deduplication — all pure functions with no side effects.
WHY
Separating compute from data means you can update the scoring algorithm without touching the database, and run the ComputeServer on a different machine if needed.
GOTCHA
threshold in match_entity is 0.0–1.0 (fraction) but WRatio returns 0–100. Multiply by 100 before comparing. Easy to get wrong.
compute_server/server.py
"""ComputeServer — entity matching, risk scoring, deduplication via FastMCP."""

from fastmcp import FastMCP
from rapidfuzz import fuzz

mcp = FastMCP("ComputeServer", port=8002)


# ── WHAT: match_entity — finds best-matching names from a candidate list
# ── WHY: UCC filings use inconsistent names. "ACME Industrial LLC" and
#         "Acme Industrial" are the same entity but won't match exactly.
# ── GOTCHA: threshold is 0.0–1.0; WRatio returns 0–100, so multiply.
@mcp.tool()
def match_entity(
    query: str,
    candidates: list[str],
    threshold: float = 0.8
) -> list[dict]:
    """Fuzzy-match query against candidates using rapidfuzz WRatio.

    Args:
        query: The entity name to match (e.g. "ACME Industrial")
        candidates: List of candidate names to compare against
        threshold: Minimum similarity score 0.0–1.0 (default 0.8 = 80%)

    Returns:
        List of {name: str, score: float} sorted by score descending,
        filtered to results >= threshold.
    """
    min_score = threshold * 100
    results = []
    for candidate in candidates:
        score = fuzz.WRatio(query, candidate)
        if score >= min_score:
            results.append({"name": candidate, "score": round(score, 1)})
    return sorted(results, key=lambda x: x["score"], reverse=True)


# ── WHAT: score_risk — compute a numeric risk score for a debtor entity
# ── WHY: converts raw filing counts into an actionable 0–100 score with tier
# ── GOTCHA: cap at 100 — open liens * 15 can easily exceed 100 for serial borrowers
@mcp.tool()
def score_risk(
    debtor_name: str,
    filing_count: int,
    open_liens: int,
    collateral_types: list[str]
) -> dict:
    """Compute a risk score for a debtor based on filing data.

    Args:
        debtor_name: Entity name (used for factor labeling only)
        filing_count: Total number of filings (all statuses)
        open_liens: Number of currently OPEN liens
        collateral_types: List of collateral type strings

    Returns:
        {score: 0-100, tier: "low"|"medium"|"high"|"critical", factors: list[str]}
    """
    score = open_liens * 15
    factors = []

    # Collateral type bonuses
    collateral_set = {c.lower() for c in collateral_types}
    if "all assets" in collateral_set:
        score += 20
        factors.append("blanket lien on all assets (+20)")
    if "accounts receivable" in collateral_set:
        score += 10
        factors.append("accounts receivable pledged (+10)")
    if "equipment" in collateral_set:
        score += 5
        factors.append("equipment collateral (+5)")

    # Volume factor
    if filing_count >= 5:
        score += 10
        factors.append(f"high filing volume: {filing_count} total filings (+10)")

    score = min(100, score)
    factors.insert(0, f"{open_liens} open lien(s) base score: {open_liens * 15}")

    if score < 25:
        tier = "low"
    elif score < 50:
        tier = "medium"
    elif score < 75:
        tier = "high"
    else:
        tier = "critical"

    return {
        "debtor_name": debtor_name,
        "score": score,
        "tier": tier,
        "factors": factors,
        "filing_count": filing_count,
        "open_liens": open_liens,
    }


# ── WHAT: deduplicate_names — cluster similar name variants into canonical forms
# ── WHY: the same company might be filed as "ACME LLC", "ACME Industrial LLC",
#         "Acme Industrial" — dedup before storing in ChromaDB
@mcp.tool()
def deduplicate_names(names: list[str]) -> list[dict]:
    """Cluster similar entity names and return canonical name + aliases.

    Args:
        names: List of entity name strings to deduplicate

    Returns:
        List of {canonical: str, aliases: list[str], count: int}
    """
    if not names:
        return []

    clusters: list[dict] = []
    used = set()

    for i, name in enumerate(names):
        if i in used:
            continue
        cluster = {"canonical": name, "aliases": [], "count": 1}
        used.add(i)

        for j, other in enumerate(names):
            if j in used:
                continue
            if fuzz.WRatio(name, other) >= 85:
                cluster["aliases"].append(other)
                cluster["count"] += 1
                used.add(j)

        clusters.append(cluster)

    return sorted(clusters, key=lambda c: c["count"], reverse=True)


if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8002)
compute_server/server.ts
/**
 * ComputeServer — entity matching, risk scoring (TypeScript)
 * Deps: npm install @modelcontextprotocol/sdk fuzzball zod
 * Note: fuzzball is a JS port of fuzzywuzzy / rapidfuzz
 */

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as fuzz from "fuzzball";
import { z } from "zod";

const server = new McpServer({ name: "ComputeServer", version: "1.0.0" });

server.tool(
  "match_entity",
  "Fuzzy-match a query against candidates using WRatio scoring",
  {
    query: z.string().describe("Entity name to match"),
    candidates: z.array(z.string()).describe("List of candidate names"),
    threshold: z.number().min(0).max(1).default(0.8),
  },
  async ({ query, candidates, threshold }) => {
    const minScore = threshold * 100;
    const results = candidates
      .map(name => ({ name, score: fuzz.WRatio(query, name) }))
      .filter(r => r.score >= minScore)
      .sort((a, b) => b.score - a.score);
    return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
  }
);

server.tool(
  "score_risk",
  "Compute 0-100 risk score for a debtor based on filing data",
  {
    debtor_name: z.string(),
    filing_count: z.number().int().nonnegative(),
    open_liens: z.number().int().nonnegative(),
    collateral_types: z.array(z.string()),
  },
  async ({ debtor_name, filing_count, open_liens, collateral_types }) => {
    let score = open_liens * 15;
    const factors: string[] = [`${open_liens} open lien(s): ${open_liens * 15}`];
    const cSet = new Set(collateral_types.map(c => c.toLowerCase()));

    if (cSet.has("all assets")) { score += 20; factors.push("blanket lien (+20)"); }
    if (cSet.has("accounts receivable")) { score += 10; factors.push("AR pledged (+10)"); }
    if (cSet.has("equipment")) { score += 5; factors.push("equipment (+5)"); }
    if (filing_count >= 5) { score += 10; factors.push(`high volume: ${filing_count} filings (+10)`); }

    score = Math.min(100, score);
    const tier = score < 25 ? "low" : score < 50 ? "medium" : score < 75 ? "high" : "critical";

    const result = { debtor_name, score, tier, factors, filing_count, open_liens };
    return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
  }
);

server.tool(
  "deduplicate_names",
  "Cluster similar entity name variants into canonical groups",
  { names: z.array(z.string()) },
  async ({ names }) => {
    const used = new Set();
    const clusters: { canonical: string; aliases: string[]; count: number }[] = [];
    for (let i = 0; i < names.length; i++) {
      if (used.has(i)) continue;
      const cluster = { canonical: names[i], aliases: [] as string[], count: 1 };
      used.add(i);
      for (let j = 0; j < names.length; j++) {
        if (used.has(j)) continue;
        if (fuzz.WRatio(names[i], names[j]) >= 85) {
          cluster.aliases.push(names[j]);
          cluster.count++;
          used.add(j);
        }
      }
      clusters.push(cluster);
    }
    clusters.sort((a, b) => b.count - a.count);
    return { content: [{ type: "text", text: JSON.stringify(clusters, null, 2) }] };
  }
);

const transport = new StreamableHTTPServerTransport({ port: 8002 });
await server.connect(transport);
console.log("[ComputeServer] Listening on http://localhost:8002");
run compute server
cd compute_server && python server.py
[ComputeServer] FastMCP server 'ComputeServer' running on http://0.0.0.0:8002 [ComputeServer] Tools: match_entity, score_risk, deduplicate_names
Risk Score Gauge — Animated (0 → 72 → "HIGH")
ACME Industrial LLC — Score
0 LOW CRIT MEDIUM
Lab 2 Checkpoint
  • match_entity("ACME Industrial", ["ACME Industrial LLC", "Acme Industrial", "Pacific Rim"]) returns 2 results with scores > 80
  • score_risk("ACME", 4, 3, ["Equipment","Accounts Receivable","All Assets"]) returns score=80, tier="critical"
  • Server stays alive on port 8002
3

Lab 3 — StorageServer

30 min · ChromaDB · sentence-transformers · Semantic Search

The StorageServer maintains a ChromaDBChromaDB — an open-source vector database designed for AI applications. It stores embeddings alongside metadata and enables fast approximate nearest-neighbor search. Runs entirely in-process — no Docker needed for development. vector store of previously analyzed entities. When the agent encounters a new debtor name, it first checks whether a similar entity has been analyzed before, then stores the result for future queries — building institutional memory across sessions.

What are sentence embeddings?

A sentence embedding converts text into a fixed-length vector of numbers (e.g. 384 dimensions for all-MiniLM-L6-v2) that encodes semantic meaning. "ACME Industrial LLC" and "Acme Manufacturing Corp" would be close in embedding space even though they share no characters. This is how find_similar can surface "related entities you haven't asked about yet" — not by string matching, but by conceptual proximity.

WHAT
Three tools: store an entity (embed + upsert), find similar entities (semantic search), and retrieve entity history (chronological snapshots).
WHY
Using sentence-transformers/all-MiniLM-L6-v2 for local embeddings means no API key, no latency, and no per-call cost for embedding operations.
GOTCHA
ChromaDB's upsert requires a string id that is unique per document. Use a normalized version of the entity name as the ID to enable idempotent updates.
storage_server/server.py
"""StorageServer — ChromaDB vector store for UCC entity snapshots."""

import time
import json
import re
from datetime import datetime, timezone
from fastmcp import FastMCP
import chromadb
from chromadb.utils import embedding_functions

mcp = FastMCP("StorageServer", port=8003)

# ── WHAT: initialize ChromaDB with local sentence-transformer embeddings
# ── WHY: all-MiniLM-L6-v2 is fast, small (80 MB), and runs CPU-only
# ── GOTCHA: EmbeddingFunction is called lazily — import errors surface at
#            first store/query, not at server startup. Test early.
_ef = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="sentence-transformers/all-MiniLM-L6-v2"
)
_client = chromadb.Client()  # in-memory; use PersistentClient for production
_collection = _client.get_or_create_collection(
    name="ucc_entities",
    embedding_function=_ef,
    metadata={"hnsw:space": "cosine"},
)


def _normalize_id(name: str) -> str:
    """Convert entity name to a safe ChromaDB document ID."""
    return re.sub(r"[^a-z0-9_-]", "-", name.lower().strip())


@mcp.tool()
def store_entity(
    entity_name: str,
    filing_ids: list[str],
    metadata: dict
) -> str:
    """Embed and upsert an entity snapshot into ChromaDB.

    Args:
        entity_name: Canonical entity name (used as embedding text + ID basis)
        filing_ids: List of associated filing IDs
        metadata: Arbitrary metadata dict (risk score, tier, timestamp, etc.)

    Returns:
        The document ID stored in ChromaDB.
    """
    doc_id = _normalize_id(entity_name)
    # Merge filing_ids into metadata as JSON string (ChromaDB metadata is flat)
    full_metadata = {
        **{k: str(v) for k, v in metadata.items()},
        "filing_ids": json.dumps(filing_ids),
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "entity_name": entity_name,
    }
    _collection.upsert(
        ids=[doc_id],
        documents=[entity_name],  # what gets embedded
        metadatas=[full_metadata],
    )
    return doc_id


@mcp.tool()
def find_similar(query: str, limit: int = 5) -> list[dict]:
    """Semantic search: find entities similar to the query.

    Args:
        query: Free-text query (entity name, description, or question)
        limit: Maximum results to return (default 5)

    Returns:
        List of {entity_name, score, filing_ids, metadata} sorted by similarity.
    """
    limit = min(max(1, limit), 20)
    results = _collection.query(
        query_texts=[query],
        n_results=min(limit, _collection.count() or 1),
        include=["documents", "distances", "metadatas"],
    )
    output = []
    for doc, dist, meta in zip(
        results["documents"][0],
        results["distances"][0],
        results["metadatas"][0],
    ):
        filing_ids = json.loads(meta.get("filing_ids", "[]"))
        output.append({
            "entity_name": meta.get("entity_name", doc),
            "score": round(1 - dist, 4),  # cosine: 1 = identical
            "filing_ids": filing_ids,
            "metadata": {k: v for k, v in meta.items()
                         if k not in ("filing_ids", "entity_name")},
        })
    return sorted(output, key=lambda x: x["score"], reverse=True)


@mcp.tool()
def get_entity_history(entity_name: str) -> list[dict]:
    """Retrieve all stored snapshots for an entity, newest first.

    Args:
        entity_name: Entity name to look up (exact or partial)

    Returns:
        List of snapshot dicts with filing_ids and metadata, sorted by timestamp.
    """
    # ChromaDB doesn't support WHERE on metadata natively in all versions;
    # fetch all and filter in Python for compatibility.
    total = _collection.count()
    if total == 0:
        return []
    all_results = _collection.get(
        include=["documents", "metadatas"],
        limit=total
    )
    matches = []
    for doc, meta in zip(all_results["documents"], all_results["metadatas"]):
        stored_name = meta.get("entity_name", doc)
        if entity_name.lower() in stored_name.lower():
            filing_ids = json.loads(meta.get("filing_ids", "[]"))
            matches.append({
                "entity_name": stored_name,
                "timestamp": meta.get("timestamp", ""),
                "filing_ids": filing_ids,
                "metadata": {k: v for k, v in meta.items()
                             if k not in ("filing_ids", "entity_name")},
            })
    return sorted(matches, key=lambda x: x["timestamp"], reverse=True)


if __name__ == "__main__":
    mcp.run(transport="http", host="0.0.0.0", port=8003)
storage_server/server.ts
/**
 * StorageServer — ChromaDB vector store (TypeScript)
 * Deps: npm install @modelcontextprotocol/sdk chromadb zod
 * Note: ChromaDB JS client connects to a running Chroma HTTP server.
 *       Start it with: docker run -p 8000:8000 chromadb/chroma
 */

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { ChromaClient, Collection } from "chromadb";
import { z } from "zod";

const server = new McpServer({ name: "StorageServer", version: "1.0.0" });
const chroma = new ChromaClient({ path: "http://localhost:8000" });

let collection: Collection;
async function getCollection(): Promise {
  if (!collection) {
    collection = await chroma.getOrCreateCollection({ name: "ucc_entities" });
  }
  return collection;
}

const normalizeId = (name: string) =>
  name.toLowerCase().trim().replace(/[^a-z0-9_-]/g, "-");

server.tool(
  "store_entity",
  "Embed and upsert an entity snapshot into ChromaDB",
  {
    entity_name: z.string(),
    filing_ids: z.array(z.string()),
    metadata: z.record(z.unknown()),
  },
  async ({ entity_name, filing_ids, metadata }) => {
    const coll = await getCollection();
    const id = normalizeId(entity_name);
    const flatMeta: Record = {
      ...Object.fromEntries(Object.entries(metadata).map(([k, v]) => [k, String(v)])),
      filing_ids: JSON.stringify(filing_ids),
      timestamp: new Date().toISOString(),
      entity_name,
    };
    await coll.upsert({ ids: [id], documents: [entity_name], metadatas: [flatMeta] });
    return { content: [{ type: "text", text: JSON.stringify({ id }) }] };
  }
);

server.tool(
  "find_similar",
  "Semantic search: find entities similar to the query string",
  { query: z.string(), limit: z.number().int().min(1).max(20).default(5) },
  async ({ query, limit }) => {
    const coll = await getCollection();
    const count = await coll.count();
    if (count === 0) return { content: [{ type: "text", text: "[]" }] };
    const results = await coll.query({
      queryTexts: [query],
      nResults: Math.min(limit, count),
      include: ["documents", "distances", "metadatas"] as any,
    });
    const output = results.documents[0].map((doc, i) => ({
      entity_name: (results.metadatas[0][i] as any)?.entity_name ?? doc,
      score: +(1 - (results.distances![0][i] ?? 0)).toFixed(4),
      filing_ids: JSON.parse((results.metadatas[0][i] as any)?.filing_ids ?? "[]"),
    }));
    return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
  }
);

server.tool(
  "get_entity_history",
  "Retrieve stored snapshots for an entity, newest first",
  { entity_name: z.string() },
  async ({ entity_name }) => {
    const coll = await getCollection();
    const count = await coll.count();
    if (count === 0) return { content: [{ type: "text", text: "[]" }] };
    const all = await coll.get({ include: ["documents", "metadatas"] as any, limit: count });
    const matches = all.documents
      .map((doc, i) => ({ doc, meta: all.metadatas[i] as Record }))
      .filter(({ meta }) => (meta?.entity_name ?? "").toLowerCase().includes(entity_name.toLowerCase()))
      .map(({ meta }) => ({
        entity_name: meta.entity_name,
        timestamp: meta.timestamp,
        filing_ids: JSON.parse(meta.filing_ids ?? "[]"),
      }))
      .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
    return { content: [{ type: "text", text: JSON.stringify(matches, null, 2) }] };
  }
);

const transport = new StreamableHTTPServerTransport({ port: 8003 });
await server.connect(transport);
console.log("[StorageServer] Listening on http://localhost:8003");
run storage server
cd storage_server && python server.py
[StorageServer] Loading sentence-transformers/all-MiniLM-L6-v2 (first run: ~5s) ... [StorageServer] FastMCP server 'StorageServer' running on http://0.0.0.0:8003 [StorageServer] Tools: store_entity, find_similar, get_entity_history
What Just Happened?

On first run, sentence-transformers downloads the all-MiniLM-L6-v2 model (~80 MB). It caches locally. ChromaDB stores embeddings in-memory (use chromadb.PersistentClient(path="./chroma_data") for persistence between restarts). The upsert call means re-storing an entity updates the record rather than creating a duplicate — idempotent by design.

Lab 3 Checkpoint
  • Server starts on port 8003 without import errors
  • store_entity("ACME Industrial LLC", ["FL-2024-001"], {"tier":"high"}) returns a doc ID
  • find_similar("industrial manufacturing lien") returns ACME with score > 0.4
  • Server stays alive on port 8003
4

Lab 4 — Orchestrating Host

60 min · Anthropic SDK · ReAct Loop · Tool Namespacing

The host connects to all three servers, merges their tool lists under namespace prefixes (data::, compute::, storage::), and runs a standard ReAct loop. Claude decides which tool to call at each step; the host dispatches to the right server and feeds results back into the conversation.

This is where the whole ecosystem comes together. If Labs 1–3 are three musicians playing solo, Lab 4 is the conductor who hears a single score and routes each phrase to the right instrument.
WHAT
MCPEcosystemHost: connects to all 3 servers, merges and namespaces their tools, runs a ReAct loop via the Anthropic Messages API.
WHY
Prefixing tool names with data::, compute::, storage:: eliminates collision when two servers export a tool with the same name (e.g., both could have a get_status tool). The prefix makes routing unambiguous.
GOTCHA
MCP's list_tools() returns tool schemas as Pydantic-like objects. You must convert them to the dict format Anthropic's messages.create(tools=...) expects. Don't pass the raw MCP objects directly.
host/agent.py
"""MCPEcosystemHost — orchestrates DataServer, ComputeServer, StorageServer."""

import asyncio
import json
import sys
from typing import Any
import anthropic
from mcp import ClientSession
from mcp.client.http import http_client

# ── WHAT: server URL map — prefix → HTTP endpoint
# ── WHY: centralizing the map here makes it easy to swap ports or hostnames
#         without touching the ReAct loop logic
SERVER_MAP = {
    "data":    "http://localhost:8001/mcp",
    "compute": "http://localhost:8002/mcp",
    "storage": "http://localhost:8003/mcp",
}

SYSTEM_PROMPT = """You are a UCC lien analysis assistant with access to three MCP servers:
- data:: tools: search filings, fetch by ID, list secured parties
- compute:: tools: match entity names (fuzzy), score risk, deduplicate names
- storage:: tools: store entity snapshots, find similar entities, get entity history

Always follow this workflow for any lien inquiry:
1. Use data::search_filings to find all matching filings
2. Use compute::match_entity to identify the canonical entity name
3. Use compute::score_risk with the open lien count and collateral types
4. Use storage::store_entity to persist the result
5. Synthesize a clear, factual risk summary with filing IDs and risk tier

Never guess filing data — always fetch it. Never fabricate scores."""


class MCPEcosystemHost:
    """Connects to all three MCP servers and runs a multi-server ReAct loop."""

    def __init__(self):
        self.sessions: dict[str, ClientSession] = {}
        self._tool_map: dict[str, tuple[str, str]] = {}  # prefixed_name → (prefix, raw_name)
        self._tools_for_api: list[dict] = []
        self._client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from env

    # ── WHAT: connect to all servers concurrently and merge tool lists
    async def connect(self) -> None:
        for prefix, url in SERVER_MAP.items():
            try:
                transport = await http_client(url).__aenter__()
                session = ClientSession(*transport)
                await session.__aenter__()
                await session.initialize()
                self.sessions[prefix] = session
                print(f"[Host] Connected to {prefix}:: at {url}")
            except Exception as exc:
                print(f"[Host] WARNING: could not connect to {prefix}:: — {exc}")

        await self._merge_tools()

    async def _merge_tools(self) -> None:
        """Fetch tool lists from all sessions; namespace and convert to Anthropic format."""
        for prefix, session in self.sessions.items():
            tools_result = await session.list_tools()
            for tool in tools_result.tools:
                prefixed = f"{prefix}::{tool.name}"
                self._tool_map[prefixed] = (prefix, tool.name)
                # ── WHAT: convert MCP tool schema to Anthropic tool dict
                # ── GOTCHA: MCP's inputSchema is already a JSON-Schema dict; wrap it
                self._tools_for_api.append({
                    "name": prefixed,
                    "description": tool.description or "",
                    "input_schema": (
                        tool.inputSchema if isinstance(tool.inputSchema, dict)
                        else tool.inputSchema.model_dump()
                    ),
                })
        print(f"[Host] Merged {len(self._tools_for_api)} tools from "
              f"{len(self.sessions)} servers: "
              f"{', '.join(self._tool_map.keys())}")

    async def _call_tool(self, prefixed_name: str, args: dict) -> Any:
        """Strip namespace prefix and dispatch the call to the correct server."""
        if prefixed_name not in self._tool_map:
            return {"error": f"Unknown tool: {prefixed_name}"}
        prefix, raw_name = self._tool_map[prefixed_name]
        session = self.sessions.get(prefix)
        if session is None:
            return {"error": f"Server '{prefix}' not connected"}
        try:
            result = await session.call_tool(raw_name, args)
            # MCP returns a list of content blocks; extract text from the first one
            if result.content:
                text = result.content[0].text if hasattr(result.content[0], "text") else str(result.content[0])
                try:
                    return json.loads(text)
                except json.JSONDecodeError:
                    return text
            return {}
        except Exception as exc:
            return {"error": str(exc)}

    # ── WHAT: ReAct loop — send messages to Claude, execute tool calls, repeat
    # ── WHY: "while stop_reason != end_turn" handles multi-step tool chains
    #         automatically without hardcoding how many steps the task takes
    async def run(self, query: str) -> str:
        messages = [{"role": "user", "content": query}]
        print(f"\n[Host] Query: {query}\n")

        while True:
            response = self._client.messages.create(
                model="claude-3-5-sonnet-20241022",
                max_tokens=4096,
                system=SYSTEM_PROMPT,
                tools=self._tools_for_api,
                messages=messages,
            )

            # Append assistant turn
            messages.append({"role": "assistant", "content": response.content})

            if response.stop_reason == "end_turn":
                # Extract final text
                final = next(
                    (b.text for b in response.content if hasattr(b, "text")), ""
                )
                print(f"\n[Host] Final answer:\n{final}\n")
                return final

            if response.stop_reason != "tool_use":
                break  # unexpected stop reason

            # Execute all tool calls in parallel
            tool_results = []
            for block in response.content:
                if block.type != "tool_use":
                    continue
                print(f"[Host] → {block.name}({json.dumps(block.input)[:80]}...)")
                result = await self._call_tool(block.name, block.input)
                print(f"[Host] ← result: {str(result)[:120]}")
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(result),
                })

            messages.append({"role": "user", "content": tool_results})

        return "[Host] Unexpected loop exit"

    async def close(self) -> None:
        for session in self.sessions.values():
            try:
                await session.__aexit__(None, None, None)
            except Exception:
                pass


async def main() -> None:
    query = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else (
        "Does ACME Industrial have any open liens? What is their risk score?"
    )
    host = MCPEcosystemHost()
    try:
        await host.connect()
        await host.run(query)
    finally:
        await host.close()


if __name__ == "__main__":
    asyncio.run(main())
host/agent.ts
/**
 * MCPEcosystemHost — TypeScript orchestrator
 * Deps: npm install @modelcontextprotocol/sdk @anthropic-ai/sdk
 */

import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const SERVER_MAP: Record = {
  data:    "http://localhost:8001/mcp",
  compute: "http://localhost:8002/mcp",
  storage: "http://localhost:8003/mcp",
};

const SYSTEM_PROMPT = `You are a UCC lien analysis assistant with three MCP servers.
Workflow: 1) data::search_filings → 2) compute::match_entity → 3) compute::score_risk → 4) storage::store_entity → 5) synthesize.
Never fabricate filing data or scores.`;

class MCPEcosystemHost {
  private sessions = new Map();
  private toolMap = new Map(); // prefixed → [prefix, raw]
  private toolsForApi: Anthropic.Messages.Tool[] = [];
  private anthropic = new Anthropic();

  async connect(): Promise {
    for (const [prefix, url] of Object.entries(SERVER_MAP)) {
      try {
        const transport = new StreamableHTTPClientTransport(new URL(url));
        const client = new Client({ name: "MCPEcosystemHost", version: "1.0.0" });
        await client.connect(transport);
        this.sessions.set(prefix, client);
        console.log(`[Host] Connected to ${prefix}:: at ${url}`);
      } catch (e) {
        console.warn(`[Host] Could not connect to ${prefix}::`, e);
      }
    }
    await this.mergeTools();
  }

  private async mergeTools(): Promise {
    for (const [prefix, client] of this.sessions) {
      const { tools } = await client.listTools();
      for (const tool of tools) {
        const prefixed = `${prefix}::${tool.name}`;
        this.toolMap.set(prefixed, [prefix, tool.name]);
        this.toolsForApi.push({
          name: prefixed,
          description: tool.description ?? "",
          input_schema: tool.inputSchema as Anthropic.Messages.Tool["input_schema"],
        });
      }
    }
    console.log(`[Host] Merged ${this.toolsForApi.length} tools`);
  }

  private async callTool(prefixed: string, args: Record): Promise {
    const entry = this.toolMap.get(prefixed);
    if (!entry) return { error: `Unknown tool: ${prefixed}` };
    const [prefix, rawName] = entry;
    const client = this.sessions.get(prefix);
    if (!client) return { error: `Server ${prefix} not connected` };
    try {
      const result = await client.callTool({ name: rawName, arguments: args });
      if (result.content?.[0]?.type === "text") {
        try { return JSON.parse(result.content[0].text); }
        catch { return result.content[0].text; }
      }
      return result.content;
    } catch (e) {
      return { error: String(e) };
    }
  }

  async run(query: string): Promise {
    const messages: Anthropic.Messages.MessageParam[] = [
      { role: "user", content: query },
    ];
    console.log(`\n[Host] Query: ${query}\n`);

    while (true) {
      const response = await this.anthropic.messages.create({
        model: "claude-3-5-sonnet-20241022",
        max_tokens: 4096,
        system: SYSTEM_PROMPT,
        tools: this.toolsForApi,
        messages,
      });

      messages.push({ role: "assistant", content: response.content });

      if (response.stop_reason === "end_turn") {
        const text = response.content.find(b => b.type === "text")?.text ?? "";
        console.log(`\n[Host] Final:\n${text}\n`);
        return text;
      }
      if (response.stop_reason !== "tool_use") break;

      const toolResults: Anthropic.Messages.ToolResultBlockParam[] = [];
      for (const block of response.content) {
        if (block.type !== "tool_use") continue;
        console.log(`[Host] → ${block.name}`);
        const result = await this.callTool(block.name, block.input as Record);
        toolResults.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(result) });
      }
      messages.push({ role: "user", content: toolResults });
    }
    return "[Host] Unexpected exit";
  }

  async close(): Promise {
    for (const client of this.sessions.values()) await client.close();
  }
}

const query = process.argv.slice(2).join(" ") ||
  "Does ACME Industrial have any open liens? What is their risk score?";

const host = new MCPEcosystemHost();
await host.connect();
await host.run(query);
await host.close();

End-to-End Run

With all three servers running in separate terminals, run the host:

run the full ecosystem
python host/agent.py "Does ACME Industrial have any open liens? What is their risk?"
[Host] Connected to data:: at http://localhost:8001/mcp [Host] Connected to compute:: at http://localhost:8002/mcp [Host] Connected to storage:: at http://localhost:8003/mcp [Host] Merged 9 tools: data::search_filings, data::get_filing, data::list_secured_parties, compute::match_entity, compute::score_risk, compute::deduplicate_names, storage::store_entity, storage::find_similar, storage::get_entity_history [Host] Query: Does ACME Industrial have any open liens? What is their risk? [Host] → data::search_filings({"debtor_name": "ACME Industrial", "limit": 10}... [Host] ← result: [{"filing_id": "FL-2024-001", ...}, ...] [Host] → compute::match_entity({"query": "ACME Industrial", "candidates": [...]... [Host] ← result: [{"name": "ACME Industrial LLC", "score": 97.3}] [Host] → compute::score_risk({"debtor_name": "ACME Industrial LLC", ...}... [Host] ← result: {"score": 80, "tier": "critical", "factors": [...]} [Host] → storage::store_entity({"entity_name": "ACME Industrial LLC", ...}... [Host] ← result: "acme-industrial-llc" [Host] Final answer: ACME Industrial LLC has 3 open UCC-9 liens on record: • FL-2024-001 — First National Bank — Equipment — $450,000 • FL-2024-002 — Pacific Capital Finance — Accounts Receivable — $125,000 • FL-2024-003 — SBA — All Assets — $800,000 Risk Assessment: CRITICAL (80/100) Factors: 3 open liens (base 45), blanket All Assets lien (+20), AR pledged (+10), equipment collateral (+5) Secured Parties: First National Bank, Pacific Capital Finance, SBA Recommendation: High-risk entity. Blanket lien from SBA subordinates all other creditors. Exercise due diligence before extending credit.
What Just Happened?

Claude received all 9 namespaced tools in its context. It chose to call them in the right order without being explicitly told the sequence — the system prompt described the workflow, and Claude followed it. The host stripped each data:: / compute:: / storage:: prefix before dispatching the underlying MCP call, then fed the JSON result back as a tool_result block. The loop ran 4 tool calls before Claude's stop_reason became "end_turn".

Lab 4 Checkpoint — Capstone Complete
  • Agent connects to all 3 servers and logs "Merged 9 tools"
  • Running the ACME query produces a risk tier in the final response
  • Each tool call is logged with its server prefix
  • StorageServer stores the entity and returns a document ID
  • Try: python host/agent.py "What similar entities to ACME have been analyzed before?" — the agent should call storage::find_similar

Bonus: OAuth Between Servers

~30 min · JWTJSON Web Token — a compact, URL-safe way to represent claims between two parties. A JWT is signed with a shared secret; the recipient verifies the signature without a database lookup. · Bearer token auth · Scope checks

In production, you must authenticate requests between the host and each server. Otherwise, any client on the network can call score_risk with arbitrary inputs. This bonus adds bearer tokenBearer token — an access token (typically a JWT) sent in the HTTP Authorization: Bearer <token> header. "Bearer" means whoever holds the token is authorized, so protect it in transit with HTTPS. auth with JWT signing and per-tool scope enforcement.

Scope Policy from the Spec

DataServer and StorageServer require mcp:tools:execute:read_only.
ComputeServer's score_risk requires mcp:tools:execute:all — treat it as a write operation because its output influences downstream credit decisions.

WHAT
JWT generation in the host (signed with a shared secret), and ASGI middleware on each server that validates the token and checks scopes before allowing tool execution.
GOTCHA
The shared secret must match exactly between the host and all servers. Store it in an env var (MCP_SECRET_KEY) — never hardcode it in source.
shared/auth.py — JWT helpers
"""Shared JWT auth utilities for the MCP ecosystem."""

import os
import time
import jwt  # PyJWT

SECRET = os.environ.get("MCP_SECRET_KEY", "dev-secret-change-in-production")
ALGORITHM = "HS256"


def generate_host_token(scopes: list[str], ttl_seconds: int = 300) -> str:
    """Generate a signed JWT for the host to authenticate with MCP servers.

    Args:
        scopes: List of OAuth scope strings the host is requesting
        ttl_seconds: Token lifetime in seconds (default 5 minutes)

    Returns:
        Signed JWT string to include as Authorization: Bearer 
    """
    payload = {
        "iss": "mcp-ecosystem-host",
        "sub": "host-agent",
        "scopes": scopes,
        "iat": int(time.time()),
        "exp": int(time.time()) + ttl_seconds,
    }
    return jwt.encode(payload, SECRET, algorithm=ALGORITHM)


def verify_token(token: str, required_scope: str) -> dict:
    """Verify a JWT and check that required_scope is present.

    Args:
        token: Raw JWT string (without 'Bearer ' prefix)
        required_scope: Scope string that must be in the token's scopes list

    Returns:
        Decoded payload dict if valid and authorized.

    Raises:
        PermissionError: If token invalid, expired, or missing required scope.
    """
    try:
        payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise PermissionError("Token expired")
    except jwt.InvalidTokenError as exc:
        raise PermissionError(f"Invalid token: {exc}")

    if required_scope not in payload.get("scopes", []):
        raise PermissionError(
            f"Missing scope: {required_scope}. "
            f"Token has: {payload.get('scopes', [])}"
        )
    return payload


# ── Auth middleware for FastMCP HTTP servers
# Add this to each server.py to enforce auth on all tool calls
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

class JWTAuthMiddleware(BaseHTTPMiddleware):
    """Validates Bearer JWT on all incoming MCP HTTP requests."""

    def __init__(self, app, required_scope: str = "mcp:tools:execute:read_only"):
        super().__init__(app)
        self.required_scope = required_scope

    async def dispatch(self, request: Request, call_next):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return JSONResponse(
                {"error": "Missing Authorization header"},
                status_code=401
            )
        token = auth_header.removeprefix("Bearer ")
        try:
            verify_token(token, self.required_scope)
        except PermissionError as exc:
            return JSONResponse({"error": str(exc)}, status_code=403)
        return await call_next(request)


# ── Usage in data_server/server.py (add after mcp = FastMCP(...)):
# from shared.auth import JWTAuthMiddleware
# mcp.app.add_middleware(JWTAuthMiddleware,
#                        required_scope="mcp:tools:execute:read_only")

# ── Usage in compute_server/server.py (score_risk needs :all scope):
# mcp.app.add_middleware(JWTAuthMiddleware,
#                        required_scope="mcp:tools:execute:all")
shared/auth.ts — JWT helpers
/**
 * Shared JWT auth for MCP ecosystem (TypeScript)
 * npm install jsonwebtoken @types/jsonwebtoken
 */

import * as jwt from "jsonwebtoken";
import type { RequestHandler, Request, Response, NextFunction } from "express";

const SECRET = process.env.MCP_SECRET_KEY ?? "dev-secret-change-in-production";

export function generateHostToken(scopes: string[], ttlSeconds = 300): string {
  return jwt.sign(
    { iss: "mcp-ecosystem-host", sub: "host-agent", scopes },
    SECRET,
    { expiresIn: ttlSeconds, algorithm: "HS256" }
  );
}

export function verifyToken(token: string, requiredScope: string): jwt.JwtPayload {
  let payload: jwt.JwtPayload;
  try {
    payload = jwt.verify(token, SECRET, { algorithms: ["HS256"] }) as jwt.JwtPayload;
  } catch (e) {
    throw new Error(`Invalid or expired token: ${e}`);
  }
  if (!(payload.scopes as string[]).includes(requiredScope)) {
    throw new Error(`Missing scope: ${requiredScope}`);
  }
  return payload;
}

/** Express middleware to validate JWT on all requests */
export function jwtAuthMiddleware(requiredScope: string): RequestHandler {
  return (req: Request, res: Response, next: NextFunction) => {
    const auth = req.headers.authorization ?? "";
    if (!auth.startsWith("Bearer ")) {
      res.status(401).json({ error: "Missing Authorization header" });
      return;
    }
    try {
      verifyToken(auth.slice(7), requiredScope);
      next();
    } catch (e) {
      res.status(403).json({ error: String(e) });
    }
  };
}

Update the host to generate a token for each request. Pass it in the HTTP headers when creating the MCP ClientSession:

host/agent.py — token injection snippet
from shared.auth import generate_host_token

# Generate tokens with appropriate scopes per server
TOKENS = {
    "data":    generate_host_token(["mcp:tools:execute:read_only"]),
    "compute": generate_host_token(["mcp:tools:execute:all"]),
    "storage": generate_host_token(["mcp:tools:execute:read_only"]),
}

# Pass as headers when creating the HTTP transport:
# transport = await http_client(url, headers={"Authorization": f"Bearer {TOKENS[prefix]}"}).__aenter__()

Add MCP_SECRET_KEY to docker-compose.yml as an environment variable shared across all containers so the JWT signature validates consistently.

Running the Full Stack

Once the individual servers are working, use Docker Compose to run the entire ecosystem as a coordinated set of containers with health checks and resource limits.

docker-compose.yml
version: "3.9"

services:
  # ── DataServer ──────────────────────────────────────────────────────
  data-server:
    build:
      context: .
      dockerfile: data_server/Dockerfile
    ports:
      - "8001:8001"
    environment:
      - MCP_SECRET_KEY=${MCP_SECRET_KEY:-dev-secret-change-in-production}
    volumes:
      - ucc-db:/app/data_server   # persist SQLite across restarts
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s
    deploy:
      resources:
        limits:
          memory: 256m
          cpus: "0.5"

  # ── ComputeServer ────────────────────────────────────────────────────
  compute-server:
    build:
      context: .
      dockerfile: compute_server/Dockerfile
    ports:
      - "8002:8002"
    environment:
      - MCP_SECRET_KEY=${MCP_SECRET_KEY:-dev-secret-change-in-production}
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
      interval: 10s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512m   # rapidfuzz loads native extensions
          cpus: "1.0"

  # ── StorageServer ────────────────────────────────────────────────────
  storage-server:
    build:
      context: .
      dockerfile: storage_server/Dockerfile
    ports:
      - "8003:8003"
    environment:
      - MCP_SECRET_KEY=${MCP_SECRET_KEY:-dev-secret-change-in-production}
    volumes:
      - chroma-data:/app/chroma_data   # persist vector store
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8003/health"]
      interval: 15s
      timeout: 10s
      retries: 3
      start_period: 30s   # sentence-transformers download on first boot
    deploy:
      resources:
        limits:
          memory: 1g      # sentence-transformer model ~400 MB
          cpus: "1.0"

  # ── Host (Claude Orchestrator) ───────────────────────────────────────
  host:
    build:
      context: .
      dockerfile: host/Dockerfile
    depends_on:
      data-server:
        condition: service_healthy
      compute-server:
        condition: service_healthy
      storage-server:
        condition: service_healthy
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - MCP_SECRET_KEY=${MCP_SECRET_KEY:-dev-secret-change-in-production}
      - DATA_SERVER_URL=http://data-server:8001/mcp
      - COMPUTE_SERVER_URL=http://compute-server:8002/mcp
      - STORAGE_SERVER_URL=http://storage-server:8003/mcp
    deploy:
      resources:
        limits:
          memory: 256m
          cpus: "0.5"

volumes:
  ucc-db:
  chroma-data:

Build and Run

start full stack
MCP_SECRET_KEY=my-prod-secret docker compose up --build
[+] Building 4 services... [+] Running data-server on http://localhost:8001 [+] Running compute-server on http://localhost:8002 [+] Running storage-server on http://localhost:8003 (downloading model...) [+] All services healthy. Host container ready.
run a query via docker exec
docker compose exec host python agent.py "Does ACME Industrial have any open liens?"
Dockerfile template (same pattern for all 3 servers)

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "server.py"]

What to Try Next

You've built a complete 3-server MCP ecosystem. Here are five directions to extend it:

1
Add a 4th NewsServer

Build a news_server/server.py that pulls RSS feeds from SEC EDGAR and state SOS filing portals. Add a get_recent_filings(entity_name: str, days: int = 30) tool. Now the host can answer "Have there been any new filings for ACME in the last month?" in real time.

2
Add a Redis caching layer

Wrap search_filings and score_risk with a Redis TTL cache (60 seconds). Repeat queries for the same entity become sub-millisecond. Add a cache::invalidate(entity_name) tool so the host can force a fresh fetch after a new filing is detected.

3
Integrate M10 Advanced RAG patterns

Replace the simple SQLite full-text search in DataServer with a Medallion-style pipeline: Bronze (raw filings) → Silver (cleaned, deduplicated) → Gold (enriched with risk scores). Use the HyDE pattern from M10: embed the user's query as a hypothetical document before searching ChromaDB.

4
Expose the host as its own MCP server

Wrap MCPEcosystemHost.run() as an MCP tool called analyze_entity_risk(entity_name: str). Now the whole 3-server ecosystem is callable as a single atomic tool from a higher-level orchestrator — the host becomes a server in a larger hierarchy.

5
Add OpenTelemetry tracing

Instrument every _call_tool dispatch with an OTEL span. Add trace_id propagation headers across servers. Connect to Jaeger or Grafana Tempo. You'll get a flame graph showing exactly how many milliseconds each server spent on each tool call — essential for production debugging.

Knowledge Check

Five questions covering the key decisions made in this capstone.

1. Why do we prefix tool names with data::, compute::, and storage:: before passing them to the Anthropic API?

To reduce the size of the tool schemas sent in the API request
Because FastMCP requires namespaced tool names when running in HTTP mode
To make each tool name unique across all servers and to encode which server to dispatch the call to
So that ChromaDB can index the tool names for semantic search
Correct. Two or more servers might export a tool with the same raw name (e.g., get_status). The prefix prevents collisions in the merged tool list and encodes the dispatch route, so the host can strip the prefix and call the right server.
Not quite. Prefixes serve two purposes: preventing name collisions across servers, and encoding the routing information so _call_tool knows which server to dispatch to.

2. In score_risk, why do we cap the score at 100 instead of letting it exceed that?

Because Claude's tool call response field has a maximum integer size of 100
To keep the score on a normalized 0–100 scale so the tier thresholds (25/50/75/100) are always meaningful, regardless of how many open liens exist
Because rapidfuzz WRatio also returns 0–100 and the two must be comparable
The JSON schema for score_risk declares score as a byte, which has a max of 100
Correct. A debtor with 10 open liens would otherwise score 150+ — which is meaningless to any downstream consumer. Capping at 100 keeps the output interpretable and the tier classification consistent.
The reason is about interpretability and consistent tier mapping. Scores above 100 make the "critical ≥ 75" threshold meaningless — you can't tell the difference between a score of 95 and 150.

3. The StorageServer uses ChromaDB's upsert rather than add. What practical problem does this solve?

It allows storing multiple embeddings per entity for ensemble retrieval
It bypasses the sentence-transformer embedding step for entities that already exist
Re-analyzing an entity (after a new filing appears) updates its record rather than creating duplicate entries, keeping queries clean and the risk snapshot current
It compresses multiple filing snapshots into a single vector for storage efficiency
Correct. If you used add, every time the agent ran on ACME Industrial you would accumulate multiple identical-ID entries (or get a duplicate key error). upsert is idempotent — calling it twice with the same ID just updates the record.
Think about what happens when the agent is run twice on the same entity. With add, you'd either crash (duplicate ID error) or accumulate stale copies. upsert is idempotent.

4. What does the ReAct loop's while True structure accomplish that a single API call cannot?

It allows the host to retry failed API calls automatically
It handles multi-step tool chains of arbitrary depth — Claude decides how many tool calls are needed; the loop runs until stop_reason == "end_turn"
It enables the host to call multiple tools in parallel rather than sequentially
It keeps the MCP server connections alive between user queries
Correct. A complex query might require 2 tool calls or 8 — Claude decides dynamically based on what information it has. The loop keeps feeding tool results back into the conversation until Claude signals it has everything it needs to write the final answer.
The key insight is that the number of tool call rounds is not known in advance. The loop runs until Claude's stop_reason is "end_turn" rather than "tool_use", handling any depth automatically.

5. Why does the spec assign mcp:tools:execute:all (rather than read_only) to ComputeServer's score_risk tool, even though it has no database writes?

Because score_risk calls an external API that performs a write operation internally
FastMCP requires the :all scope for any tool that returns more than a list of strings
Risk scoring has downstream consequences (credit decisions, compliance records) — a stricter scope enforces deliberate authorization before the agent can invoke it, even if no database row is changed
Because score_risk internally calls match_entity, which requires the broader scope
Correct. "Read-only" in the MCP auth sense means "low-stakes data retrieval." A risk score drives real-world credit decisions and may end up in compliance logs — that's not low-stakes, even if no row changes in a database. Tighter scopes make the authorization intent explicit and auditable.
This is an authorization philosophy question. Operations with significant downstream consequences (credit scoring, compliance, financial modeling) should require explicit, narrower authorization even when they don't modify a database. The scope boundary encodes intent and risk, not just CRUD operations.