# hello-mcp
A minimal MCP server built as a learning exercise. Uses the Streamable HTTP transport and demonstrates the core MCP primitives: tools and resources.
## What is MCP?
The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open standard for connecting AI models to external data and capabilities. An MCP server exposes three types of primitives:
| Primitive | Purpose | Initiated by |
|---|---|---|
| **Tools** | Do things (side effects allowed) | The model |
| **Resources** | Expose data (read-only) | The client/user |
| **Prompts** | Reusable prompt templates | The client/user |
## Project Structure
```
src/
server.js # Entry point — HTTP transport
stdio.js # Entry point — stdio transport (Claude Desktop)
mcp.js # MCP server + tool/resource registration
data/
greetings.js # Shared data (language → greeting mapping)
tools/
helloWorld.js # No-input tool
helloName.js # Tool with a required string input
greetName.js # Tool with error handling and MCP logging
listLanguages.js # Tool that exposes the languages list to the model
resources/
languages.js # Static resource exposing available languages
```
## Transport: Streamable HTTP
This server uses the `StreamableHTTPServerTransport` from `@modelcontextprotocol/sdk`. It runs as a plain Node.js HTTP server and handles MCP messages at `POST /mcp`.
Key details:
- Runs in **stateless mode** (`sessionIdGenerator: undefined`) — each request gets a fresh transport instance
- The client must send `Accept: application/json, text/event-stream` or the server will reject with 406
- The transport handles both JSON responses and SSE streams depending on the request
## Tools
Tools are registered with `server.registerTool(name, config, handler)`.
- `config.description` — shown to the model so it knows when to use the tool
- `config.inputSchema` — a plain object of Zod fields; the SDK wraps it in `z.object()` automatically
- Input is **validated at the transport layer** before the handler is called — invalid input returns `-32602` without ever reaching your code
### hello_world
No inputs. Always returns `"Hello, World!"`.
### hello_name
Takes a required `name` string. Returns `"Hello, {name}!"`.
### greet_name
Takes a `name` string and a `language` string. Returns a greeting in the specified language, or an `isError` response if the language isn't supported. Uses `sendLoggingMessage` to emit info, warning, and debug log messages through the MCP protocol — visible in the MCP Inspector notifications panel.
### list_languages
No inputs. Returns the list of supported languages. Useful for Claude Desktop where resources aren't surfaced to the model — the model can call this tool to discover valid options when `greet_name` returns an error.
## Resources
Resources are registered with `server.registerResource(name, uri, config, reader)`.
- Each resource has a **URI** (e.g. `languages://list`) that the client uses to fetch it
- The reader returns `{ contents: [{ uri, text }] }`
- Resources are read-only — no side effects
### languages://list
Returns a JSON array of supported language keys. Backed by the same `greetings.js` data used by the `greet_name` tool.
## Running the Server
### HTTP Streaming (MCP Inspector, remote clients)
```bash
npm install
npm start
```
The MCP endpoint will be available at `http://localhost:3000/mcp`.
To run on a different port:
```bash
PORT=3001 npm start
```
### stdio (Claude Desktop)
Claude Desktop spawns the process itself — no server needs to be running beforehand. Add the following to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"hello-mcp": {
"command": "node",
"args": ["/absolute/path/to/hello-mcp/src/stdio.js"]
}
}
}
```
Quit and relaunch Claude Desktop after editing the config. Check **Claude menu → Settings → Developer** to confirm the server shows a green dot.
## Testing with MCP Inspector
```bash
npx @modelcontextprotocol/inspector
```
Open the Inspector in **Chrome** (not Brave — its privacy settings block localhost requests), set the transport to **Streamable HTTP**, and enter `http://localhost:3000/mcp`.
You can also test with raw curl:
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}'
```
## Key Lessons
- **Multiple transports, one server** — `mcp.js` is transport-agnostic; `server.js` (HTTP) and `stdio.js` (stdio) are separate entry points that both import the same server instance
- **Separation of concerns** — `server.js` owns the HTTP transport, `mcp.js` owns the MCP server, each tool/resource lives in its own file
- **Single source of truth** — shared data in `src/data/` is imported by both tools and resources; no duplication
- **Zod validation is free** — defining an `inputSchema` gives you automatic input validation before your handler runs
- **`registerTool` over `tool`** — the `tool()` method is deprecated; `registerTool()` uses a cleaner config object pattern
- **Stateless vs stateful** — stateless mode is simpler and sufficient for most use cases; stateful mode adds session tracking via `sessionIdGenerator`
- **Factory pattern for server-dependent handlers** — handlers that need to call `server.sendLoggingMessage()` can't import `server` directly (circular dependency). Instead, export a `createHandler(server)` factory that closes over the server instance and returns the handler function. `mcp.js` calls `createHandler(server)` at registration time.
- **`isError: true` vs throwing** — returning `isError: true` sends the error text back to the model as readable tool output it can reason about; throwing produces a protocol-level JSON-RPC error the model can't see
- **MCP logging** — `server.sendLoggingMessage({ level, message })` sends log messages through the MCP protocol to the client; visible in MCP Inspector's notifications panel
- **Resources vs tools in Claude Desktop** — Claude Desktop doesn't surface resources to the model; expose the same data as a tool if the model needs to access it