MCP in Production · Chapter 3
By the end of this chapter you will have a real package on npm. Not a gist. Not a sample repo. A package that any Claude Code user -- including you, on a different machine, with no copy of the source -- can install in one command:
claude mcp add gh-mcp -- npx -y @yourscope/github-tools-mcp
Three tools, against the public GitHub REST API, no auth required for the read-only paths we are going to ship. The whole thing is around 250 lines of TypeScript. By the time you finish reading you will have made every mistake I made the first six times I built one of these, and you will have shipped the seventh.
I am writing this chapter as the worked example for the entire book. Chapters 4 through 12 layer auth, schema design, transports, deployment, and observability on top of what we build here. If you only buy one chapter of this book, buy this one -- the rest is commentary on the choices we are about to make.
I picked GitHub for the worked example for four reasons, in priority order:
search_repos hits GitHub's Search API, which carries its own stricter unauthenticated cap of about 10 requests per minute -- so a tight loop of searches can 403 even while the 60-per-hour core budget is untouched.) We get to defer the entire auth chapter (Chapter 4) without skipping production-shaped code./issues endpoint quietly returns pull requests as well as issues, because GitHub considers a PR to be an issue with a pull_request field attached. Both bugs are textbook MCP-tool bugs: the model gets a confusing answer and you cannot tell whether the model or the tool is at fault.We will build three tools:
search_repos(query, limit) -- search public repositoriesget_repo(owner, name) -- fetch a single repository's metadatalist_issues(owner, name, state) -- list real issues on a repository (filtering out PRs)This is the smallest tool surface that exercises every shape you will build for the rest of your career: a search tool, a single-resource fetch, and a list-with-filter. Memorize them; they recur.
Before any code, a sketch of where we are headed. Your finished tree will look like this:
github-tools-mcp/
package.json
tsconfig.json
README.md
.gitignore
.npmignore
src/
index.ts
dist/ # generated by npm run build
index.js
index.d.ts
prompts.md # local test harness
That is the whole package. No bundler. No build pipeline. tsc from the official TypeScript compiler emits plain ES modules and a type declaration file, and that is what we ship. If you have built Node packages in 2020 -- this is simpler than that. The MCP SDK does not require any of the ceremony you may be used to.
Make a directory, init the package, and pick your scope. I am going to use @yourscope/github-tools-mcp throughout this chapter -- when you are following along, replace yourscope with your actual npm scope (or the username you registered when you ran npm login).
mkdir github-tools-mcp
cd github-tools-mcp
git init
npm init -y
The generated package.json is mostly wrong. Open it and replace it with this:
{
"name": "@yourscope/github-tools-mcp",
"version": "0.1.0",
"description": "GitHub read-only tools as an MCP server. Search repos, fetch repo metadata, list real issues.",
"license": "MIT",
"type": "module",
"bin": {
"github-tools-mcp": "dist/index.js"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"prepublishOnly": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && npm run build && node -e \"if (require('fs').readFileSync('dist/index.js','utf8').slice(0,2)!=='#!'){console.error('shebang missing');process.exit(1)}\""
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.4.0"
}
}
A field-by-field tour, because every line here was learned the hard way:
"type": "module" -- we are shipping ESM. The MCP SDK is ESM-only as of v1. CommonJS users will get import errors at install time; do not pretend otherwise."bin" -- this is what makes npx -y @yourscope/github-tools-mcp work. npm symlinks dist/index.js onto the user's PATH as github-tools-mcp when the package is installed globally, and npx uses that mechanism under the hood."files" -- an allowlist. Without this, npm publishes everything not in .gitignore, which usually includes your editor config, your local .env, your test harness prompts, and one time for me a 40MB folder of screenshot evidence from a bug I was hunting. Allowlist your way out of this."engines": { "node": ">=20" } -- Node 20 is the current LTS as I write this. The SDK uses fetch and Web Streams, which are stable in 20. Node 18 will mostly work; it is not worth the support burden."prepublishOnly" -- this hook runs only on npm publish, not on npm install. We use it to nuke dist/, rebuild, and then verify the shebang -- both the cleanup and the check are node -e one-liners. The cleanup uses fs.rmSync('dist',{recursive:true,force:true}), which is the Node 14.14+ equivalent of rm -rf and is guaranteed available by our engines.node >= 20. The check reads the first two bytes of dist/index.js and exits non-zero if they aren't #!. Both steps are portable across Windows, macOS, and Linux without WSL or Git Bash -- the older rm -rf dist && ... | grep -q '#!' shape only runs on Unix shells. If your CI shell mangles the && chain or the double-quote escaping inside node -e "...", split the hook into a small scripts/prepublish.mjs and call that from the script field instead. If the verification fails, the publish aborts. I shipped a broken package once because I forgot to rebuild after a refactor; this hook makes that impossible."publishConfig": { "access": "public" } -- without this, scoped packages publish private by default and npm publish fails with a 402 (payment required) unless you have a paid org account. With it, npm publish works on the free tier.From the field: I have watched four engineers in a row at four different jobs ship their first npm package and hit the 402. The fix is one line in
package.json. Bake it in now and you will never see the error.
Save this as tsconfig.json in the repo root:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": false,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
module: NodeNext is the line that matters. It pairs with "type": "module" in package.json and tells TypeScript to emit ESM with Node-compatible module resolution. moduleResolution: NodeNext is its required twin. If you set one without the other, you will get cryptic errors about .js extensions in imports.
strict: true is non-negotiable. The MCP SDK's types are tight; with strict off you will silently lose all the type safety the SDK was designed to give you. If that breaks your existing habits, this is the chapter to break them.
declaration: true emits .d.ts files alongside the JS. Even if no one ever consumes your package as a library (and they will not -- this is a binary, not a lib), the declaration files help editors light up correctly when someone opens your published source.
Install the dependencies:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
Now create src/index.ts. The first line is the most important line in the entire file:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "github-tools-mcp",
version: "0.1.0",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
process.stderr.write(`fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
process.exit(1);
});
Three things to notice.
The shebang is line 1, byte zero. Not "near the top." Not "after a comment." Byte zero. Operating systems look at the first two bytes of an executable file; if those bytes are #! it interprets the rest of that line as the interpreter to invoke. Anything before the shebang -- a UTF-8 byte order mark, a blank line, a // eslint-disable comment -- means the file is not executable on Linux or macOS, and npx calls fall over with a confusing parse error.
TypeScript happily preserves the shebang into the compiled output as long as it is the first line of the source. Verify it with head -n 1 dist/index.js after every build. The prepublishOnly hook we set up earlier verifies this automatically before publish.
Only stderr writes for logs. Notice process.stderr.write rather than console.log. This is not paranoia -- this is the protocol.
main is async, with a top-level catch. If server.connect throws, you want a clear message, not a silent process exit. The catch logs to stderr and exits with code 1, which is exactly what process supervisors expect from a misbehaving child.
This is the war story I tell every engineer who is about to write their first MCP server, because every one of them does it at least once.
The Stdio transport speaks JSON-RPC over the server's stdin and stdout. The framing is line-delimited JSON: each message is a single JSON object, terminated by a newline. Claude Code (or any MCP client) writes a request to your server's stdin, and reads your response off your stdout, parsing one JSON object per line.
Anything that is not a valid JSON-RPC message on stdout breaks the protocol. The client sees garbage where it expected a response, the JSON parser raises, the connection dies, and from the user's perspective the tool just stops working with no error message anywhere obvious.
The first time I wrote one of these, I dropped a console.log("loaded") near the top of my file, the way you do. Forty minutes later I was reading the SDK source line by line trying to figure out why tools/list returned silence. The fix, of course, was to delete the console.log. But by then I had also rewritten my transport handling twice, restarted Claude Code six times, and accused the SDK of being broken in a Slack message I am still embarrassed about.
The rule, from then on:
Pitfall: stdout is the protocol channel. Every log, every debug print, every "huh that's weird" trace goes to stderr. If you cannot resist a
console.logfor debugging, writeconsole.errorinstead -- it goes to stderr, your client does not care, and you will see it in the terminal where you launched the server.
For a 250-line file, vigilance is enough; an eslint rule with no-restricted-syntax against console.log is the next step once the file grows.
You can verify the skeleton boots by running:
npm run dev
tsx will compile-and-run the TypeScript in one step. The process should print nothing and hang, waiting for stdin. Press Ctrl-C. That hang is correct behavior -- the server is waiting for the client to send it a JSON-RPC message. We will not feed it any input by hand; we will hook Claude Code up to it shortly.
If you want a one-line smoke test of the handshake before involving Claude Code, you can pipe an initialize request straight into the server and read the response:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"manual","version":"0"}}}' | node dist/index.js
You should see one line of JSON come back on stdout containing serverInfo and capabilities. If you see anything else first -- a stray log line, a stack trace, a banner message from a dependency -- that is stdout pollution and it will break a real client. Fix it before you wire the server up to Claude Code, because Claude will give you the silent treatment and you will spend an hour wondering why. The exact protocolVersion string the SDK negotiates may vary by SDK release; the handshake will succeed across compatible versions.
Before tools, the helper that every tool depends on. Add this above the async function main() in src/index.ts:
type GhResult<T> =
| { ok: true; data: T }
| { ok: false; status: number; message: string };
async function ghFetch<T>(path: string): Promise<GhResult<T>> {
const url = path.startsWith("http") ? path : `https://api.github.com${path}`;
const res = await fetch(url, {
headers: {
"User-Agent": "github-tools-mcp/0.1.0 (+https://github.com/yourscope/github-tools-mcp)",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) {
let message = res.statusText;
try {
const body = (await res.json()) as { message?: string };
if (body && typeof body.message === "string") message = body.message;
} catch {
// body wasn't JSON; keep statusText
}
return { ok: false, status: res.status, message };
}
const data = (await res.json()) as T;
return { ok: true, data };
}
Four design decisions in 25 lines:
1. The User-Agent is mandatory.
GitHub's API will return 403 Forbidden -- Request forbidden by administrative rules. Please make sure your request has a User-Agent header if you call it without one. The node runtime's fetch does not set a User-Agent by default. This is the second-most-common cause of "why is my tool returning errors" in MCP servers I have reviewed.
The format of the User-Agent string does not matter to GitHub as long as it is non-empty. I use "<package-name>/<version> (+<repo-url>)" because if my server starts misbehaving against GitHub's rate limits, I want the GitHub team to be able to find me. Be a good citizen.
2. The Accept header pins the response shape.
application/vnd.github+json is GitHub's recommended Accept header. Without it you sometimes get responses in older formats. The cost of including it is zero; the cost of debugging a shape mismatch six months from now when GitHub changes a default is high.
3. The X-GitHub-Api-Version header pins the API version.
GitHub's REST API is versioned by date. Pinning it means your server keeps working when GitHub ships v2027-04-29 with breaking changes. Update the pin deliberately, in a release that bumps your minor version, after testing the new shapes.
4. The return type is a discriminated union, not throws.
Errors flow through the type system. There is no try/catch in the tool handlers below; the union forces the tool to handle the failure case explicitly. This is the single most important pattern in the whole chapter -- it is the line between a tool that confuses the model on every error and a tool the model handles gracefully.
From the field: when a tool throws, the MCP SDK wraps the error into a JSON-RPC error response. Models do see those, but they see them as "the tool broke" rather than "the tool worked and returned this information." For a 404 ("repo not found"), the second framing is what you want -- the model should retry with a different repo, not give up. We will return errors as content, not as protocol-level failures, and the model will route around them.
The first tool. Add this above async function main(), after ghFetch:
type GhRepoSummary = {
full_name: string;
description: string | null;
stargazers_count: number;
language: string | null;
html_url: string;
pushed_at: string;
};
server.registerTool(
"search_repos",
{
description:
"Search GitHub for public repositories matching a query string. " +
"Returns up to `limit` results with name, description, star count, language, URL, and last-push timestamp. " +
"Use this when the user wants to discover repos by topic, language, or keyword (e.g. 'python web frameworks', 'rust async runtime').",
inputSchema: {
query: z
.string()
.min(1)
.describe("Search query in GitHub search syntax (e.g. 'language:rust topic:async')."),
limit: z
.number()
.int()
.min(1)
.max(20)
.default(5)
.describe("Max number of results to return (1-20). Defaults to 5."),
},
},
async ({ query, limit }) => {
const params = new URLSearchParams({
q: query,
per_page: String(limit),
sort: "stars",
order: "desc",
});
const result = await ghFetch<{ items: GhRepoSummary[] }>(
`/search/repositories?${params.toString()}`,
);
if (!result.ok) {
return {
content: [
{
type: "text",
text: `Search failed (HTTP ${result.status}): ${result.message}`,
},
],
isError: true,
};
}
if (result.data.items.length === 0) {
return {
content: [
{
type: "text",
text: `No repositories matched query: ${query}`,
},
],
};
}
const lines = result.data.items.map((r) => {
const desc = r.description ?? "(no description)";
const lang = r.language ?? "unknown";
return `${r.full_name} -- ${desc}\n ${r.stargazers_count} stars, ${lang}, last pushed ${r.pushed_at}\n ${r.html_url}`;
});
return {
content: [
{
type: "text",
text: `Top ${result.data.items.length} results for "${query}":\n\n${lines.join("\n\n")}`,
},
],
};
},
);
Walk through this with me, because every shape choice is deliberate.
The description is three sentences in one string, in a fixed order: what it does, what it returns, when to use it. The model reads this description as part of the tools list. If it is vague, the model picks the wrong tool. If it is too long, it crowds out the other tools' descriptions. Three sentences, in this order, has been my standard since 2024 and it still works.
Every Zod field has .describe(). The descriptions show up in the MCP tools/list payload, which the model reads. Without them, the model sees query: string and has to guess what kind of string. With them, the model sees query: string -- "Search query in GitHub search syntax" and gives you well-formed queries on the first try.
The success path returns formatted text, not JSON.
This is the second-most-common mistake I see in MCP tools, after "logging to stdout." The temptation is to return the raw API response as JSON and let the model parse it. The model can parse it -- but that costs tokens, and the model often has to make a follow-up call to format the result for the user anyway.
Format for the model. Concrete prose, clear field labels, one item per chunk, blank lines between chunks. The model reads it like a human reads a search result page, summarizes it for the user, and you save 60% of the token cost on every tool call.
The error path uses isError: true and a plain text message.
isError: true is the MCP convention for "the tool ran and the result is an error." The model sees it and routes accordingly. The text is human-readable so the model can also explain what went wrong if the user asks.
The empty-result path is its own branch.
"No matches" is not an error -- the tool worked, the answer is just empty. Treat it as a normal return, not an error. The model will phrase it correctly to the user ("I searched for X and didn't find anything") rather than alarming them with an error message.
The single-resource fetch. Append this below search_repos:
type GhRepoFull = {
full_name: string;
description: string | null;
stargazers_count: number;
forks_count: number;
open_issues_count: number;
language: string | null;
default_branch: string;
license: { spdx_id: string | null } | null;
html_url: string;
pushed_at: string;
archived: boolean;
};
server.registerTool(
"get_repo",
{
description:
"Fetch metadata for a single GitHub repository by owner and name. " +
"Returns description, stars, forks, open issue count, default branch, license, and last-push timestamp. " +
"Use this when the user names a specific repository and wants details about it (e.g. 'tell me about facebook/react').",
inputSchema: {
owner: z
.string()
.min(1)
.describe("Repository owner (user or org), e.g. 'facebook'."),
name: z
.string()
.min(1)
.describe("Repository name, e.g. 'react'."),
},
},
async ({ owner, name }) => {
const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
const result = await ghFetch<GhRepoFull>(path);
if (!result.ok) {
if (result.status === 404) {
return {
content: [
{
type: "text",
text: `Repository not found: ${owner}/${name}. Check the owner and name spelling.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to fetch ${owner}/${name} (HTTP ${result.status}): ${result.message}`,
},
],
isError: true,
};
}
const r = result.data;
const license = r.license?.spdx_id ?? "no license";
const desc = r.description ?? "(no description)";
const archived = r.archived ? " [ARCHIVED]" : "";
const text =
`${r.full_name}${archived}\n` +
`${desc}\n\n` +
`Stars: ${r.stargazers_count}\n` +
`Forks: ${r.forks_count}\n` +
`Open issues: ${r.open_issues_count}\n` +
`Default branch: ${r.default_branch}\n` +
`Language: ${r.language ?? "unknown"}\n` +
`License: ${license}\n` +
`Last pushed: ${r.pushed_at}\n` +
`URL: ${r.html_url}`;
return { content: [{ type: "text", text }] };
},
);
Two new patterns here.
encodeURIComponent on every path segment.
Repository names can contain ., -, _. Owner names cannot contain slashes. So in practice you rarely need to encode -- but you will eventually call this code with a parameter like microsoft/TypeScript-Website where someone has confused name with owner/name, and the slash will rewrite your URL. Encode every segment, every time, with no exceptions. The cost is one function call; the benefit is that this category of bug never bites you.
Specific handling for 404.
A 404 from GitHub means the user gave us a bad owner/name. The model can recover from this if we tell it clearly. The message is phrased as actionable advice ("Check the owner and name spelling") rather than a generic failure, because the model picks up on phrasing and explains the problem to the user in similar language.
The same pattern -- check for known status codes, give specific advice for each -- scales as your tools mature. Add a 403 branch when you start authenticating. Add a 422 branch when you start writing data. Each known status gets its own branch with its own user-facing message. The general fallback at the bottom catches the rest.
Last tool. Append this below get_repo:
type GhIssue = {
number: number;
title: string;
state: string;
user: { login: string } | null;
comments: number;
created_at: string;
html_url: string;
pull_request?: { url: string };
};
server.registerTool(
"list_issues",
{
description:
"List issues on a GitHub repository, filtered by state. " +
"Returns up to 30 issues with number, title, author, comment count, and URL. " +
"Pull requests are excluded -- use a separate tool for PRs. " +
"Use this when the user wants to see open or closed issues on a specific repo.",
inputSchema: {
owner: z.string().min(1).describe("Repository owner."),
name: z.string().min(1).describe("Repository name."),
state: z
.enum(["open", "closed", "all"])
.default("open")
.describe("Issue state filter. Defaults to 'open'."),
},
},
async ({ owner, name, state }) => {
const params = new URLSearchParams({
state,
per_page: "30",
});
const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues?${params.toString()}`;
const result = await ghFetch<GhIssue[]>(path);
if (!result.ok) {
return {
content: [
{
type: "text",
text: `Failed to list issues for ${owner}/${name} (HTTP ${result.status}): ${result.message}`,
},
],
isError: true,
};
}
const issues = result.data.filter((i) => i.pull_request === undefined);
if (issues.length === 0) {
return {
content: [
{
type: "text",
text: `No ${state} issues on ${owner}/${name} (excluding pull requests).`,
},
],
};
}
const lines = issues.map((i) => {
const author = i.user?.login ?? "ghost";
return `#${i.number} [${i.state}] ${i.title}\n by ${author}, ${i.comments} comments, opened ${i.created_at}\n ${i.html_url}`;
});
return {
content: [
{
type: "text",
text: `${issues.length} ${state} issue${issues.length === 1 ? "" : "s"} on ${owner}/${name}:\n\n${lines.join("\n\n")}`,
},
],
};
},
);
The line that matters is this one:
const issues = result.data.filter((i) => i.pull_request === undefined);
GitHub's /repos/{owner}/{repo}/issues endpoint returns issues and pull requests, because in GitHub's data model a pull request is an issue with extra metadata attached. If you call /issues?state=open on a busy repo, you get a mix. Without this filter, your list_issues tool returns "open issues" that are actually pull requests, the model summarizes them as issues, and the user gets a confidently-wrong answer.
The filter is one line. The bug is invisible in development against your own quiet repos and obvious in production against any popular repo. I have shipped this bug. I have reviewed it in five other people's code. It is so reliably a bug that I would put money on it making the cut for every published list of "common MCP tool mistakes" within two years of when this book ships.
Pitfall: GitHub's
/issuesreturns PRs too. Filteri.pull_request === undefinedor your "list issues" tool will lie. The pull_request field is missing on real issues and present (with a URL) on PRs.
While we are here -- the description string mentions the exclusion explicitly: "Pull requests are excluded -- use a separate tool for PRs." That sentence is for the model, not the user. When the user asks about PRs, the model reads that line, knows this tool does not handle PRs, and either says so or (in a richer setup) calls a different tool. The model's behavior is shaped by every word in your description. Spend the words.
Time to put it in front of Claude Code.
claude mcp add gh-mcp-dev -- npx tsx /full/path/to/github-tools-mcp/src/index.ts
Replace /full/path/to with the absolute path. claude mcp add registers an MCP server with your Claude Code config; npx tsx compiles and runs the TypeScript on every invocation. The -- separator tells claude mcp add that everything after it is the command to run.
Open a new Claude Code session in any directory and run:
List the MCP servers I have configured.
You should see gh-mcp-dev in the list. Now exercise it:
Use the github tools to search for popular Rust async runtimes, then get the metadata for the top result.
If the model picks up search_repos, calls it with a sensible query, then chains into get_repo, you have a working local dev loop. If it doesn't, two things to check: did the description steer it (revisit your three-sentence template), and is your stderr channel clean (run the server manually with npm run dev and watch for spurious output).
I keep a prompts.md file in every server I build. It is not a unit test -- those go in their own files -- it is a list of prompts that I copy-paste into Claude Code after every meaningful change. Mine for this server looks like:
# github-tools-mcp prompt harness
Run each of these in a fresh Claude Code session against the gh-mcp-dev server.
1. "Search github for popular python web frameworks. Show me the top 5."
- Expect: search_repos called with a query like "python web framework", limit=5
- Expect: the results include flask, django, fastapi (in some order)
2. "Tell me about facebook/react."
- Expect: get_repo called with owner=facebook, name=react
- Expect: a description, star count > 200000, MIT license
3. "Tell me about facebook/this-repo-does-not-exist-12345."
- Expect: get_repo called and returns an error
- Expect: the model tells the user the repo wasn't found, doesn't fabricate
4. "List the open issues on rust-lang/rust."
- Expect: list_issues called with state=open
- Expect: real issues, NOT pull requests
5. "Find me a popular Rust async runtime, then list its open issues."
- Expect: search_repos -> get_repo OR list_issues
- Expect: the model chains the tools without prompting
Five prompts, one minute to run, catches 90% of the regressions you would otherwise ship. Augment with real unit tests on ghFetch and the formatters; the prompt harness is the integration test.
Sometimes you need to debug why a tool is returning the wrong shape, and console.error is your friend:
console.error(`[search_repos] query=${query} limit=${limit}`);
These print to stderr, your client ignores them, and you see them in the terminal where you ran npm run dev. When you finalize a tool, delete the noise -- not because it breaks anything, but because shipping debug logging is sloppy.
You have a working server. Now we publish it. This is the checklist I run from memory before every release; if you skip a step, eventually one of them bites.
# 1. Clean rebuild from a clean tree
rm -rf dist
npm run build
# 2. Confirm the entry point boots
node dist/index.js
# Press Ctrl-C after a second of silence -- the hang is correct.
# 3. Confirm the shebang is byte zero of the entry point
head -n 1 dist/index.js
# Expect: #!/usr/bin/env node
# 4. Confirm the file list is what you think it is
npm pack --dry-run
# Expect: package.json, README.md, dist/index.js, dist/index.d.ts, LICENSE
# NOT expected: src/, tsconfig.json, .git, prompts.md, node_modules, .env
# 5. Install the local tarball in a scratch dir and run it like a real user
npm pack
# Create a fresh scratch directory anywhere outside this repo and cd into it
# (Unix: mkdir -p /tmp/gh-mcp-test && cd /tmp/gh-mcp-test
# Windows PowerShell: New-Item -ItemType Directory -Force -Path $env:TEMP\gh-mcp-test; Set-Location $env:TEMP\gh-mcp-test
# Windows cmd.exe: mkdir %TEMP%\gh-mcp-test && cd /d %TEMP%\gh-mcp-test)
npm init -y
npm install /path/to/yourscope-github-tools-mcp-0.1.0.tgz
npx -y @yourscope/github-tools-mcp
# Press Ctrl-C after silence -- if it boots, you are ready to publish.
Each step exists because I have shipped a package that failed it.
dist/ from a previous version. The new feature was missing.Syntax error: ( unexpected.npm pack --dry-run: shipped a 40MB tarball because I had a bug-investigation folder of screenshots in the repo root.dependencies was actually devDependencies. The first user who installed it got module-not-found errors at runtime.The whole checklist takes about three minutes. It will save you a deprecate-and-republish cycle every couple of releases. Do it.
If you are publishing to a public scope you have never published to before, you may need to run npm login --auth-type=web first. The web auth flow opens a browser, walks you through 2FA, and writes a session token to your ~/.npmrc.
Once you are logged in:
npm publish
That is the entire command. The publishConfig.access: public we set earlier handles the --access public flag automatically. The prepublishOnly hook does the safety rebuild and shebang check. If everything works, you see something like:
npm notice publishing to https://registry.npmjs.org/ (latest)
+ @yourscope/github-tools-mcp@0.1.0
If something goes wrong, here are the errors I have hit, in order of how often they bite:
402 Payment Required. You forgot publishConfig.access: public. Fix the package.json, commit, retry.
403 Forbidden. You are not a member of the scope, or you mis-typed the scope name. npm whoami to confirm your identity, then check the scope on npmjs.com.
409 Conflict. You are trying to publish a version number that already exists. npm versions are immutable -- once 0.1.0 is published, it is gone forever even if you npm unpublish it (the unpublish creates a tombstone; the version cannot be reused). Bump to 0.1.1 and retry.
E401 ENEEDAUTH. Your session token is missing or expired. Run npm login --auth-type=web again.
EOTP. npm asked for a one-time password and timed out, or the WebAuthn handshake didn't propagate yet. The session token from npm login --auth-type=web typically takes about 30 seconds to fully propagate through npm's auth backend. If you publish in the first 30 seconds after logging in, you can hit this even though everything else looks fine. The fix: wait 30 seconds, retry. If it fails again, wait another 30 and retry. I have hit this on three out of the last ten packages I published; the third retry has always worked. Do not theorize about TTY requirements or hunt for OTP codes -- the humble retry wins.
From the field: I once spent 25 minutes hunting for a TOTP code that did not exist because I forgot npm switched my account to WebAuthn months earlier. The terminal said EOTP and I assumed "OTP code." Always check what auth method your npm account is using before debugging an OTP error.
After publish, a useful sanity check:
npm view @yourscope/github-tools-mcp
You should see your version, your readme excerpt (if you wrote one), and the dependency list. If the page on https://www.npmjs.com/package/@yourscope/github-tools-mcp shows up within a minute, you are live.
The last test, and the one that proves the whole thing works.
Open a different machine -- a coworker's, a fresh container, anywhere your local source tree does not exist. Run:
claude mcp add gh-mcp -- npx -y @yourscope/github-tools-mcp
Open a Claude Code session and run:
Use the gh-mcp tools to search for popular MCP servers on github.
If it works, you have just used your own published package the same way every other Claude Code user in the world will use it. The whole pipeline -- npm registry, npx download, MCP transport, tool registration, model dispatch -- is exercised end to end. There is no shortcut here, and no faster way to find subtle bugs (a missing dep in dependencies, a shebang dropped in build, a path-resolution issue) than running it cold.
This step is non-negotiable for the first version. It is highly recommended for every subsequent version. I run it as part of the release workflow on real CI for my own packages, with a job that installs the published tarball in a clean container and exercises a smoke test.
Step back and inventory what you just shipped.
You have a public npm package, scoped to your namespace, that exposes three GitHub read-only tools. Anyone running Claude Code can install it with one command. The tools are well-described enough that the model picks the right one and calls it correctly on the first try, with no further coaching from the user. The error paths return information the model can act on, not opaque exceptions. The pre-publish checklist catches the categories of mistake that bite first-time publishers. The shebang is byte zero, stdout is the protocol channel, and you know in your bones why both of those things matter.
This is the smallest production-shaped MCP server. It has no auth, no schemas more complex than three Zod fields, no structured error recovery beyond surfacing failures as isError content, and no test layer. The next chapters fill those gaps in order:
After that: testing in Chapter 8, hosting (and the streamable HTTP transport that lets this server run anywhere besides a user's laptop) in Chapter 9, security-review survival in Chapter 10, four @yawlabs case studies in Chapter 11, and where MCP is heading in Chapter 12.
For now, you have a real package on npm. Test it on a friend's machine, watch them install it in one command, watch the model pick up your three tools and use them correctly. That moment -- the first time someone other than you uses something you built and shipped to a public registry -- is the moment this stops being a tutorial and starts being your career.
Then come back for Chapter 4. We have nine chapters of work to do.
Build the server in this chapter end to end against your own npm scope and ship 0.1.0 to the public registry. Three tools (search_repos, get_repo, list_issues), shebang at byte zero, stderr-only logging, the pre-publish checklist run cleanly, and a fresh-machine install that actually works.
The full exercise spec, including the rubric I use to grade submissions ("did you remember publishConfig.access: public," "does npm pack --dry-run exclude src/ and prompts.md," "is the shebang preserved in dist/index.js"), lives in the companion repo at exercises/module-3/exercise.md. The reference solution -- the same shape as this chapter, organized as a complete repo -- is at the module-3-final git tag.
Solution and starter code at https://github.com/YawLabs/mcp-in-production-companion, tag module-3-final. The companion repo is public -- just clone it.
A first production-shaped server is smaller than most people expect. The worked example in this chapter -- three read-only GitHub tools with schemas, error handling, and formatted output -- is around 250 lines of TypeScript, with no bundler or build pipeline beyond tsc. Most of the time goes into the local dev loop and the pre-publish checklist (about three minutes per release), not the code itself. Working the eleven steps end to end, including publishing to npm and verifying from a clean machine, is a matter of hours, not weeks.
This chapter's worked example uses TypeScript with the official @modelcontextprotocol/sdk, which is ESM-only as of v1 and needs Node 20 or newer. The protocol itself is line-delimited JSON-RPC over stdin/stdout, so any language can implement it, but the TypeScript SDK plus Zod input schemas gives you tight types, .describe() metadata the model actually reads, and the smallest gap between the code you write and the tools/list payload the model sees.