Skip to main content

The classroom MCP example

examples/classroom-mcp is a small MCP server over a made-up course: six students with grades, and a forum whose posts carry names, a phone, emails, a DNI and an IBAN. Every value is invented. It shows each part of the library where a real server would use it.

The tools​

ToolWhat the model getsLibrary call
list_studentsEvery student as an alias, with grades and averageremember() with the whole list
read_forumPosts with authors as aliases and text redactedremember(), then redact() per post
student_summaryOne student's grades, asked for by aliasresolve()
grade_studentA grade set by aliasresolve() to the LMS id

The whole integration is in src/tools.ts. The core of it:

const asPerson = (student: Student): Person => ({
service: SERVICE,
identifiers: { institutionalId: student.studentNumber, email: student.email, serviceUserId: student.lmsId },
displayName: student.fullName,
});

/** Everyone's alias, in one call (one write to the directory), keyed by LMS id. */
async function aliases(): Promise<Map<string, string>> {
const list = await remember(course.students.map(asPerson));
return new Map(course.students.map((student, i) => [student.lmsId, list[i]!]));
}

And the way back, in grade_student:

// The model only knows the alias: resolve() turns it into the LMS id the write needs, on our side.
const found = byLmsId(await resolve(student, { service: SERVICE }));

What the model reads​

[
{
"author": "A-9NQWD5",
"date": "2026-10-01",
"text": "Hola, soy A-9NQWD5. ¿Alguien hace la práctica 2 conmigo? Mi móvil es [PHONE_1] y mi correo [EMAIL_1]."
},
{
"author": "A-TA8D1J",
"date": "2026-10-01",
"text": "Yo me apunto, A-9NQWD5. A-TA8D1J, del grupo B."
},
{
"author": "A-Q30XC4",
"date": "2026-10-04",
"text": "Lucía, ¿me pasas tu parte? Firmado: A-Q30XC4."
}
]

Two students are called Lucía, so "Lucía" alone stays: it could be either of them. The full name, "LUCÍA FERRÁNDIZ", is replaced. That's the rule for first names.

An alias that doesn't exist comes back as an MCP error the model can read:

Unknown alias A-000000: nobody with it is in the directory.

Run it​

npm install # at the repository's root: builds the library
cd examples/classroom-mcp
npm install
npm run demo # every tool's output, then the CLI commands to try
npm test # no tool output may contain any personal value

The demo keeps its key and directory in examples/classroom-mcp/.pseudonym/, never in your real folder, and ends by printing the pseudonym commands that let you play the teacher:

$env:PSEUDONYM_HOME = "<the example's folder>\.pseudonym"
npx pseudonym whois A-9NQWD5

Try it with Claude Code​

npm run build
claude mcp add classroom -e PSEUDONYM_HOME=/abs/path/to/examples/classroom-mcp/.pseudonym -- node /abs/path/to/examples/classroom-mcp/dist/server.js

Run it through node, not npm start: npm prints to stdout, which is the MCP protocol.

Then ask, for example: "List the students of the classroom, give the one with the lowest practice1 grade a 5 in the exam, and summarize what the forum is about."

We tried exactly that with claude -p: the model only saw aliases, graded A-4C2Z5W, and noticed the ambiguous "Lucía" as the one name left in the forum. whois A-4C2Z5W in that directory gave Pablo Lorente, who had the lowest grade. Keep your agent away from the example's .pseudonym/ folder and the CLI as you would from the real ones: see Keeping agents away from the key.

The test​

test/tools.test.ts calls every tool through MCP, with an in-process client and a new PSEUDONYM_HOME per test, and checks that no output contains any name, email, student number, LMS id, phone, DNI or IBAN from the data; that aliases are stable; that the right student is graded; and that an unknown alias is an error. It's the pattern described in Testing your integration.