Lesson 5.4 left Meridian's CRM lookup living inside one script: a lookupContact tool built with createSdkMcpServer(), callable only by the drafter process that created it. Then Grace wants the same lookup in her interactive Claude Code session, Ben wants it in the intake worker, and the pasted third copy of the tool definition has already drifted from the first two. An in-process tool serves exactly one process. The fix is a standalone MCP server: one implementation, one contract, mountable by any client that speaks the protocol.
When writing a server is the right call
Lesson 2.7's position does not soften because you can now build the thing: a good CLI still wins, and a maintained official vendor server usually beats yours. Writing your own is third in the preference order, earned in three situations.
First, the system is yours. Meridian's stand-in CRM has no CLI and no vendor. Any internal database or bespoke workflow is the same: if you want it in the tool layer, you are the vendor.
Second, the vendor's surface is broader than your access matrix allows. Lesson 5.4 noted that HubSpot's official server exposes read and write across CRM objects, while the triage worker's matrix row says read one contact. A server you wrote cannot perform an operation you did not implement. That is a tighter gate than any permission rule: what does not exist cannot be escalated.
Third, the interface needs your business rules baked in: public fields only, results capped at ten, error text written for a model. A generic connector cannot know those rules; your server is where they live.
The anti-reason is fashion. If gh already does it, writing a GitHub server is a hobby, not engineering.
The server side of the protocol
You have consumed servers since lesson 2.7. Authoring puts you on the other side of the same diagram: you now define what crosses into someone's context window.
A server answers JSON-RPC 2.0 requests: a client connects, the two negotiate capabilities at initialization, then the client calls the features you declared. As of July 2026 the current spec release is 2025-11-25, with a 2026-07-28 release at release-candidate stage. The SDK negotiates protocol versions for you.
Servers expose up to three primitives, and the spec assigns each a different controller. That framing should drive your design:
- Tools are model-controlled: the model decides when to call them. Typed functions for actions and queries.
- Resources are application-driven: URI-addressed data (a file, a client brief, a schema) the host decides how to surface.
- Prompts are user-controlled: templated workflows the user explicitly invokes, typically as slash commands.
Here is the position: build tools first, and often only tools. A two-tool server that does its job beats a ten-primitive showcase. Add a resource when a client you use will surface it, a prompt when a workflow repeats often enough to deserve a slash command. Every primitive is more surface to audit.
Transports, and the auth line between them
The two current transports are the ones from 2.7, with rules that now bind you as the author.
stdio: the client launches your server as a subprocess and speaks newline-delimited JSON-RPC over stdin and stdout. The rule that breaks the most first servers: nothing hits stdout except valid protocol messages. One stray console.log corrupts the stream. Stderr is yours for logging.
Streamable HTTP: an independent process behind a single MCP endpoint accepting POST and GET, optionally streaming over SSE, with sessions riding an MCP-Session-Id header. The spec's security requirements are blunt: validate the Origin header on every connection (DNS rebinding is the attack), and bind to 127.0.0.1, not 0.0.0.0, when running locally.
Authorization splits along that line. For stdio, the spec says implementations SHOULD NOT do OAuth at all: credentials come from the environment, so 5.4's scoped-token discipline is the whole auth story locally. For HTTP, a protected server acts as an OAuth 2.1 resource server: it must implement RFC 9728 Protected Resource Metadata for discovery, answer unauthorized requests with a 401 plus a WWW-Authenticate header carrying the metadata URL and required scopes, and validate every token it accepts. Do not hand-roll any of that; the SDK ships auth helpers. The operational position: default to stdio, and move to HTTP only when someone not on your machine needs the server. Meridian's CRM lookup has no such someone yet.
In practice: meridian-crm
The server wraps the stand-in crm.json from lesson 5.4 and exposes the lookup as a proper tool. Scaffold a package next to your Meridian working folder:
mkdir meridian-crm && cd meridian-crm
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/nodezod is a required peer dependency (Zod 3.25+ or 4). The layout stays small:
meridian-crm/
package.json
tsconfig.json # strict, NodeNext modules, noEmit for checking
src/
index.ts # the whole server, for now
Here is the core: setup plus the lookup tool. Everything below typechecks under strict against SDK 1.29.0 and was verified against a live client before it reached this page.
#!/usr/bin/env node
// meridian-crm/src/index.ts (part 1 of 3)
import { readFile } from "node:fs/promises";
import {
McpServer,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const CRM_PATH =
process.argv[2] ?? process.env.MERIDIAN_CRM_PATH ?? "standin-data/crm.json";
interface Contact {
email: string;
name: string;
company: string;
client: string;
role: string;
lastActivity: string;
}
async function loadContacts(): Promise<Contact[]> {
const raw = await readFile(CRM_PATH, "utf8");
return JSON.parse(raw) as Contact[];
}
const server = new McpServer({
name: "meridian-crm",
version: "0.1.0",
});
const contactShape = {
email: z.string(),
name: z.string(),
company: z.string(),
client: z.string(),
role: z.string(),
lastActivity: z.string(),
};
server.registerTool(
"lookup_contact",
{
title: "Look up a CRM contact",
description:
"Find one contact in Meridian's CRM by exact email address. " +
"Returns the contact's public fields only. Use this instead of " +
"reading CRM data directly.",
inputSchema: {
email: z
.string()
.email()
.describe("The contact's email address, exact match"),
},
outputSchema: contactShape,
annotations: { readOnlyHint: true },
},
async ({ email }) => {
const contacts = await loadContacts();
const match = contacts.find(
(c) => c.email.toLowerCase() === email.toLowerCase()
);
if (!match) {
const domain = email.split("@")[1] ?? "";
const sameDomain = contacts.filter((c) =>
c.email.endsWith(`@${domain}`)
);
return {
isError: true,
content: [
{
type: "text",
text:
`No contact with email ${email}. ` +
(sameDomain.length > 0
? `${sameDomain.length} contact(s) share the domain @${domain}; ` +
`closest: ${sameDomain[0]!.email}. Retry with an exact address.`
: `No contacts at @${domain} either. Ask the user to confirm ` +
`the address before retrying; do not guess.`),
},
],
};
}
return {
content: [{ type: "text", text: JSON.stringify(match) }],
structuredContent: { ...match },
};
}
);Read the registration config as the model's documentation, because it is. The description is all the model knows about your tool at call time; write it like a one-line SOP, including what the tool replaces ("use this instead of reading CRM data directly"). inputSchema is a plain object of Zod validators; the SDK converts it to JSON Schema for the wire and rejects malformed arguments before your handler runs. Declare outputSchema and the success path must return matching structuredContent, which downstream code can consume without parsing prose. readOnlyHint says this tool mutates nothing, and note the word hint: advisory metadata, not a security boundary. The boundary remains what your code can do and what its credential allows, as lesson 5.4 drew it.
Error design: the message is a prompt
The spec gives a server two error channels, and choosing the right one per failure separates a production server from a demo. Protocol errors are JSON-RPC errors: unknown tool, malformed request, server crash. The SDK raises them; clients treat them as plumbing failures. Tool execution errors are results with isError: true: the lookup missed, the API said no, the input was plausible but wrong. The spec's reasoning is the design rule: execution errors flow back to the model as content it can self-correct on, while protocol errors indicate problems the model probably cannot fix.
So here is the position: every isError message is a prompt, and you should write it as one: what failed, the likely fix, the stopping condition. The lookup above does all three, down to the instruction to stop and ask the human when nothing matches. That clause matters most. A model fed a vague error retries with mutations until something sticks; a model told when to stop, stops.
The handler throws on a miss, and the client surfaces a protocol error: Error: contact not found at lookupContact (index.ts:41).
The model has no path forward. It retries the same call, or worse, invents a plausible contact and keeps drafting.
The handler returns isError with: No contact with email X; closest at that domain: sara.finch@northwindlogistics.com. Retry with an exact address.
The model corrects the typo on the next call, or reports the miss. Either way it acts correctly without you in the loop.
Resources and a prompt, where they earn it
The CRM server gets one resource template and one prompt, each with a concrete consumer: client briefs from the 5.3 knowledge repo, and Ben's triage workflow as the slash command Grace asked for.
// meridian-crm/src/index.ts (part 2 of 3)
server.registerResource(
"client-brief",
new ResourceTemplate("meridian://clients/{client}", { list: undefined }),
{
title: "Client brief",
description:
"One-pager for a Meridian client: retainer, contacts, live notes.",
mimeType: "text/markdown",
},
async (uri, { client }) => {
const name = Array.isArray(client) ? client[0] : client;
const brief = await readFile(
`meridian-knowledge/clients/${name}.md`,
"utf8"
);
return {
contents: [{ uri: uri.href, mimeType: "text/markdown", text: brief }],
};
}
);
server.registerPrompt(
"qualify_lead",
{
title: "Qualify an inbound lead",
description:
"Standard first-pass qualification for a lead from the #leads channel.",
argsSchema: {
email: z.string().describe("The lead's email address"),
},
},
({ email }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text:
`Qualify the inbound lead ${email}. Use lookup_contact to check ` +
`whether they already exist in the CRM, then classify per the ` +
`triage rubric and draft (do not send) a follow-up.`,
},
},
],
})
);A ResourceTemplate turns one registration into a URI family: meridian://clients/northwind, meridian://clients/halcyon. The list: undefined is required by the constructor on purpose: you decide explicitly whether clients can enumerate the family or must know a URI. And note what the prompt does: it encodes the drafts-only rule from Meridian's process specs into the template, so the workflow ships with its guardrail attached.
Wiring up the transport is the smallest part:
// meridian-crm/src/index.ts (part 3 of 3)
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
// stdout belongs to the protocol; log to stderr only.
console.error("meridian-crm ready");
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});Moving to Streamable HTTP later touches zero registrations; you swap the transport: construct a StreamableHTTPServerTransport with a sessionIdGenerator (or undefined for stateless mode) and route POST and GET on one /mcp endpoint to transport.handleRequest(req, res, req.body). The SDK's createMcpExpressApp() helper applies DNS rebinding protection for localhost binds. Cross that line and the authorization section above stops being background reading.
Testing: interrogate it before any model does
The MCP Inspector is the standard debugging tool; its CLI mode is the one you will script. From your Meridian folder (so the relative crm.json path resolves):
# Interactive UI at localhost:6274 (Inspector needs Node 22.7.5+)
npx @modelcontextprotocol/inspector npx tsx meridian-crm/src/index.ts
# CLI mode: list tools, then exercise both paths of the lookup
npx @modelcontextprotocol/inspector --cli npx tsx meridian-crm/src/index.ts \
--method tools/list
npx @modelcontextprotocol/inspector --cli npx tsx meridian-crm/src/index.ts \
--method tools/call --tool-name lookup_contact \
--tool-arg email=sara.finch@northwindlogistics.comTest the miss path as deliberately as the hit: call with an address you know is absent and read the error text as if you were the model receiving it. Does it name a next action? For assertions you can commit, the SDK's Client plus StdioClientTransport gives a real end-to-end test: spawn the server, call listTools() and callTool(), assert on structuredContent and isError. That harness is how the server above was verified.
Packaging: make it installable, then version it like an API
A server someone else mounts is a build artifact, not a tsx invocation. Emit JavaScript and declare a binary:
{
"name": "meridian-crm",
"version": "0.1.0",
"type": "module",
"bin": { "meridian-crm": "dist/index.js" },
"scripts": { "build": "tsc -p tsconfig.build.json" }
}With dist/ built (a tsconfig.build.json that sets an outDir and turns noEmit off), npm pack produces an installable tarball, and the shebang line you already wrote makes meridian-crm runnable as a command. Publish to npm when the consumer is not you. Treat the tool surface as a public API, because it is one: renaming lookup_contact breaks every prompt, subagent definition, and permission rule referencing mcp__meridian-crm__lookup_contact. Renames and schema tightenings are major versions; additive tools are minor.
Where it breaks
One stray stdout write kills a stdio server. A console.log, a dependency printing a banner on import, a forgotten debug line: any of them corrupts the protocol stream, and the client marks the server failed with JSON parse errors. It is the most common first-server bug; the fix is stderr for everything human-readable, always.
Declared schemas drift from returned data. The SDK validates structuredContent against your outputSchema on every success. Add a field to the data without updating the shape and every call starts failing, uniformly, in production. Schema and handler change together or not at all; your end-to-end test exists to catch this.
You flood the consumer you were built to serve. Claude Code warns at 10,000 tokens of tool output and caps at 25,000 by default (lesson 2.7). As the author you control the tap. Return public fields, not rows; cap list results and state the cap in the description; paginate anything unbounded. A tool that dumps the database on success is manufacturing context rot.
One kitchen-sink tool instead of narrow ones. A query_crm tool that takes raw SQL is one tool doing everything, which means one injection doing anything. Narrow tools with typed inputs are the server-side expression of the 5.4 access matrix, and easier for the model to call correctly. If a tool's description needs a paragraph of warnings, split the tool.
Lablab-a1Build meridian-crm and swap it into the tool layer
Goal: Author, verify, and package an MCP server for Meridian's stand-in CRM, then mount it in Claude Code and prove a dispatch uses it.
Prereqs: the Meridian folder from lesson 5.4 with standin-data/crm.json in place, Node 18+ (22.7.5+ for the Inspector), Claude Code.
- Scaffold as shown in the lesson:
npm init -y, install@modelcontextprotocol/sdkandzod, dev-installtypescript,tsx, and@types/node, add a stricttsconfig.json(NodeNext,noEmit: true). - Implement
src/index.tsfrom the lesson's three parts, then add one tool of your own:list_client_contacts, taking aclientstring, returning at most ten contacts' public fields,readOnlyHintset. Write its miss-path error as a prompt: what should the model do when the client name matches nothing? - Typecheck:
npx tscmust exit clean. A server that does not typecheck does not ship. - Interrogate it with the Inspector CLI from the Meridian folder root:
tools/list, thentools/callonlookup_contactwith a real stand-in contact, then with an absent address. Confirm the miss returnsisError: trueand text naming a concrete next step. - Swap it in:
claude mcp add --transport stdio meridian-crm -- npx tsx /absolute/path/to/meridian-crm/src/index.ts /absolute/path/to/standin-data/crm.json(absolute paths, so the server finds its data from any working directory). Start a session, confirm via/mcpthat the server is connected, then send the dispatch below and watch for calls namedmcp__meridian-crm__lookup_contact. - Package it: add the
binfield and a build config that emitsdist/, build, thennpm pack. Re-run Inspector CLItools/listagainstnode dist/index.jsto prove the artifact serves withouttsx.
Using the meridian-crm MCP tools, qualify this inbound lead: d.okoro@halcyonsoftware.io. Check whether the contact exists in the CRM, tell me which tool you called and what it returned, then draft (do not send) a two-line follow-up for Ben to review.
Verify
npx tscexits 0 with strict mode on.- Inspector CLI
tools/listshows bothlookup_contactand yourlist_client_contacts. - The hit call returns
structuredContentwith exactly the public fields; the miss returnsisError: truewith text naming a next step. /mcpshows meridian-crm connected, and the dispatched task produces at least one call namedmcp__meridian-crm__lookup_contact.npm packproduces a tarball, and the Inspector lists tools fromnode dist/index.js.
>Troubleshooting
- Server shows as failed, or the client logs JSON parse errors: something wrote to stdout. Find the
console.log(yours or a dependency's import-time banner) and route it to stderr. This happens to everyone once. - The Inspector exits with a version complaint: it requires Node 22.7.5+, independent of what your server needs. Check
node --version. claude mcp addfails with an option-parsing error: you dropped the--separator, lesson 2.7's oldest trap. Everything after--belongs to your server, including the crm.json path.- Tool calls fail with ENOENT on
crm.json: the server resolved its default relative path against the wrong working directory. Pass the absolute path argument, as step 5 shows.
Knowledge check
Knowledge check
Sources
- MCP specification (2025-11-25, current release): https://modelcontextprotocol.io/specification/latest (fetched July 2026)
- MCP spec, Tools: https://modelcontextprotocol.io/specification/2025-11-25/server/tools (fetched July 2026)
- MCP spec, Resources: https://modelcontextprotocol.io/specification/2025-11-25/server/resources (fetched July 2026)
- MCP spec, Prompts: https://modelcontextprotocol.io/specification/2025-11-25/server/prompts (fetched July 2026)
- MCP spec, Transports: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports (fetched July 2026)
- MCP spec, Authorization: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization (fetched July 2026)
- MCP TypeScript SDK repo README (v1/v2 status): https://github.com/modelcontextprotocol/typescript-sdk (fetched July 2026)
- MCP TypeScript SDK v1 README, 1.29.0: https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/README.md (fetched July 2026)
- MCP Inspector README (UI and CLI modes): https://github.com/modelcontextprotocol/inspector (fetched July 2026)