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.
What the Agent Can Do
When you run the finished system, you can ask:
python host/agent.py "Does ACME Industrial have any open liens? What is their risk?"Prerequisites
Before starting, verify you have:
Run this once in your project root before starting Lab 1:
pip install fastmcp anthropic rapidfuzz chromadb sentence-transformers PyJWT
Time Budget
| Lab | Focus | Est. Time |
|---|---|---|
| Lab 1 | DataServer — SQLite filings, full-text search, resources | 45 min |
| Lab 2 | ComputeServer — entity matching, risk scoring | 45 min |
| Lab 3 | StorageServer — ChromaDB vector store, similarity search | 30 min |
| Lab 4 | Orchestrating Host — merge tool lists, ReAct loop | 60 min |
| Bonus | OAuth, Docker Compose full stack | 45 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.
# 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
Lab 1 — DataServer
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.
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.
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.
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);
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..
@mcp.tool() decorator auto-generates the JSON Schema for Claude from Python type hints — no manual schema writing required.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."""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)
/**
* 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");
Run and verify
cd data_server && python server.pymcp dev data_server/server.py --transport http --port 8001FastMCP 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.
ucc_filings.dbexists with 20 rowssearch_filings("ACME")returns 4 rowsget_filing("FL-2024-001")returns the ACME Equipment filing- Server stays alive on port 8001 — leave it running for Lab 4
Lab 2 — ComputeServer
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.
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.
threshold in match_entity is 0.0–1.0 (fraction) but WRatio returns 0–100. Multiply by 100 before comparing. Easy to get wrong."""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)
/**
* 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");
cd compute_server && python server.pymatch_entity("ACME Industrial", ["ACME Industrial LLC", "Acme Industrial", "Pacific Rim"])returns 2 results with scores > 80score_risk("ACME", 4, 3, ["Equipment","Accounts Receivable","All Assets"])returns score=80, tier="critical"- Server stays alive on port 8002
Lab 3 — StorageServer
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.
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.
sentence-transformers/all-MiniLM-L6-v2 for local embeddings means no API key, no latency, and no per-call cost for embedding operations.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."""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)
/**
* 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");
cd storage_server && python server.pyOn 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.
- Server starts on port 8003 without import errors
store_entity("ACME Industrial LLC", ["FL-2024-001"], {"tier":"high"})returns a doc IDfind_similar("industrial manufacturing lien")returns ACME with score > 0.4- Server stays alive on port 8003
Lab 4 — Orchestrating Host
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.
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.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."""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())
/**
* 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:
python host/agent.py "Does ACME Industrial have any open liens? What is their risk?"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".
- 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 callstorage::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.
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.
MCP_SECRET_KEY) — never hardcode it in source."""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 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:
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.
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
MCP_SECRET_KEY=my-prod-secret docker compose up --builddocker compose exec host python agent.py "Does ACME Industrial have any open liens?"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:
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.
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.
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.
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.
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?
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._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?
score_risk declares score as a byte, which has a max of 100
3. The StorageServer uses ChromaDB's upsert rather than add. What practical problem does this solve?
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.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?
stop_reason == "end_turn"
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?
score_risk calls an external API that performs a write operation internally
:all scope for any tool that returns more than a list of strings
score_risk internally calls match_entity, which requires the broader scope