A1 · Building MCP Servers

A1 · 45 min

Building MCP Servers

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.

CLAUDE CODEMCP clientstdiono networkHTTPOAuthtool callmcp__server__toolresult, into contextLOCAL SERVERon your machineREMOTE SERVERin their cloudfrontsfrontsLOCAL SYSTEMbrowser, files, DBSAAS APIissues and errorsevery result crosses into your context:trust the server before you connect itagent / actioncontext / data

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/node

zod 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.

Error for a stack trace

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.

Error for a model

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.com

Test 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.

  1. Scaffold as shown in the lesson: npm init -y, install @modelcontextprotocol/sdk and zod, dev-install typescript, tsx, and @types/node, add a strict tsconfig.json (NodeNext, noEmit: true).
  2. Implement src/index.ts from the lesson's three parts, then add one tool of your own: list_client_contacts, taking a client string, returning at most ten contacts' public fields, readOnlyHint set. Write its miss-path error as a prompt: what should the model do when the client name matches nothing?
  3. Typecheck: npx tsc must exit clean. A server that does not typecheck does not ship.
  4. Interrogate it with the Inspector CLI from the Meridian folder root: tools/list, then tools/call on lookup_contact with a real stand-in contact, then with an absent address. Confirm the miss returns isError: true and text naming a concrete next step.
  5. 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 /mcp that the server is connected, then send the dispatch below and watch for calls named mcp__meridian-crm__lookup_contact.
  6. Package it: add the bin field and a build config that emits dist/, build, then npm pack. Re-run Inspector CLI tools/list against node dist/index.js to prove the artifact serves without tsx.
PromptThe swap-in dispatch

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 tsc exits 0 with strict mode on.
  • Inspector CLI tools/list shows both lookup_contact and your list_client_contacts.
  • The hit call returns structuredContent with exactly the public fields; the miss returns isError: true with text naming a next step.
  • /mcp shows meridian-crm connected, and the dispatched task produces at least one call named mcp__meridian-crm__lookup_contact.
  • npm pack produces a tarball, and the Inspector lists tools from node 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 add fails 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

Q1You add console.log('meridian-crm starting') to main() in your stdio server. Every client now marks the server failed, with JSON parse errors in the logs. What happened?
Q2A model calls lookup_contact with an email that is not in the CRM. Per the spec, what should your handler do?
Q3Meridian's triage worker needs a one-contact CRM lookup. HubSpot ships an official MCP server with read and write across contacts, companies, deals, and tickets. When the real CRM replaces the stand-in, what is the move?
Q4You are moving meridian-crm from stdio to Streamable HTTP so the Meridian team can share one instance. A teammate proposes skipping OAuth and accepting an API key in the query string. What does the current spec call for?

Sources