washing-machine-mcp

washing-machine-mcp

MCP server for tiered, injection-resistant natural-language access to a washing-machine Turso database, supporting stdio and HTTP transports with security guarantees like silent downgrades and audit logging.

Category
访问服务器

README

washing-machine-mcp

Test status License: MPL-2.0 MCP protocol 2025-06-18

TL;DR. MCP server for tiered natural-language access to a washing-machine Turso DB. Stdio (local agent) and Streamable HTTP (remote agent). The differentiator is not "uses MCP" — it's that the tiered, injection-resistant authorization layer from a previous deployment was ported to MCP transport without weakening a single guarantee, and the test matrix proves it.

Why this is interesting

Most public MCP servers treat authorization as a footnote and have no real security test suite. This project inverts the priority: the threat model is the lede, the protocol is the transport. A reviewer should be able to read the matrix below and tell within five minutes whether the project's guarantees hold up.

Headlines

  • Belt-and-suspenders tier enforcement. The LLM is told the full column/filter space; the server downgrades silently server-side. Tests assert that prompt injection can't escalate access.
  • No bearer-token oracle. A failed Authorization: Bearer … always produces the same response shape, the same message, and the same status. Unknown-bearer and DB-error paths are byte-identical.
  • Stdout is the protocol. A static rg check forbids console.log anywhere in src/; a functional test spawns the server, sends real JSON-RPC, and asserts every byte on stdout parses as a JSON-RPC frame.
  • Two transports, one security model. Stdio (MCP_KEYS alias map) and Streamable HTTP (Authorization: Bearer …) share the query layer byte-for-byte. Neither accepts identity via tool arguments.
  • Audit log per request. Every request — success, RBAC denial, auth failure, rate-limit, body-too-large — emits exactly one [audit] JSON record on stderr, never raw token, never natural-language text.

See the threat-model matrix for the enumeration.

Threat model and test matrix

This is the lead section of the README on purpose. Anyone evaluating this project should be able to read this section and walk away with the full story.

Threat Test(s) that prove it's defended
SQL injection via filter name tests/sql-identifier.test.ts + tests/sql-injection.test.ts
SQL injection via column name tests/sql-injection.test.ts (DROP TABLE attempt)
SQL injection in filter value Kysely's parameter binding (no string concat anywhere) + tests/sql-injection.test.ts
Tier escalation by prompt injection ("pretend I'm admin") tests/mcp-integration.test.ts + invariant #5 (tool descriptions are tier-agnostic) + invariant #2 (tier derived only from credential)
Tier escalation by tool-argument smuggling tests/mcp-integration.test.ts ("HTTP-mode tools should not have identity in their schema")
Data leak via fallback to all-rows when filters rejected tests/tier-enforcement.test.ts
Information disclosure of higher-tier columns in tool descriptions tests/mcp-integration.test.ts (description regex scan)
Bearer-token oracle (distinguishing unknown-vs-valid tokens) tests/unknown-identity-oracle.test.ts
Stale-token oracle (distinguishing very-old from fresh tokens) tests/last-used.test.ts (refusal uses identical message)
Plaintext token disclosure in logs tests/stdout-purity.test.ts + tests/resolve-tier.test.ts + tests/audit.test.ts (audit never carries raw token)
Plaintext token disclosure in DB Schema column is api_key_hash (SHA-256); tests/token-hash.test.ts
Stdout pollution breaking the MCP protocol tests/stdout-purity.test.ts (static + functional)
LLM hang on input tests/zen-timeout.test.ts
Provider internals leaking via raw exception message safeErrorKind() in src/mcpServer.ts; covered by tests/tools-rbac.test.ts + tests/mcp-integration.test.ts
DNS rebinding (HTTP only) src/http.ts checkOriginAndHost + tests/http-hardening.test.ts
Origin/Host deny-by-default in production tests/http-hardening.test.ts (DEV_MODE=false + empty allowlist → 403)
Bearer-token replay via session-ID reuse with another user's token src/http.ts sessions keyed by token hash; mismatch → 401
Cleartext bearer on the wire TLS enforcement at startup; tests/tls-enforce.test.ts
Basic-tier triggering LLM cost Per-tier tool allowlist; tests/tool-allowlist.test.ts + tests/tools-rbac.test.ts
Oversized JSON-RPC frame DoS Body-size cap on /mcp (64 KB → 413); tests/http-hardening.test.ts
Brute-force / hammer attack Rate limit per (IP, bearer-fingerprint) → 429 with Retry-After; tests/http-hardening.test.ts
Bearer replay over very long windows last_used_at surface; tests/last-used.test.ts
Missing audit trail after an incident Every request emits exactly one [audit] JSON record; tests/audit.test.ts
Slow query tying up a worker / DoS via expensive SELECT QUERY_TIMEOUT_MS race in executeTierQuery; QueryTimeoutError mapped to query_timeout audit outcome; loser promise .catch-suppressed to avoid unhandled rejection; tests/query-timeout.test.ts

Guarantees and non-guarantees

What this project commits to, and what it does NOT commit to. Reading this section is faster than reading every line of src/.

✓ Guarantees

These hold today, are tested, and are documented as security invariants in AGENTS.md:

  • Tier projection is a pure function of the resolved transport credential. Stdio: alias from MCP_KEYS. HTTP: Authorization: Bearer SHA-256 hash. Neither accepts identity from tool arguments.
  • The LLM is told the full column/filter space; tier enforcement happens server-side after the LLM returns. Prompt-injection cannot escalate access.
  • All identifiers (filter names, column names) come from a regex whitelist (/^[a-z_][a-z0-9_]*$/, length ≤ 64) before reaching Kysely. Values are bound via Kysely parameterization.
  • Identifier, filter, and column rejections are silent and uniform. No response shape distinguishes "unknown tier" from "unknown column" from "DB error".
  • All rejected filters ⇒ empty results + error message. The auto-injected LIMIT does not count as a user-applied filter for this check.
  • STDOUT is only JSON-RPC. Logs go to stderr; logs are funneled through two named writers only (src/logger.ts, src/audit.ts).
  • One [audit] record per request, in a finally block, even on exception. Never raw token. Never natural-language text.
  • users.last_used_at is touched on every successful auth (fire-and-forget). Tokens older than STALE_TOKEN_REFUSE_DAYS (default 365d) get the identical IDENTITY_NOT_RESOLVED response — no stale-token oracle.

✗ Non-guarantees (or scoped)

These are deliberately deferred and called out so a reviewer doesn't discover them silently:

  • Silent column / filter downgrades. When a caller asks for a column or filter they don't have, the server silently drops it rather than returning an error. Rows are returned with whatever subset was allowed. The audit log and the response payload's requestedColumns / rejectedOperations arrays expose what was dropped, but a programmatic caller must inspect those — the response code is still 200. We trade explicitness for usability: a tier-restricted column is the common case, not the error case. If you'd rather get a 4xx on every rejected column, see the wrapper in src/mcpServer.ts (wrapTool).
  • Rate limiter is in-memory. Single-process, single-instance. Behind a load balancer with N replicas, the effective limit is N × 60/min. Multi-instance deployments must use a shared KV (the legacy Worker used Cloudflare KV; this project doesn't ship one). Tracked as a follow-up.
  • Query-timeout loser is abandoned, not cancelled. When QUERY_TIMEOUT_MS fires, we stop awaiting the DB promise and surface query_timeout to the caller, but we don't cancel the underlying statement — Kysely + libsql don't expose a statement-cancel API here, and the HTTP transport to Turso means the abandoned JS-side promise resolves on its own once Turso finishes. The loser has .catch(() => {}) attached so the eventual settle doesn't surface as an unhandled rejection.
  • SOURCE_IP extraction trusts the first X-Forwarded-For hop. Set TRUSTED_PROXY_HOPS to match your edge proxy topology. With TRUSTED_PROXY_HOPS=0 (default), only the immediate connection peer is trusted.
  • OAuth 2.1 introspection (RFC 7662) not implemented. Bearer validation is local-DB lookup, not a remote AS call. True federated identity is a separate workstream.
  • Tokens are SHA-256-hashed, not argon2id. For high-entropy server-generated tokens the choice is fine; documented in src/security/tokenHash.ts. If your threat model includes low-entropy tokens, swap the hash function there.
  • Tools are advertised regardless of tier. tools/list returns the same shape to every caller; the per-tier filter fires at invocation. This is a deliberate trade — see the per-tier tool allowlist test comment.
  • HTTP_PUBLIC_URL is the external URL. The TLS proxy assumption is that an upstream terminates TLS and forwards cleartext to this server. We do not enforce that the local listen socket is bound on https://.

Why this project exists

The same data platform had a Cloudflare Worker HTTP API exposing this. That deployment worked and the security work was done (SECURITY_FIXES.md in the legacy repo documents the threat model and the three classes of bugs that were fixed). This project is the updated canonical implementation:

  • Same authorization layer, lifted from src/filters/* and src/handlers/*
  • Same code paths; transport swapped from HTTP-POST to MCP
  • MCP-native: tools, not REST endpoints
  • Two transports shipped: stdio for local agents, Streamable HTTP for remote
  • No console.log to stdout; no tool-argument identity in HTTP mode

Architecture

                    ┌───────────────────────┐
                    │  MCP client          │
                    │  (Claude / Inspector │
                    │   / opencode / etc.)  │
                    └──────────┬────────────┘
                               │  JSON-RPC
            ┌──────────────────┴──────────────────┐
            │                                     │
        stdio transport                  Streamable HTTP transport
            │                                     │
   ┌────────▼────────┐                  ┌──────────▼──────────┐
   │   src/stdio.ts  │                  │     src/http.ts    │
   │                 │                  │                    │
   │ identity from   │                  │ identity from       │
   │ MCP_KEYS env    │                  │ Authorization:      │
   │ (alias map)     │                  │ Bearer <token>      │
   └────────┬────────┘                  └──────────┬──────────┘
            │                                     │
            └─────────────┬───────────────────────┘
                          │
                  ┌───────▼────────┐
                  │ src/mcpServer │
                  │  (shared)     │
                  │  4 tools +    │
                  │  1 resource   │
                  └───────┬────────┘
                          │
        ┌─────────────────┼─────────────────┐
        │                 │                 │
  query_natural_     query_structured    get_machine_by_id,
  language tool      tool, filter        list_available_columns
  (calls Zen LLM)    whitelist            (no LLM)
                          │
                  ┌───────▼────────┐
                  │ src/filters/   │
                  │ queryBuilder   │  ← SQL identifier regex whitelist
                  │ tierFilters    │  ← per-tier allowlists
                  └───────┬────────┘
                          │
                  ┌───────▼────────┐
                  │  Turso / libsql│
                  │  (hashed       │
                  │   tokens)      │
                  └────────────────┘

Setup

bun install

# 1. Create your Turso DB (one-time)
turso db create washing-machine-mcp
turso db shell washing-machine-mcp < db/schema.sql

# 2. Configure environment
cp .env.example .env
# Fill TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, API_KEY_ZEN, MCP_KEYS

# 3. Seed (one-time, idempotent)
bun run seed

# 4. Enable the git hooks (recommended)
git config core.hooksPath .githooks
# pre-commit: typecheck. pre-push: typecheck + full bun test.

# 5. Run
bun run start              # stdio (spawned by MCP clients)
bun run start:http         # streamable HTTP on $HTTP_PORT

MCP client configuration

Claude Desktop

{
  "mcpServers": {
    "washing-machines": {
      "command": "/absolute/path/to/bun",
      "args": ["run", "/absolute/path/to/washing-machine-mcp/index.ts"],
      "env": {
        "TURSO_DATABASE_URL": "libsql://...",
        "TURSO_AUTH_TOKEN": "...",
        "API_KEY_ZEN": "...",
        "MCP_KEYS": "basic:dk-demo-basic,medium:dk-demo-medium,high:dk-demo-high,ultra:dk-demo-ultra",
        "MCP_DEFAULT_IDENTITY": "medium"
      }
    }
  }
}

Pitfall: Claude Desktop does NOT inherit PATH reliably across platforms. The command MUST be an absolute path to bun.

Inspector

npx @modelcontextprotocol/inspector bun run /path/to/index.ts

This is the demo environment. Pick an identity, run the same query in two sessions, watch the row set change.

Tools and resources

Tool Args Auth Returns
query_structured { filters: [{filter, value}], columns?: string[] } bearer (HTTP) / env alias (stdio) Tier-projected rows + applied/rejected operations + generated SQL
query_natural_language { naturalLanguage: string } bearer (HTTP) / env alias (stdio) Same shape as query_structured after the LLM extracts structure
get_machine_by_id { id: number } bearer (HTTP) / env alias (stdio) One row projected to tier columns. Ids are enumerable within a tier.
list_available_columns {} bearer (HTTP) / env alias (stdio) { tier, columns } for the calling identity only
Resource Mode Contents
schema://current-tier stdio only Column list for the server's MCP_DEFAULT_IDENTITY. Reflects the default identity, not the calling identity of any tool call.

Two-tier demonstration

Same agent, same query, different identity → different rows.

stdio — restart the server with a different MCP_DEFAULT_IDENTITY:

# Step 1: ask for a supplier-cost filter as basic tier
MCP_DEFAULT_IDENTITY=basic bun run start:http
# Tool call: "Samsung under 1000"
# Result: 1 row (Samsung EcoWash 3000), 3 columns

# Step 2: same query as ultra tier
MCP_DEFAULT_IDENTITY=ultra bun run start:http
# Tool call: "Samsung under 1000"
# Result: still 1 row but 12 columns including supplierCost, marginPercent, ...

HTTP — curl with two different bearer tokens:

# dk-demo-basic — gets 3 columns, no supplier cost
curl -X POST http://localhost:3000/mcp \
     -H 'Authorization: Bearer dk-demo-basic' \
     -H 'Content-Type: application/json' \
     -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
          "params":{"name":"query_structured",
                    "arguments":{"filters":[{"filter":"brand","value":"Samsung"}]}}}'

# dk-demo-ultra — gets 12 columns
curl -X POST http://localhost:3000/mcp \
     -H 'Authorization: Bearer dk-demo-ultra' \
     -H 'Content-Type: application/json' \
     -d '... same body ...'

Difference between the two transports

stdio HTTP
Identity source MCP_DEFAULT_IDENTITY env Authorization: Bearer ... header
Tool arguments for identity? No — schema forbids it No — schema forbids it
Switching tiers Restart the server Change the bearer
TLS N/A (local pipe) Required upstream
DNS rebinding N/A HTTP_ALLOWED_ORIGINS
Resource registered yes (schema://current-tier) no (resources can't take per-call args)
RFC 9728 metadata n/a /.well-known/oauth-protected-resource
401 includes WWW-Authenticate n/a yes

In both transports, the security model is identical. Switching transport doesn't relax any rule.

Tests

bun test
# 103 pass, 0 fail, 253 expect() calls, ~21s

The suite includes a tests/stdout-purity.test.ts that spawns the actual stdio server, sends a real JSON-RPC ping, and asserts that every byte on stdout is a parseable JSON-RPC frame. This catches the most common way to silently break MCP clients (a stray console.log somewhere).

The protocol integration test in tests/mcp-integration.test.ts uses the SDK's InMemoryTransport to drive two clients through the same server in one process, with two different identities, and asserts that the tool results differ in exactly the way tier enforcement requires.

Pre-commit / pre-push hooks

.githooks/ ships two shell-script hooks. Enable them once per clone:

git config core.hooksPath .githooks
Hook Runs Why
pre-commit bunx --bun tsc --noEmit Fast type-check on every commit. Skipped automatically for chore: / docs: / ci: subjects, or when WASHING_MACHINE_MCP_SKIP_HOOKS=1.
pre-push bunx --bun tsc --noEmit + bun test Full suite (~21s). The "before pushing up" gate.

If the pre-push fails, the push is blocked — git push --no-verify is the escape hatch, but please don't reach for it without reading the failure.

MCP mechanics (the boring bit, kept here)

MCP mechanics (the boring bit, kept here)

  • SDK: @modelcontextprotocol/sdk@1.30.0
  • MCP spec version: 2025-06-18
  • Transport 1: StdioServerTransport
  • Transport 2: WebStandardStreamableHTTPServerTransport
  • Tool registration: McpServer.registerTool(name, {description, inputSchema}, handler)
  • Output: content: [{ type: "text", text: JSON.stringify(...) }] plus structuredContent for schema-aware clients

Lineage

This repo Source it descends from
src/filters/queryBuilder.ts (SQL identifier whitelist + whitelist enforcement) Legacy Worker src/filters/queryBuilder.ts (file diff in SECURITY_FIXES.md)
src/handlers/llm.ts (tier-agnostic system prompt + tier-agnostic retry) Legacy Worker src/handlers/llm.ts
src/handlers/washingMachines.ts (tier column arrays) Legacy Worker src/handlers/washingMachines.ts
src/filters/tierFilters.ts (filter definitions + per-tier allowlists) Legacy Worker src/filters/tierFilters.ts

The query layer is byte-for-byte equivalent. The transport is the only meaningful difference.

License

MPL-2.0 — see LICENSE. Source files that are derivative works of the licensed Covered Software should carry the MPL notice; if you redistribute a modified version, that modification is also under the MPL.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选