Build Your First MCP Server as a Narrow Domain Adapter
A good server doesn't start with an LLM and doesn't start with the protocol at all. It takes existing deterministic domain functions, describes them with schemas, and returns a verifiable result. This order keeps business rules separate from the protocol layer - and so they can be tested and changed independently.
First - a pure domain function that knows nothing about MCP. In our example it's a release check against five rules: CI, tests, vulnerabilities, migration reversibility, a documented rollback.
export function listReleaseSnapshots(): readonly ReleaseSnapshot[] {
return snapshots;
}
export function getReleaseSnapshot(service: string, version: string): ReleaseSnapshot | undefined {
return snapshots.find(
(snapshot) => normalize(snapshot.service) === normalize(service) && snapshot.version === version.trim()
);
}
export function reviewRelease(service: string, version: string): ReleaseReview {
const snapshot = getReleaseSnapshot(service, version);
if (!snapshot) {
throw new Error(`Release ${service}@${version} not found`);
}
const checks: ReleaseCheck[] = [
{
id: "ci",
title: "CI passed",
passed: snapshot.ci === "passed",
evidence: `ci=${snapshot.ci}; commit=${snapshot.commit}`
},
{
id: "tests",
title: "All tests passed",
passed: snapshot.testsFailed === 0,
evidence: `passed=${snapshot.testsPassed}; failed=${snapshot.testsFailed}`
},
{
id: "security",
title: "No critical vulnerabilities",
passed: snapshot.criticalVulnerabilities === 0,
evidence: `critical=${snapshot.criticalVulnerabilities}`
},
{
id: "migration",
title: "Migration is reversible",
passed: snapshot.migrationReversible,
evidence: `reversible=${snapshot.migrationReversible}`
},
{
id: "rollback",
title: "Rollback is documented",
passed: snapshot.rollbackDocumented,
evidence: `documented=${snapshot.rollbackDocumented}`
}
];
const failed = checks.filter((check) => !check.passed);
const decision = failed.length === 0 ? "ready" : "blocked";
const summary = decision === "ready"
? `${snapshot.service}@${snapshot.version} is ready to ship: all ${checks.length} checks passed.`
: `${snapshot.service}@${snapshot.version} is blocked: ${failed.map((check) => check.title).join("; ")}.`;
return { service: snapshot.service, version: snapshot.version, decision, checks, summary };
}And only then is that function wrapped into a tool - with an input/output schema and annotations that honestly describe its character.
server.registerTool(
"review_release",
{
title: "Release readiness check",
description: "Checks an existing release snapshot against five deterministic rules.",
inputSchema: z.object({
service: z.string().min(2).max(64),
version: z.string().regex(/^\d+\.\d+\.\d+$/u)
}),
outputSchema: reviewOutputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
async ({ service, version }) => {
try {
const output = reviewRelease(service, version);
return {
content: [{ type: "text", text: output.summary }],
structuredContent: output
};
} catch (error) {
return {
isError: true,
content: [{ type: "text", text: error instanceof Error ? error.message : "Unknown release error" }]
};
}
}
);A server like this unfolds by the same mechanism, worth memorizing as a recipe.
- Create an
McpServerwith a unique name/version and short instructions. - Describe the input and output schema - a contract, not decoration.
- In the handler call the domain service; don't write business rules inside the protocol layer.
- Return both a human-readable
contentand a machine-readablestructuredContent. - Turn an expected domain error into
isError; log unexpected errors without secrets.
And whether a tool is safe by construction, you can check in the lab.
Check the tool's safety
A safe operation: narrow, typed, with a confirmed write.
Two representations of the result are not redundancy but a separation of consumers.
Why two representations of the result. Text is convenient for the model and the UI.structuredContentis convenient for code, tests, and later composition. If anoutputSchemais declared, the structure must conform to it - otherwise the client is entitled to consider the answer invalid.
And a boundary of responsibility for errors that must not be blurred.
Don't pass a database exception off as a useful answer. A user error, a protocol error, and an infrastructure failure have different retry semantics. Give the client a safe message and keep the full stack only in protected telemetry.
The server is ready - what's left is to decide how the host will launch it. For the local scenario that's stdio, and it has its own discipline.