MCP REST Bridge

TypeScript MCP server that lets Claude work with a store REST API (inventory, orders, sales). Writes are guarded by dry-run, runs over stdio or Streamable HTTP, includes a demo API and Vitest tests.

Documentation

mcp-rest-bridge

An MCP server that lets Claude (or any MCP client) work with a store through its REST API: "what's running low?", "where is order 1007?", "how did we do in the last 30 days?", "we just got 20 drills, add them".

It comes with a small sample store API (products, customers, orders in SQLite) so the whole thing runs locally in a minute. Every name and number in that data is made up.

This is a personal portfolio project. What it shows is the pattern: a typed, tested MCP layer in front of an existing HTTP API, with safe writes and errors the model can act on.

Features

tools/list in the MCP Inspector CLI: 6 read-only tools and 2 write tools

Read tools:

ToolWhat it answers
search_productsFind products by name/SKU text and/or category, with paging
get_productOne product's price, stock and reorder level
list_low_stockProducts at or below their reorder level (or a threshold you give)
get_orderOrder status, customer, line items, total
list_customer_ordersA customer's orders, newest first
sales_summaryRevenue, order count, average order, status breakdown, top products for a date range

Write tools, only registered when STORE_ALLOW_WRITES=true:

ToolWhat it does
adjust_stockAdd or remove units with a reason; logged in a stock_movements table
create_orderPending order at list prices for an existing customer; reserves the stock

Both write tools are dry runs unless the call says confirm: true. The dry run goes through the real API inside a transaction that is rolled back, so the preview applies the same rules as the real write (no negative stock, enough stock for every line, customer exists). The tool tells the model to show the preview and ask the user before confirming.

adjust_stock dry run, then the same call with confirm: true

Also:

  • Resource store://categories and resource template store://products/{sku}
  • Prompt restock_report: has the model build a purchase list from list_low_stock
  • Every tool has a zod input schema and an output schema and returns structuredContent plus JSON text
  • Two transports: stdio (Claude Desktop, Claude Code) and Streamable HTTP (stateless, bearer token required, DNS-rebinding protection from the SDK when bound to localhost)
  • Upstream failures become tool errors with a next step instead of stack traces:
UpstreamWhat the model sees
404Product ZZZ-999 not found. Use search_products to find a valid SKU
409Not possible: Only 0 of TLS-005 in stock, 1 requested.
401The store API rejected the credentials (401). The server operator should check STORE_API_KEY.
timeoutStore API did not answer within 5000 ms. Try again, or ask for a smaller result.
downCould not reach the store API at http://... The API may be down.
5xx / bad JSONThe store API failed (502). Try again later. (details go to stderr)

A 404 from the API becomes a tool error with a recovery hint

Architecture

Claude Desktop / Claude Code / any MCP client
        │  stdio, or Streamable HTTP + bearer token (src/stdio.ts, src/http.ts)
        ▼
src/mcp/             server.ts (read tools, resources, prompt), write-tools.ts (dry run / confirm),
        │            tool-result.ts (ApiError -> tool error text), http-app.ts (HTTP endpoint)
        ▼
src/client/          StoreApiClient: fetch + timeout + bearer token, zod-validated responses,
        │            ApiError with a `kind` (not_found, conflict, timeout, ...)
        ▼  HTTP
src/api/             sample store REST API: Express 5 + node:sqlite, seeded sample data

src/domain.ts holds the zod schemas shared by the API, the client and the tools' output schemas, so a change in the API's response shape shows up as a type error, and at runtime as a clear bad_response error.

Run it

Needs Node 22.13 or newer (uses the built-in node:sqlite).

npm install
npm run build

# terminal 1: the sample API (data resets on every start unless DATABASE_PATH is a file)
API_KEY=change-me-to-something-long npm run api

# terminal 2: try the MCP server with the MCP Inspector CLI
npx @modelcontextprotocol/inspector --cli node dist/stdio.js \
  -e STORE_API_URL=http://127.0.0.1:4000/api -e STORE_API_KEY=change-me-to-something-long \
  --method tools/call --tool-name list_low_stock

All settings are in .env.example.

Claude Desktop

Add this to claude_desktop_config.json (absolute path to your checkout), start the API, restart Claude:

{
  "mcpServers": {
    "store": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-rest-bridge/dist/stdio.js"],
      "env": {
        "STORE_API_URL": "http://127.0.0.1:4000/api",
        "STORE_API_KEY": "change-me-to-something-long",
        "STORE_ALLOW_WRITES": "false"
      }
    }
  }
}

Claude Code:

claude mcp add store -e STORE_API_URL=http://127.0.0.1:4000/api -e STORE_API_KEY=change-me-to-something-long \
  -- node /absolute/path/to/mcp-rest-bridge/dist/stdio.js

Over HTTP

STORE_API_KEY=change-me-to-something-long MCP_HTTP_TOKEN=another-long-random-token npm run mcp:http
# POST http://127.0.0.1:3000/mcp with "Authorization: Bearer another-long-random-token"
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --transport http \
  --header "Authorization: Bearer another-long-random-token" --method tools/list

Binding to anything other than localhost turns off the SDK's DNS-rebinding check; put it behind HTTPS and a reverse proxy in that case.

Pointing it at your own API

The MCP layer only knows StoreApiClient, so connecting a real system means changing two files:

  1. src/domain.ts: describe your API's responses with zod (or reuse your OpenAPI types).
  2. src/client/rest-client.ts: one method per endpoint. request() already handles the base URL, bearer token, timeout, JSON parsing, response validation and status-to-ApiError mapping. If your API uses another auth scheme (API key header, OAuth client credentials), change the headers there.

Then adjust the tools in src/mcp/ to the questions your users ask. Things that matter in practice:

  • Keep tool descriptions specific: what it returns, units (cents), and which tool to call first.
  • Write a recovery hint for every 404 (callApi(fn, "Use search_products to find a valid SKU")).
  • Keep writes behind STORE_ALLOW_WRITES and the dry run, and give the server an API key that can only do what the tools need.
  • Delete src/api/ once you have a real API; tests can keep using it as a stand-in.

Tests

npm run check   # typecheck + eslint + vitest + build

32 tests. They start the real sample API on a random port and talk to the MCP server through the SDK's own Client: over the in-memory transport, as a spawned stdio process, and over Streamable HTTP. Covered: every tool, the resources, the prompt, input validation, dry run vs confirm (stock and orders checked in the database afterwards), 401 / 404 / 409 / timeout / connection refused, HTTP token check, 405 on GET and the Host-header check.

Screenshots of a sales_summary call and the test run: docs/sales.png, docs/tests.png. They show the bundled sample data.

Stack

TypeScript (strict), Node 22, @modelcontextprotocol/sdk 1.32, zod 4, Express 5, node:sqlite, vitest 5, ESLint with typescript-eslint.

Related

If you also use Claude Code for freelance work, I sell a paid workspace kit with skills for job triage, proposals, quotes and delivery handoff: Claude Code Freelance OS.

License

MIT