Skip to main content
Glama
README.md
# node-mcp

A training project for building MCP (Model Context Protocol) servers with Node.js and TypeScript.

## Stack

- Node.js with ES modules (`"type": "module"`)
- TypeScript (`NodeNext` module mode)
- [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) — official MCP TypeScript SDK
- [Zod](https://zod.dev) — input/output schema validation
- [Express](https://expressjs.com) — HTTP server for Streamable HTTP transport
- [`openweather-api-node`](https://github.com/loloToster/openweather-api-node) — OpenWeatherMap API client
- [`dotenv`](https://github.com/motdotla/dotenv) — environment variable loading

## Project Structure

```
node-mcp/
├── index.ts                        # Server entry point — registers all tools, selects transport
├── toolHandler.ts                  # Generic error-handling wrapper for tool handlers
├── tools/
│   ├── opperations/
│   │   ├── add.ts                  # Add tool
│   │   └── divide.ts               # Divide tool
│   └── weather/
│       └── weather.ts              # Weather tool
├── types.d.ts                      # Module declarations for untyped packages
├── .env                            # API keys and config (not committed)
├── dist/                           # Compiled output (generated by tsc)
├── tsconfig.json
└── package.json
```

## Getting Started

```bash
pnpm install
```

Create a `.env` file in the project root:

```
OPEN_WEATHER_API_KEY=your_key_here
NODE_ENV=development
TRANSPORT=stdio        # or "http"
MCP_PORT=3000          # optional, HTTP mode only
```

Build:

```bash
pnpm build
```

## Running the Server

### stdio mode (for Claude Desktop / Claude Code)

```bash
$env:TRANSPORT="stdio"; node dist/index.js
```

The server communicates over stdin/stdout and waits for JSON-RPC messages from an MCP client. No visible output when idle.

### HTTP mode (persistent process)

```bash
$env:TRANSPORT="http"; node dist/index.js
```

Starts an Express server on port 3000 (or `MCP_PORT`):

```
Starting Express MCP server on port 3000
MCP endpoint: http://localhost:3000/mcp
Server is running on http://localhost:3000
```

## Testing with MCP Inspector

### stdio mode

```bash
pnpm exec mcp-inspector node dist/index.js
```

### HTTP mode

Start the server first, then open the inspector without arguments:

```bash
pnpm exec mcp-inspector
```

In the inspector UI, set transport type to **Streamable HTTP** and URL to `http://localhost:3000/mcp`.

## Connecting to Claude Code

### stdio (project-level)

```bash
claude mcp add --scope project node-mcp-server node C:/Training/node-mcp/dist/index.js
```

### HTTP

Add the server URL directly in Claude Code's MCP settings pointing at `http://localhost:3000/mcp`.

## Tools

### `add`

Adds two numbers together and returns the result.

**Inputs**

| Name     | Type   | Description    |
|----------|--------|----------------|
| `numOne` | number | First operand  |
| `numTwo` | number | Second operand |

**Output**

```json
{ "result": "32 + 32 = 64" }
```

---

### `divide`

Divides two numbers and returns the result. Both inputs must be positive — validated by Zod before the handler runs.

**Inputs**

| Name     | Type              | Description              |
|----------|-------------------|--------------------------|
| `numOne` | number (positive) | Dividend                 |
| `numTwo` | number (positive) | Divisor (cannot be zero) |

**Output**

```json
{ "result": "10 / 2 = 5" }
```

---

### `weather`

Returns current weather data for a location using the OpenWeatherMap API. Results are in metric units.

Supply **one** of the following location strategies:

| Name           | Type                 | Description                              |
|----------------|----------------------|------------------------------------------|
| `lat`          | number (-90 to 90)   | Latitude (use with `lng`)                |
| `lng`          | number (-180 to 180) | Longitude (use with `lat`)               |
| `locationName` | string               | City or place name (e.g. `"London"`)     |
| `zipCode`      | string               | Zip/postal code (e.g. `"90210"`)         |

Priority order when multiple are provided: coordinates → zip code → location name. At least one strategy must be supplied or the tool returns an error.

**Output**

Returns a JSON object with temperature, humidity, wind speed, weather description, and more.

**Requires** `OPEN_WEATHER_API_KEY` in `.env`.

## Key Concepts

### Transport selection

The server supports two transports, selected via the `TRANSPORT` environment variable:

| Value   | Use case                                              |
|---------|-------------------------------------------------------|
| `stdio` | Local servers spawned as child processes (Claude Code, Claude Desktop) |
| `http`  | Persistent process accessible over a network          |

Tools are registered once and shared between both transports — the capability is decoupled from the delivery mechanism.

### Tool registration pattern

Each tool lives in its own file and exports a registration function that accepts the server instance:

```ts
export const myTool = (server: McpServer) => {
  server.registerTool('tool-name', { ... }, toolHandler(async (inputs) => {
    // handler logic
  }))
}
```

In `index.ts`:

```ts
myTool(server)
```

### Error handling — `toolHandler`

All tool handlers are wrapped with `toolHandler`, a generic wrapper that catches any thrown error and returns it in the correct MCP format:

```ts
export const toolHandler = <T>(fn: (inputs: T) => Promise<CallToolResult>) => {
  return async (inputs: T) => {
    try {
      return await fn(inputs)
    } catch (err) {
      return {
        isError: true,
        content: [{
          type: 'text' as const,
          text: err instanceof Error
            ? process.env.NODE_ENV === 'development' ? err.stack ?? err.message : err.message
            : 'Something went wrong...'
        }]
      }
    }
  }
}
```

- In development (`NODE_ENV=development`): returns the full stack trace
- In production: returns the error message only
- For non-Error throws: returns a generic fallback message

### Input validation with Zod

Zod schemas on `inputSchema` are validated by the SDK before your handler runs. Invalid inputs return a `-32602` protocol error — they never reach your handler:

```ts
inputSchema: {
  numTwo: z.number().positive()            // rejects zero and negatives
  numTwo: z.number().refine(n => n !== 0)  // rejects only zero
}
```

### Streamable HTTP transport

The HTTP transport uses Express with the SDK's `StreamableHTTPServerTransport`. Key points:

- `sessionIdGenerator: randomUUID` — required, assigns a unique ID to each client session
- `express.json()` middleware parses the request body before it reaches the transport
- The parsed body is passed as the third argument to `handleRequest` — the SDK uses it directly rather than re-parsing the raw request

```ts
app.use(express.json())
app.all('/mcp', (req, res) => {
  transport.handleRequest(req, res, req.body)
})
```

### ES module import paths

With `"module": "NodeNext"` in tsconfig, imports require explicit `.js` extensions even in `.ts` source files:

```ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
```

### Environment variables

Loaded via `import 'dotenv/config'` at the top of `index.ts`. Add `.env` to `.gitignore` to avoid committing API keys.