hello-mcp-server
hello-mcp-server
Simple MCP server — TypeScript ESM ("type": "module"), tsx, Express + Streamable HTTP transport.
Streamable HTTP (
POST/GET/DELETE /mcp) was picked for scale / larger audience. It is the current MCP standard; legacy SSE (/sse+/messages) is deprecated.
Prereqs
Node >= 22.5 (uses built-in node:sqlite, no extra DB deps)
Setup
npm install
cp .env.example .env # optional, default PORT=3081Run
npm run dev # tsx watch src/index.ts
npm start # tsx src/index.ts
npm run build && npm run start:prod # compiled dist/Server:
POST/GET/DELETE http://localhost:3081/mcpGET http://localhost:3081/healthGET http://localhost:3081/metrics— counts, avg latency, per-tool statsGET http://localhost:3081/traces?limit=50— recent HTTP tracesGET http://localhost:3081/traces/:traceId— HTTP + tool calls for one trace
Try it
Health:
curl http://localhost:3081/healthMCP Inspector:
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP, URL: http://localhost:3081/mcp
# Call tool `hello` with { "name": "pugazh" }Structure
src/
index.ts # bootstrap only (config, listen, graceful shutdown)
config.ts # env: PORT, SQLITE_PATH, LOG_LEVEL, LOG_PRETTY
logger.ts # pino (pretty in dev, JSON in prod)
observability/
trace.ts # AsyncLocalStorage trace context
db.ts # node:sqlite init + schema
repository.ts # logHttp/logTool/getMetrics/listRecentTraces/getTraceDetail
index.ts # barrel
mcp/
server.ts # createMcpServer() - registers all tools
sessions.ts # McpSessionStore (sessionId -> transport map)
tools/
withTracing.ts # runTracedTool() shared wrapper
hello.ts # hello tool
observability.ts # get_metrics / list_traces / get_trace tools
http/
app.ts # createApp() - middleware + route wiring
middleware/ # trace.ts (X-Trace-Id), requestLogger.ts (pino-http)
routes/ # health.ts, observability.ts, mcp.tsRules: HTTP layer never touches SQLite directly (via observability/);
MCP tools never touch Express (via runTracedTool); index.ts only
bootstraps. Add a tool = new file in mcp/tools/ + one line in
mcp/server.ts.
MCP tools
Tool | Input | Returns |
|
|
|
| — | HTTP + tool-call stats, per-tool / per-route breakdowns |
|
| Recent HTTP traces, newest first |
|
| HTTP + tool calls for one trace |
From VS Code Copilot (Agent mode): What do the server metrics say? or
Show me the trace for <traceId>. From the Inspector: call them like any
tool. Plain-HTTP mirrors: GET /metrics, /traces, /traces/:id.
Logs (pino + pino-pretty)
Every request prints a pretty line to the terminal in dev
(src/logger.ts, via pino-http; req.id is the X-Trace-Id, so terminal
lines join to the SQLite traces below). Tool calls log a tool called line,
session init/close and bad-session warnings are logged too.
LOG_LEVEL=debug npm run dev # trace/debug lines
LOG_PRETTY=false npm start # JSON lines (production style)Dual sink: the terminal stays pretty in dev, and every line is also
appended as JSON to LOG_FILE (default logs/app.log, auto-created,
git-ignored) — same events, grep-able and tail-able:
tail -f logs/app.log
cat logs/app.log | python3 -c "import sys,json; [print(json.loads(l)['msg'], json.loads(l).get('traceId')) for l in sys.stdin]"
LOG_FILE=false npm run dev # terminal only
LOG_FILE=/tmp/mcp.jsonl npm start # custom pathRotation is built in (pino-roll, no logrotate needed): the file rolls
daily or at 10m, keeping 14 rotated files + the active one
(logs/app.1.log, app.2.log, …). Tune via LOG_ROTATE_FREQUENCY
(daily|hourly|<ms>), LOG_ROTATE_SIZE (10m, 500k, 1g),
LOG_ROTATE_KEEP. Shutdown flushes the file transport first, so no
trailing lines are lost.
Note: GET /mcp holds an SSE stream open, so its request line prints when
the stream closes — that is expected.
Why you saw nothing before: telemetry only went to SQLite; stdout only had
startup lines. Now both exist: stdout (pino) + SQLite (/metrics, /traces).
Observability & traceability (node:sqlite)
Zero-dependency SQLite via node:sqlite (src/observability/, DB at SQLITE_PATH,
default data/mcp.db, WAL mode). Every request gets X-Trace-Id
(pass your own or one is generated); it propagates via AsyncLocalStorage
into MCP tool handlers, so HTTP + tool calls share one trace_id.
Tables: http_requests, tool_calls. New tools stay traceable via the
shared wrapper (src/mcp/tools/withTracing.ts):
const text = await runTracedTool("my_tool", { arg }, async () => {
return "result";
});curl http://localhost:3081/metrics
curl http://localhost:3081/traces?limit=20
curl http://localhost:3081/traces/<traceId>Add tools
New file in src/mcp/tools/ (see hello.ts) → register it in
src/mcp/server.ts. Zod defines the input schema. Always go through
runTracedTool so stdout + SQLite tracing stay automatic.
Scale note
Stateful by default via McpSessionStore (src/mcp/sessions.ts). For
horizontal scale without sticky sessions, switch to stateless mode
(sessionIdGenerator: undefined, no store) — see comment there.