Back to portfolio

March 2026

Giving Claude Direct Access to Student Records (Safely)

17MCP Tools
11Pydantic Models
100%Calls Audited
0TODOs Left

I use Claude Code as my primary development tool. I also manage student data in Aeries SIS daily. The natural question: what if Claude could query Aeries directly when I'm working on student-facing tools?

The naive answer is dangerous. Student records are protected under FERPA. You can't just pipe a database connection into an AI tool and hope nothing sensitive leaks into a model's context window. But with the right filtering layer, it's not just possible -- it's useful. I built an MCP server that gives Claude Code read-only access to Aeries through 17 tools, with Pydantic-enforced PII stripping and a CSV audit trail on every single invocation.

What MCP Actually Gives You

Model Context Protocol lets you expose tools to Claude that it can call during a conversation. Instead of me copying attendance data from Aeries and pasting it into the chat, Claude can call get_attendance(student_id=12345) directly and get structured JSON back.

The 17 tools cover the full Aeries API surface I need:

@mcp.tool()
async def get_student(student_id: int) -> str:
    """Look up a student by ID. Returns filtered profile."""
    if student_id <= 0:
        return json.dumps({"error": "student_id must be positive"})
    try:
        data = await aeries.get_student(student_id)
        student = StudentSummary.model_validate(data)
        log_access("get_student", {"student_id": student_id}, 1)
        return _response_json(student)
    except HTTPStatusError as e:
        return json.dumps({"error": f"Aeries returned {e.response.status_code}"})
    except TimeoutException:
        return json.dumps({"error": "Aeries request timed out"})

Schools, students, attendance, grades, enrollment history, contacts, staff, gradebooks, assignments, marking periods, and a differential sync tool for detecting recent changes. Every tool validates input, calls the Aeries API, filters the response through a Pydantic model, logs the access, and returns JSON.

The FERPA Filtering Problem

Aeries API responses are dense. A student record can include SSN, medical alerts, IEP flags, parent employment, immigration status, home address -- data that should never enter an AI model's context. The filtering layer has two modes:

class StudentSummary(BaseModel):
    student_id: int
    first_name: str
    last_name: str
    grade: int
    school_code: int
    gender: str | None = None
    birthdate: str | None = None
    home_language: str | None = None
    student_email: str | None = None

    def to_response_dict(self, strict: bool) -> dict:
        data = self.model_dump()
        if strict:
            # FERPA_STRICT removes fields that are identifying
            # but not needed for most dev/admin workflows
            for field in ["gender", "birthdate", "home_language", "student_email"]:
                data.pop(field, None)
        return data

Baseline mode: Pydantic's model validation silently drops any field not in the schema. SSN, medical, financial data -- can't be returned because it's not modeled.

FERPA_STRICT mode (default): An additional layer removes fields that are technically allowlisted but carry identifying risk beyond what's needed for typical workflows. Gender, birthdate, home language, email, phone numbers, absence reason codes, grade comments -- all stripped in strict mode.

The key insight: Pydantic's validation is the allowlist, not a blocklist. If Aeries adds a new sensitive field to their API response next week (they've done this without warning before), it gets silently dropped. The only way data enters Claude's context is through an explicitly modeled field.

The Audit Trail

Every tool invocation writes a row to a CSV audit log at ~/.aeries-mcp/audit.log:

timestamp,tool,parameters,result_count,user
2026-03-16T14:23:01Z,get_student,"{""student_id"": 12345}",1,robert
2026-03-16T14:23:04Z,get_attendance,"{""student_id"": 12345}",47,robert
2026-03-16T14:25:12Z,search_students,"{""name"": ""Garcia""}",3,robert

CSV was a deliberate choice over SQLite here. The audit log needs to be inspectable with basic tools (cat, grep, Excel). IT staff at a small district should be able to review access patterns without writing SQL or installing a viewer. The server appends only -- no UPDATE, no DELETE, no truncation logic.

Fail-Fast Config

One decision I'm glad I made early: the server refuses to start without valid configuration.

# config.py
AERIES_BASE_URL = os.getenv("AERIES_BASE_URL")
AERIES_CERT = os.getenv("AERIES_CERT")

if not AERIES_BASE_URL or not AERIES_CERT:
    print("ERROR: AERIES_BASE_URL and AERIES_CERT required")
    sys.exit(1)

# FERPA_STRICT defaults to True. You have to explicitly disable it.
FERPA_STRICT = os.getenv("FERPA_STRICT", "true").lower() == "true"

No mock fallback. No "demo mode." If you don't have real credentials, the server doesn't start. This prevents the failure mode where someone accidentally runs a development build against production and the lack of a real connection silently returns empty results.

Current State

This server is feature-complete but has never connected to a live Aeries instance. Tests validate the Pydantic filtering layer (the part that matters for FERPA). The async HTTP client is straightforward httpx with proper error handling. Once I have a valid district API certificate, it's a one-line .env change.

The same credential that blocks this blocks the SARB Tracker and the Aeries Assistant. Three projects, one blocker. District IT moves at district IT speed.

Why This Matters for My Workflow

When I'm building tools that touch student data (SARB Tracker, gradebook audits, enrollment automation), being able to ask Claude "show me attendance for student 54321" directly in the terminal -- without switching to the Aeries web UI, navigating four menus, and copying data back -- cuts the feedback loop from minutes to seconds. The MCP server is the glue between my development environment and the district's data, with the compliance layer baked in at the protocol level rather than bolted on after the fact.