edidiong umana · writing
home
Build it10 min read

Build your first MCP server: give any AI agent a new skill in 30 minutes

One small TypeScript program with two tools, a currency converter and a live rain forecast. You'll test it with a real client, plug it into Claude Desktop, Claude Code, goose and the MCP Inspector, and learn the design and security choices that matter once real people use it.

You've written a function that does something useful. It converts naira to shillings, estimates a bus fare, or works out what a term's school fees mean per month. Now you want an AI agent to use it. Claude Desktop expects one plugin shape, your code editor another, and your own agent a third. Write the integration three times and you'll maintain it three times.

This post fixes that with one small program: a server any MCP-compatible agent can call, tested with a real client and connected to four hosts without changing a line.

The idea in plain words

The Model Context Protocol (MCP) is an open standard for connecting AI applications to tools and data. Anthropic introduced it in November 2024. It's now hosted by the Agentic AI Foundation, a neutral foundation under the Linux Foundation that also hosts goose and AGENTS.md.

Three words do most of the work. A host is the AI app a person uses, such as Claude Desktop. A server is a small program that offers tools: named actions, each with a description and a schema (a formal description) of its inputs. Inside the host, a client keeps one connection to one server. Write the server once and every MCP host can list your tools and call them.

This matters for the agentic economy, where agents act, pay and need to be trusted. An agent can only act through the tools you give it, so a tool is a boundary you control: it decides what the agent can do, checks every input and returns results you can log. MCP gives that boundary the same shape in every app.

Before you start

  • Node.js 22.19 or newer. The server alone runs on Node 20, but the MCP Inspector (step 7) needs 22.19. I ran everything below on Node 24.18.0 and npm 11.16.0, on Windows 11.
  • A terminal. Every command here works in bash, zsh and PowerShell.
  • An internet connection for npm and for the forecast tool. No API keys, no accounts and no money.
  • Optional: Claude Desktop, Claude Code or goose, to use the server from a real agent.

Exact versions used: @modelcontextprotocol/sdk 1.32.0 (the latest 1.x release), zod 4.6.5, typescript 7.0.2, @types/node 24.19.1 and @modelcontextprotocol/inspector 2.9.0.

Build it, step by step

Step 1: create the folder

mkdir city-helper-mcp
cd city-helper-mcp
mkdir src

Step 2: package.json

Create this file in the project folder. The versions are exact, with no ^, so the project builds the same way next year. "type": "module" lets you use import.

{
  "name": "city-helper-mcp",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "client": "node build/client.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.32.0",
    "zod": "4.6.5"
  },
  "devDependencies": {
    "@types/node": "24.19.1",
    "typescript": "7.0.2"
  }
}

Then install:

npm install

Step 3: tsconfig.json

This matches the TypeScript settings in the official MCP quickstart.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "types": ["node"],
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Step 4: .gitignore

Nothing here is secret yet, but start the habit now: keys belong in .env, and .env never reaches git.

node_modules/
build/
.env

Step 5: the server, src/index.ts

// src/index.ts: an MCP server with two tools, served over stdio
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// ---------- Tool 1 data: a fixed rates table ----------
// Units of each currency for 1 US dollar. Snapshot from the ExchangeRate-API
// open access endpoint (https://open.er-api.com/v6/latest/USD), rounded to 2 dp.
const RATES_AS_OF = "2026-10-05";
const RATES_SOURCE = "Rates By Exchange Rate API (https://www.exchangerate-api.com)";
const CURRENCIES = ["NGN", "GHS", "KES", "ZAR", "USD"] as const;
type Currency = (typeof CURRENCIES)[number];
const UNITS_PER_USD: Record<Currency, number> = {
  NGN: 1331.28,
  GHS: 11.62,
  KES: 129.58,
  ZAR: 16.66,
  USD: 1,
};

const round2 = (n: number) => Math.round(n * 100) / 100;

const server = new McpServer({ name: "city-helper", version: "1.0.0" });

server.registerTool(
  "convert_currency",
  {
    title: "Convert currency",
    description:
      "Convert an amount between NGN, GHS, KES, ZAR and USD using a fixed rates table. " +
      "Rates are a dated snapshot, not live market rates: always tell the user the rates_as_of date.",
    inputSchema: {
      amount: z.number().positive().max(1e12).describe("Amount to convert, e.g. 50000"),
      from: z.enum(CURRENCIES).describe("Currency code you have, e.g. NGN"),
      to: z.enum(CURRENCIES).describe("Currency code you want, e.g. KES"),
    },
    outputSchema: {
      amount: z.number(),
      from: z.enum(CURRENCIES),
      to: z.enum(CURRENCIES),
      result: z.number(),
      rate: z.number().describe("Units of `to` for 1 unit of `from`"),
      rates_as_of: z.string(),
      source: z.string(),
    },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async ({ amount, from, to }) => {
    const rate = UNITS_PER_USD[to] / UNITS_PER_USD[from];
    const output = {
      amount,
      from,
      to,
      result: round2(amount * rate),
      rate: Number(rate.toPrecision(6)),
      rates_as_of: RATES_AS_OF,
      source: RATES_SOURCE,
    };
    return {
      content: [
        {
          type: "text",
          text: `${amount} ${from} = ${output.result} ${to} (rate ${output.rate}, rates as of ${RATES_AS_OF})`,
        },
      ],
      structuredContent: output,
    };
  },
);

// ---------- Tool 2: a free public API with no key (Open-Meteo) ----------
const USER_AGENT = "city-helper-mcp/1.0";

async function getJson(url: string): Promise<unknown> {
  const res = await fetch(url, {
    headers: { "User-Agent": USER_AGENT },
    signal: AbortSignal.timeout(10_000), // never let a slow API hang the agent
  });
  if (!res.ok) throw new Error(`Upstream API returned HTTP ${res.status}`);
  return res.json();
}

type GeoResult = {
  results?: { name: string; country?: string; latitude: number; longitude: number }[];
};
type Forecast = {
  daily: {
    time: string[];
    precipitation_probability_max: (number | null)[];
    precipitation_sum: (number | null)[];
    temperature_2m_max: (number | null)[];
  };
};

server.registerTool(
  "get_rain_forecast",
  {
    title: "Rain forecast",
    description:
      "Daily rain forecast for a town or city: chance of rain, rainfall in mm and the high temperature. " +
      "Use it to plan market trips, travel or farm work. Data from Open-Meteo.",
    inputSchema: {
      city: z.string().min(2).max(80).describe("Town or city name, e.g. Kumasi"),
      country_code: z
        .string()
        .regex(/^[A-Za-z]{2}$/)
        .optional()
        .describe("Two-letter ISO country code to avoid mix-ups, e.g. GH"),
      days: z.number().int().min(1).max(7).default(3).describe("Days to forecast, 1 to 7"),
    },
    annotations: { readOnlyHint: true, openWorldHint: true },
  },
  async ({ city, country_code, days }) => {
    const geoUrl = new URL("https://geocoding-api.open-meteo.com/v1/search");
    geoUrl.searchParams.set("name", city);
    geoUrl.searchParams.set("count", "1");
    if (country_code) geoUrl.searchParams.set("countryCode", country_code.toUpperCase());

    const geo = (await getJson(geoUrl.toString())) as GeoResult;
    const place = geo.results?.[0];
    if (!place) {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: `No place called "${city}" found. Check the spelling or add a country_code such as NG, GH, KE or ZA.`,
          },
        ],
      };
    }

    const fcUrl = new URL("https://api.open-meteo.com/v1/forecast");
    fcUrl.searchParams.set("latitude", String(place.latitude));
    fcUrl.searchParams.set("longitude", String(place.longitude));
    fcUrl.searchParams.set("daily", "precipitation_probability_max,precipitation_sum,temperature_2m_max");
    fcUrl.searchParams.set("timezone", "auto");
    fcUrl.searchParams.set("forecast_days", String(days));

    const fc = (await getJson(fcUrl.toString())) as Forecast;
    const d = fc.daily;
    const lines = d.time.map(
      (date, i) =>
        `${date}: ${d.precipitation_probability_max[i] ?? "?"}% chance of rain, ` +
        `${d.precipitation_sum[i] ?? "?"} mm, high ${d.temperature_2m_max[i] ?? "?"} °C`,
    );
    return {
      content: [
        {
          type: "text",
          text: `Forecast for ${place.name}, ${place.country ?? ""} (Open-Meteo):\n${lines.join("\n")}`,
        },
      ],
    };
  },
);

// ---------- Start: stdout carries the protocol, so log to stderr only ----------
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("city-helper MCP server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

What each part does:

  • registerTool(name, config, handler) declares a tool. The host shows its name, description and input schema to the model, which decides when to call it.
  • The zod inputSchema becomes JSON Schema. The SDK checks every call against it before your handler runs, so from can only be one of the five codes.
  • outputSchema plus structuredContent give programs a typed result; the text block is for the model.
  • isError: true marks a failure the model can see and fix, such as a misspelt town.
  • console.error, never console.log. On stdio, standard output carries the protocol, and one stray log line corrupts it.

The rates are a snapshot I took from ExchangeRate-API's open endpoint on 5 October 2026, rounded to two decimals. That service needs no key but asks for attribution, which the source field carries. The forecast comes from Open-Meteo, which needs no key, allows up to 10,000 calls a day on its free tier, and is for non-commercial use under a CC BY 4.0 licence.

Step 6: a test client, src/client.ts

Before trusting any host, talk to the server yourself. This script starts the server exactly as a host would, lists its tools and makes three calls: one good, one deliberately bad, and one live.

// src/client.ts: start the server as a child process and talk MCP to it
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: process.execPath, // the same node binary running this script
  args: ["build/index.js"],
  stderr: "ignore", // hide the server's startup log line
});
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);

const server = client.getServerVersion();
console.log(`Connected to ${server?.name} ${server?.version}\n`);

// 1. Discover tools, exactly as an agent would
const { tools } = await client.listTools();
for (const tool of tools) {
  const params = Object.keys(tool.inputSchema.properties ?? {}).join(", ");
  console.log(`- ${tool.name}(${params})`);
}

// 2. A good call
const ok = await client.callTool({
  name: "convert_currency",
  arguments: { amount: 50000, from: "NGN", to: "KES" },
});
console.log("\nconvert_currency 50000 NGN -> KES");
console.log("text:      ", (ok.content as { text: string }[])[0].text);
console.log("structured:", JSON.stringify(ok.structuredContent));

// 3. A bad call: EUR is not in the enum, so the SDK rejects it before our code runs
const bad = await client.callTool({
  name: "convert_currency",
  arguments: { amount: 100, from: "EUR", to: "NGN" },
});
console.log("\nconvert_currency 100 EUR -> NGN");
console.log("isError:", bad.isError);
console.log((bad.content as { text: string }[])[0].text);

// 4. The live API tool
const rain = await client.callTool({
  name: "get_rain_forecast",
  arguments: { city: "Lagos", country_code: "NG", days: 3 },
});
console.log("\nget_rain_forecast Lagos, NG");
console.log((rain.content as { text: string }[])[0].text);

await client.close();

Run it and see it work

npm run build
npm run client

This is the real output from my run. The forecast lines are live, so yours will differ.

> city-helper-mcp@1.0.0 client
> node build/client.js

Connected to city-helper 1.0.0

- convert_currency(amount, from, to)
- get_rain_forecast(city, country_code, days)

convert_currency 50000 NGN -> KES
text:       50000 NGN = 4866.74 KES (rate 0.0973349, rates as of 2026-10-05)
structured: {"amount":50000,"from":"NGN","to":"KES","result":4866.74,"rate":0.0973349,"rates_as_of":"2026-10-05","source":"Rates By Exchange Rate API (https://www.exchangerate-api.com)"}

convert_currency 100 EUR -> NGN
isError: true
MCP error -32602: Input validation error: Invalid arguments for tool convert_currency: Invalid option: expected one of "NGN"|"GHS"|"KES"|"ZAR"|"USD" at from

get_rain_forecast Lagos, NG
Forecast for Lagos, Nigeria (Open-Meteo):
2026-10-05: 100% chance of rain, 13.3 mm, high 28.3 °C
2026-10-06: 100% chance of rain, 4.9 mm, high 27.9 °C
2026-10-07: 93% chance of rain, 2.1 mm, high 28.2 °C

Three things to notice. The client discovered both tools without being told about them. The good call returned the answer twice, as text for the model and as structured data for programs. And the bad call crashed nothing: it came back with isError: true and a message listing the valid codes, which a model can use to retry.

Step 7: check it with the MCP Inspector

The MCP Inspector is the official tool for testing servers. Its CLI mode is the fastest check there is, because it needs no host at all:

npx @modelcontextprotocol/inspector@2.9.0 --cli node build/index.js --method tools/call --tool-name convert_currency --tool-arg amount=200 --tool-arg from=GHS --tool-arg to=ZAR
city-helper MCP server running on stdio
{
  "content": [
    {
      "type": "text",
      "text": "200 GHS = 286.75 ZAR (rate 1.43373, rates as of 2026-10-05)"
    }
  ],
  "structuredContent": {
    "amount": 200,
    "from": "GHS",
    "to": "ZAR",
    "result": 286.75,
    "rate": 1.43373,
    "rates_as_of": "2026-10-05",
    "source": "Rates By Exchange Rate API (https://www.exchangerate-api.com)"
  }
}

The first line is the server's own startup message on stderr, which the Inspector passes through. For the web interface, drop --cli and the method flags:

npx @modelcontextprotocol/inspector@2.9.0 node build/index.js

It prints a local address on port 6274 with a one-time token in it. Open that address, connect, and you can list and call each tool by hand.

Do this todayRun npx @modelcontextprotocol/inspector@2.9.0 --cli with --method tools/list against one MCP server you already use, and read every tool description it prints. That text goes straight into your model's context.

Connect it to an agent

In every host, use the absolute path of build/index.js. I ran the server, client and Inspector myself; for the three hosts below, I checked each command and config against the official docs but didn't run the apps for this post.

Claude Desktop

In Claude Desktop, open Settings, then Developer, then Edit Config. The file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude\claude_desktop_config.json on Windows. On macOS or Linux:

{
  "mcpServers": {
    "city-helper": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/city-helper-mcp/build/index.js"]
    }
  }
}

On Windows, double every backslash inside JSON strings:

{
  "mcpServers": {
    "city-helper": {
      "command": "node",
      "args": ["C:\\PATH\\TO\\city-helper-mcp\\build\\index.js"]
    }
  }
}

Then quit Claude Desktop completely and reopen it; closing the window isn't enough. If the tools don't appear, read mcp-server-city-helper.log in ~/Library/Logs/Claude (macOS) or %APPDATA%\Claude\logs (Windows). It holds your server's stderr.

Claude Code

The -- separates Claude Code's own options from the command that starts your server:

claude mcp add --transport stdio city-helper -- node /ABSOLUTE/PATH/TO/city-helper-mcp/build/index.js
claude mcp list

Inside a session, /mcp shows whether the server connected. The default scope is local, which means only you, in this project. Add --scope user to use it in all your projects, or --scope project to write it to a .mcp.json file your team can commit.

goose

goose calls MCP servers "extensions". The quickest way to try one is a session with the extension attached:

goose session --with-extension "node /ABSOLUTE/PATH/TO/city-helper-mcp/build/index.js"

To keep it, run goose configure, choose Add Extension, then Command-line Extension. Or add it by hand to ~/.config/goose/config.yaml (on Windows, %APPDATA%\Block\goose\config\config.yaml):

extensions:
  city-helper:
    type: stdio
    name: city-helper
    enabled: true
    cmd: node
    args: ["/ABSOLUTE/PATH/TO/city-helper-mcp/build/index.js"]
    envs: {}
    timeout: 300

Going deeper

Here's what changes when other people, other machines and real money get involved.

stdio or Streamable HTTP

MCP defines two standard transports. With stdio, the host starts your server as a child process and they exchange newline-delimited JSON-RPC messages over standard input and output. There's no network port, nothing to authenticate, and the server only ever has one client. The spec says clients should support stdio whenever possible.

With Streamable HTTP, your server is an independent process with one endpoint, such as /mcp, that accepts POST and GET and can stream with server-sent events. That's what you need when a team, a phone app or another machine has to reach the tool. It also makes your server a web service, with the duties that come with it. The spec requires servers to validate the Origin header to block DNS rebinding, a trick that lets a malicious web page reach a server on your own machine. Locally, bind to 127.0.0.1, not 0.0.0.0, and authenticate every connection. In SDK 1.x, createMcpExpressApp() from @modelcontextprotocol/sdk/server/express.js applies the rebinding protection by default when it binds to localhost.

Start on stdio. Move to HTTP when a second person or machine needs the tool.

Tool design: names, descriptions and errors

The model chooses tools by reading them, so a description is really a prompt. Say when to use the tool and what it must not assume: convert_currency tells the model its rates are a dated snapshot.

  • Names. The spec recommends 1 to 128 characters, case-sensitive, using only letters, digits, underscores, hyphens and dots. A verb and a noun, like get_rain_forecast, reads well in a list of fifty tools.
  • Tight inputs. An enum makes an invalid currency impossible to send. Limits such as max(7) stop a model from asking for a 365-day forecast.
  • Two kinds of error. The spec separates protocol errors (an unknown tool, a malformed request) from tool execution errors, which come back as a normal result with isError: true. Bad input and failed API calls belong in the second group, because the model can read them and correct itself. In SDK 1.32.0, a failed schema check or an exception thrown in your handler is returned that way, as the EUR call showed.
  • Errors that teach. "Add a country_code such as NG, GH, KE or ZA" fixes the next call. "Error 400" doesn't.
  • Small, bounded results. Return the fields the model needs, and put a timeout on every network call, as getJson does with AbortSignal.timeout.

The readOnlyHint and openWorldHint annotations help hosts decide when to ask for approval, but they're hints. The spec says clients must treat annotations as untrusted unless the server itself is trusted.

Prompt injection through tool output

Everything a tool returns goes into the model's context. If a tool fetches a web page, an email or a customer's message, whoever wrote that text can write instructions to your model, and it may follow them. Claude Code's docs warn about exactly this for servers that fetch external content. My post on prompt injection covers the design side.

The forecast tool is low risk: it returns numbers and a place name. A tool that returns text written by strangers is not. The spec says servers must validate tool inputs and sanitise tool outputs, so on the server side:

  • Prefer numbers, dates and enums to free text. Pull out the fields you need instead of passing a raw page through.
  • Cut long text to a fixed length, and label it as data from an outside source.
  • Never put a tool that reads untrusted text in the same agent as a tool that moves money or sends messages unless a person approves each action. The spec says a human should always be able to deny a tool call.

Tool descriptions travel the same path, so a malicious server can hide instructions in them. Install servers only from sources you trust, and read their tools/list output first.

Least privilege, and secrets in env

A local MCP server runs with your user account's permissions, so it can do anything you can. Give each server only what its tools need: a read-only API key rather than an admin one, one folder rather than your whole disk, and never a wallet key. Put limits such as a maximum amount in code, where the model can't argue with them, not in the prompt.

Secrets never go in source code. Read them from the environment and fail fast when one is missing:

// Read secrets from the environment, and fail fast without them
const apiKey = process.env.PRICES_API_KEY;
if (!apiKey) {
  console.error("PRICES_API_KEY is not set. Add it to the env block of your MCP config.");
  process.exit(1);
}

Then pass the value through the host's config: an env block in Claude Desktop, --env KEY=value with claude mcp add, or envs in goose, whose docs recommend its own secret store for sensitive values. In Claude Desktop that looks like this:

{
  "mcpServers": {
    "market-prices": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/market-prices/build/index.js"],
      "env": { "PRICES_API_KEY": "paste-your-key-here" }
    }
  }
}

A config file holding a key is a plain-text secret, so never commit it or share a screenshot of it. If you build your own host with the SDK, StdioClientTransport passes the server only a short allowlist of environment variables, such as PATH, unless you give it an env. Your shell's other secrets stay out of every server by default.

Versioning

Three version numbers matter here.

  • Your server's. The version in new McpServer(...) is yours. Renaming a tool or making an optional input required breaks every agent and prompt that relied on the old shape. Add new tools rather than changing old ones, and bump the version when you do.
  • The SDK's. Pin it exactly, as step 2 does. The TypeScript SDK now has two lines. v2 (@modelcontextprotocol/server and @modelcontextprotocol/client) is the stable line, released alongside the 2026-07-28 spec, and the official quickstart uses it. v1.x, used here, gets bug fixes and security updates for at least six months after v2's release, so plan your move within that window.
  • The protocol's. MCP versions are dates, marking the last backwards-incompatible change. The current revision is 2026-07-28. This server, on SDK 1.32.0, negotiates 2025-11-25 during its handshake (the Inspector's --method initialize shows it), and the spec defines how newer clients work with handshake-based servers like this one.

Your 30-minute build

Pick one job from your own city that people still do by hand, and wrap it as a single MCP tool. Copy this project, replace the two tools with yours, and stop when it passes these checks:

  • ☐ npm run client lists your tool, with a name and a description that says when to use it.
  • ☐ A good call returns the right answer, as text and as structuredContent, with an "as of" date for any data.
  • ☐ A bad call returns isError: true with a message that says how to fix it, and the server keeps running.
  • ☐ npx @modelcontextprotocol/inspector@2.9.0 --cli node build/index.js --method tools/list --strict exits with code 0.

Three ideas to start from:

  • Bus fares. estimate_fare(from_stop, to_stop) for danfo, matatu or trotro routes, from a fares table you collect yourself, with a "fares as of" date.
  • Market prices. get_market_price(item, market) for a basket of tomatoes, a bag of rice or a paint bucket of garri at Mile 12 or Makola, from a table you update each week.
  • School fees. plan_fee_savings(total_fees, weeks_to_resumption), which turns next term's fees into a weekly savings target, rounded up to the nearest ₦500 or GH₵10.
Hand this to your coding agent
Help me build a small MCP server in TypeScript, in a new folder called
my-city-tool.

My tool: [ONE SENTENCE, e.g. "estimate_fare: estimate a danfo fare
between two Lagos bus stops from a fares table in the code, with a
fares_as_of date"]

Stack, pinned to exact versions (no ^ or ~):
- Node.js 22.19 or newer
- dependencies: @modelcontextprotocol/sdk 1.32.0, zod 4.6.5
- devDependencies: typescript 7.0.2, @types/node 24.19.1
- package.json: "type": "module", scripts "build": "tsc" and "client":
  "node build/client.js"
- tsconfig.json: target ES2022, module and moduleResolution Node16,
  rootDir src, outDir build, strict true

Files:
1. package.json, tsconfig.json, and .gitignore containing node_modules/,
   build/ and .env
2. src/index.ts: an McpServer named "my-city-tool" connected to
   StdioServerTransport, with exactly one tool.
   - Use server.registerTool with a title, a description that says when
     to use the tool, a zod inputSchema (enums and min/max wherever
     possible, .describe() on every field) and an outputSchema.
   - Return both a text content block and structuredContent.
   - Any data table in the code carries an "as of" date and a source,
     and the tool returns them.
   - For bad input or a failed lookup, return isError: true with a
     message that tells the caller how to fix it. Never crash the
     server.
   - If the tool calls an HTTP API, use one that needs no key, add a 10
     second timeout with AbortSignal.timeout, and return only the fields
     the model needs.
   - Log with console.error only. Never write to stdout with
     console.log.
3. src/client.ts: use Client and StdioClientTransport from the SDK to
   start node build/index.js, list the tools, make one good call and one
   bad call, and print both results.

Acceptance checks:
- npm run build, then npm run client, prints the tool's name and
  parameters.
- The good call returns the right answer as text and structuredContent,
  including the "as of" date.
- The bad call returns isError: true with a helpful message, and the
  server keeps running.
- npx @modelcontextprotocol/inspector@2.9.0 --cli node build/index.js
  --method tools/list --strict exits with code 0.

Rules:
- No secrets in code. If a key is ever needed, read it from process.env,
  and keep .env in .gitignore.
- Before you say you are finished, run npm run build, npm run client and
  the Inspector command yourself, show me their real output, and fix
  anything that fails. Never show me output you did not run.

Learn it properly

For tool calling, MCP and the rest of the agent stack from first principles, Track 1 of the AI Study Group, the Agentic AI engineer roadmap, is free and self-paced.

Sources