Skip to main content
Glama
manuelbomi

observability-mcp-gateway

by manuelbomi

observability-mcp-gateway

A remote Model Context Protocol (MCP) server for a synthetic observability dataset - logs, latency/error-rate metrics, and alerts for five fictional microservices - plus a small client that drives it over the network. It's a learning project built to get hands-on with the "remote MCP server" pattern: HTTP + Server-Sent-Events instead of the more commonly-documented local stdio server.

Everything here is fake data generated by a seeded script (src/data/generator.ts). No real services, no real incidents. The point is the MCP plumbing, not the data.

Why remote (HTTP) instead of stdio?

Most MCP tutorials show the stdio transport: your MCP client (an IDE plugin, a desktop AI assistant app, an agent framework) spawns your server as a local child process and talks to it over stdin/stdout. That's simple and it's the right choice when:

  • the server only ever needs to run on the same machine as the client,

  • you're fine with the client managing the server's lifecycle (start/stop/crash-restart),

  • there's exactly one client talking to one server instance at a time.

A remote MCP server (what this repo builds) runs independently, reachable over HTTP, and clients connect to it like any other web API. That's the right choice when:

  • multiple clients/users need to share one running server (and its state - here, one SQLite-backed dataset everyone queries),

  • the server needs to live somewhere other than the client's machine (a teammate's laptop can't spawn a process on your server),

  • you want normal ops tooling to apply: a process manager, a reverse proxy, horizontal scaling, logs/metrics on the server itself, independent deploys.

The trade-off is that a remote server has to solve problems stdio gets for free: network transport framing, reconnection, and - the one stdio servers never have to think about - access control. Anyone who can reach the port can call your tools, so this repo includes a bearer-token gate (see Auth below).

The SDK's StreamableHTTP transport is the current standard way to do this: a single /mcp endpoint that accepts JSON-RPC requests over HTTP POST and can upgrade individual responses to Server-Sent-Events for streaming/long-running calls. It replaced the older plain-SSE transport from earlier versions of the spec.

This server runs StreamableHTTP in stateless mode - no session ID, no long-lived connection, a fresh McpServer per request. That's a deliberate simplification: every tool here is a quick read against SQLite, so there's no need for server-initiated push notifications or resumable streams, and stateless mode means no sticky-session requirements if you ever put a load balancer in front of it. A chattier server (one that pushes progress updates or long-running job notifications) would want the stateful mode instead - one McpServer per client session, tracked by a session ID header.

Related MCP server: MCP Hello World

Architecture

  ┌───────────────┐   POST /mcp (JSON-RPC)    ┌──────────────────────────────┐
  │   MCP client   │   Authorization: Bearer   │   observability-mcp-gateway  │
  │ (src/client,   │ ─────────────────────────▶│                              │
  │  or any MCP    │                            │  express                    │
  │  host app)     │◀───────────────────────── │   └─ bearerAuth middleware   │
  └───────────────┘   JSON-RPC result / SSE    │        └─ StreamableHTTP     │
                                                 │             Transport       │
                                                 │                 └─ McpServer│
                                                 │                      │      │
                                                 │           ┌──────────┴────┐ │
                                                 │           │               │ │
                                                 │        tools          resource│
                                                 │   (search_logs, etc.) (health)│
                                                 │           │               │ │
                                                 │           └──────┬────────┘ │
                                                 │                  ▼          │
                                                 │        ObservabilityStore   │
                                                 │         (SQLite, in-memory) │
                                                 │                  ▲          │
                                                 │        seeded once at boot  │
                                                 │         generateDataset()   │
                                                 └──────────────────────────────┘

Request flow for one tool call:

  1. Client sends POST /mcp with a JSON-RPC tools/call body and an Authorization: Bearer <token> header.

  2. bearerAuth middleware checks the header before anything else runs.

  3. A fresh StreamableHTTPServerTransport + McpServer pair handles the request (stateless mode - see above).

  4. The tool handler calls into ObservabilityStore, which runs an indexed SQL query against the in-memory SQLite database seeded at server startup.

  5. The result comes back as both structuredContent (typed, per the tool's outputSchema) and a human-readable content text block.

Repo layout

src/
  data/
    types.ts       domain types (LogEntry, MetricPoint, AlertRecord, ...)
    rng.ts          seeded PRNG (mulberry32) for reproducible fake data
    generator.ts    builds the synthetic dataset (logs/metrics/alerts)
    store.ts        SQLite-backed query layer the tools call into
  server/
    auth.ts         bearer-token middleware
    mcpServer.ts     builds the McpServer and registers tools + resource
    httpServer.ts    express app: health check, auth, /mcp route
    index.ts         process entrypoint (reads env, starts listening)
    tools/           the 5 MCP tools + their Zod schemas
    resources/       the live service-health resource
  client/
    index.ts         demo MCP client over StreamableHTTP
tests/
  store.test.ts       unit tests for the query layer
  auth.test.ts        unit tests for the auth middleware
  integration.test.ts  starts the real HTTP server + real SDK client

Setup

Requires Node 20+.

npm install
cp .env.example .env     # then edit MCP_GATEWAY_TOKEN to something real

Running it

Start the server (reads MCP_GATEWAY_TOKEN, HOST, PORT from the environment - npm run dev loads .env via your shell, or export them inline as shown):

MCP_GATEWAY_TOKEN=dev-secret npm run dev

(PowerShell: $env:MCP_GATEWAY_TOKEN = "dev-secret"; npm run dev)

observability-mcp-gateway listening on http://127.0.0.1:8787/mcp
health check: http://127.0.0.1:8787/healthz
auth: bearer token required (MCP_GATEWAY_TOKEN)

In another terminal, run the demo client against it:

MCP_GATEWAY_TOKEN=dev-secret npm run client

(PowerShell: $env:MCP_GATEWAY_TOKEN = "dev-secret"; npm run client)

Other scripts:

npm run seed        # print a summary of the generated dataset, no server needed
npm run build        # tsc -> dist/
npm start             # run the compiled server (dist/server/index.js)
npm test              # vitest: unit + integration tests
npm run lint          # eslint
npm run typecheck     # tsc --noEmit

Example tool calls

Output below is a lightly trimmed real run of npm run client against a freshly-seeded server (the dataset is deterministic per server start, so service names/alert text will match; exact timestamps/ids will differ).

search_logs - errors mentioning "timed out" on payments-gateway:

{
  "logs": [
    {
      "id": 2879,
      "timestamp": "2026-10-01T13:26:06.344Z",
      "service": "payments-gateway",
      "severity": "error",
      "message": "downstream request timed out",
      "traceId": "47d3695d616ca81d"
    }
  ],
  "matchCount": 5
}

get_service_health - payments-gateway (seeded with an ongoing incident):

{
  "service": "payments-gateway",
  "status": "down",
  "latencyP95Ms": 652,
  "errorRatePct": 12.99,
  "activeAlertCount": 1,
  "lastUpdated": "2026-10-01T13:28:08.567Z"
}

list_active_alerts:

{
  "alerts": [
    {
      "id": 1,
      "service": "payments-gateway",
      "severity": "critical",
      "status": "firing",
      "title": "Elevated error rate on payments-gateway",
      "description": "Error rate on payments-gateway has exceeded the 5% threshold for the last 15+ minutes, correlated with a spike in upstream processor timeouts.",
      "startedAt": "2026-10-01T12:46:08.567Z",
      "resolvedAt": null
    }
  ],
  "count": 1
}

get_latency_percentiles - checkout-api, trailing 60 minutes:

{ "service": "checkout-api", "windowMinutes": 60, "sampleCount": 60, "p50": 118, "p95": 149, "p99": 155 }

query_recent_errors - notifications-worker:

{
  "errors": [
    { "id": 5800, "timestamp": "2026-10-01T13:20:09.032Z", "service": "notifications-worker", "severity": "error", "message": "unhandled exception while processing request", "traceId": "ff0779e5ef9ead10" }
  ],
  "count": 5
}

resource read - observability://service-health/snapshot (abridged):

{
  "generatedAt": "2026-10-01T13:28:11.911Z",
  "services": [
    { "service": "checkout-api", "status": "healthy", "latencyP95Ms": 149, "errorRatePct": 0.55, "activeAlertCount": 0, "lastUpdated": "..." },
    { "service": "payments-gateway", "status": "down", "latencyP95Ms": 652, "errorRatePct": 12.99, "activeAlertCount": 1, "lastUpdated": "..." }
  ]
}

Auth approach

The /mcp route is gated by bearerAuth (src/server/auth.ts): every request must carry Authorization: Bearer <MCP_GATEWAY_TOKEN>, checked with a constant-time comparison (crypto.timingSafeEqual) so token-guessing can't be sped up by timing the response. No token configured means the server refuses to start at all - there's no "accidentally open" default.

This is intentionally the simplest thing that is still correct access control, not a production-grade auth system. It's here to demonstrate the point that a remote MCP server is a network service and needs one - a local stdio server gets access control for free because only the process that spawned it can talk to it; a remote one does not.

To harden this for a real deployment:

  • Swap in OAuth 2.1. The MCP auth spec (2025-03-26+) expects a remote server to act as an OAuth resource server: serve /.well-known/oauth-protected-resource, validate access tokens (JWT or opaque, verified against your IdP), and return WWW-Authenticate challenges per RFC 9728 instead of a bare 401. The SDK ships this as requireBearerAuth (@modelcontextprotocol/sdk/server/auth/...) plus mcpAuthRouter - the shape of the change is replacing bearerAuth(...) with that middleware and pointing it at a real OAuthTokenVerifier.

  • Scope tokens per capability. Right now one token can call every tool. A real deployment would mint tokens with scopes like observability:read and check them per tool/resource instead of all-or-nothing.

  • Rotate and expire tokens. A static long-lived secret in .env is fine for a side project; it should not survive contact with real users.

  • Rate-limit and log auth failures separately from normal traffic, so a credential-stuffing attempt is visible before it succeeds.

  • Put TLS in front of it. This server assumes it sits behind a TLS-terminating proxy; bearer tokens over plain HTTP are only as safe as the network they cross.

Lessons learned

A few things that weren't obvious going in:

  • Stateless StreamableHTTP is a real, supported mode, not a hack - the SDK ships an official example for it. For a server whose tools are all quick reads, it removes an entire class of ops problems (session affinity, reconnection logic) that the stateful mode has to solve.

  • outputSchema + structuredContent is worth the extra typing. It's tempting to just stuff a JSON string into a text block and call it done, but declaring the output shape means any MCP client that understands structured content gets typed data for free, and the schema doubles as documentation of exactly what a tool returns.

  • "Synthetic data" is easy to make too clean. The first pass at the generator produced tidy, uniform log lines and nobody would believe it was real telemetry. Adding deliberate mess - inconsistent casing, stray whitespace, occasional missing trace IDs - made search_logs demos feel like they were querying an actual system instead of a fixture.

  • A remote server's biggest conceptual shift from stdio isn't the transport, it's ownership. A stdio server's lifecycle is the client's problem. A remote server has to manage its own startup, shutdown, and access control, because nobody is holding its hand.

Related MCP Connectors

Related MCP Servers

  • F
    license
    B
    quality
    Not graded
    maintenance
    A minimal reference implementation of an MCP server that responds with "Hello, World" via Streamable HTTP. Serves as a baseline for integration testing and MCP client development with production-ready features including health checks, metrics, and containerized deployment.
    3
    53,329 npm
    -
  • A
    license
    A
    quality
    D
    maintenance
    An MCP server that emits OpenTelemetry traces, metrics, and logs to OTLP endpoints in various formats. It includes tool-mimicry profiles to generate realistic traffic resembling common tools like nginx, postgres, and AWS Lambda for testing and demonstration purposes.
    11
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    This MCP server provides a Streamable HTTP endpoint with bearer token authentication, exposing echo and add tools, and an info resource for remote client integration.
    -