Skip to main content
Glama
pugazece

hello-mcp-server

by pugazece

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=3081

Run

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/mcp

  • GET http://localhost:3081/health

  • GET http://localhost:3081/metrics — counts, avg latency, per-tool stats

  • GET http://localhost:3081/traces?limit=50 — recent HTTP traces

  • GET http://localhost:3081/traces/:traceId — HTTP + tool calls for one trace

Try it

Health:

curl http://localhost:3081/health

MCP 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.ts

Rules: 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

hello

{ name? }

Hello, {name or world}!

get_metrics

HTTP + tool-call stats, per-tool / per-route breakdowns

list_traces

{ limit? }

Recent HTTP traces, newest first

get_trace

{ trace_id }

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 path

Rotation 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.