mcp-guarded-tools

mcp-guarded-tools

An MCP server that exposes 43 domain tools behind 4 meta-tools, applying guardrails such as budgets, rate limits, schema validation, human-in-the-loop confirmation for mutations, PII redaction, and an append-only audit log while reducing context window usage.

Category
访问服务器

README

mcp-guarded-tools

An MCP server that treats a large tool surface as a governance problem: 43 domain tools sit behind 4 always-on meta-tools, and every call passes a composable chain of guardrails — scope, budgets, rate limits, schema validation, human-in-the-loop confirmation for writes, PII redaction, and an append-only audit log.

Most public MCP servers are thin API wrappers: they register every tool up front and execute whatever the model asks. That works at 5 tools. At 40+, two problems appear that this repo is built to explore:

  1. Context cost — every tool schema is paid for on every request, whether used or not.
  2. Blast radius — an agent loop that can call refund_order or purge_tenant_data directly has no natural place to put a budget, an approval step, or a review trail.

Quickstart (60 seconds, zero credentials)

npm install
npm run demo

No API keys, no network calls, no database. The demo spawns the real server as a child process, drives it over the real stdio MCP transport, and prints the transcript below.

npm run build   # tsc, strict mode
npm test        # vitest — 62 tests, all offline
npm run lint    # eslint + prettier
npm run tokens  # the context-cost measurement

Requires Node 20+.


What the model actually sees

Tools exposed over MCP: 4
  - search_tools     find tools by keyword
  - describe_tool    get one tool's full JSON Schema
  - confirm_action   mint a single-use token for a mutating tool
  - invoke_tool      run a tool by name

Domain tools hidden behind them: 43

The model discovers tools by searching, not by receiving 43 schemas up front.


Measured context cost

Both surfaces expose the same 43 tools. The difference is what lands in the context window before the conversation starts.

Surface Tokens
A. All 43 schemas dumped (conventional tools/list) 2,913
B. Tool-search facade (4 meta-tools) 486
Difference 2,427 fewer tokens (83.3% smaller)

How this was counted. Each tool is serialised into the exact MCP Tool shape ({name, description, inputSchema}), the array is JSON.stringify'd with no pretty-printing, and the string is tokenized with gpt-tokenizer using the o200k_base encoding. Surface B is measured from the live tools/list response of the running server in that same run, not from a hand-written copy. Counts cover the tool-definition payload only — protocol envelope and system prompt are identical for both and excluded.

Reproduce:

npm run demo    # prints surface B measured live  -> 486
npm run tokens  # standalone; uses static schema copies -> 477

The two commands differ by 9 tokens (486 vs 477) because npm run tokens measures a static copy of the meta-tool definitions while npm run demo measures what the server actually emitted. 486 is the honest number — it is what a client really receives. The gap is left visible rather than reconciled away.

This is not a free win, and the break-even is measured too. Surface B moves cost from startup to run time: discovering a tool costs a search_tools reply (178 tokens for a 5-result response) and, when the schema is needed, a describe_tool reply (88 tokens for orders.refund_order). At ~266 tokens per discovery cycle, the 2,427-token saving is repaid after roughly 10 search+describe cycles in a single session. Below that, the facade wins; above it, dumping every schema would have been cheaper. Sessions that touch a handful of tools out of a large catalogue are the case this design targets — a session that methodically uses most of the catalogue is not.

Scaling note: surface B is fixed at 4 schemas regardless of catalogue size, so the gap widens as tools are added. It also narrows to nothing if the catalogue is small — at ~10 tools this indirection is not worth it.


Architecture

flowchart TD
    C[MCP client] -->|stdio JSON-RPC| M{{4 meta-tools}}
    M --> S[search_tools]
    M --> D[describe_tool]
    M --> K[confirm_action]
    M --> I[invoke_tool]

    S -.-> R[(Tool registry<br/>43 tools)]
    D -.-> R
    K -->|mints single-use<br/>args-bound token| T[(Session state)]

    I --> G1
    subgraph GC [Guardrail chain — first denial short-circuits]
        direction TB
        G1[1 · scope<br/>allow-list, default-deny] --> G2[2 · budget<br/>requests + tokens]
        G2 --> G3[3 · rate limit<br/>sliding window per tool]
        G3 --> G4[4 · validation<br/>Zod parse + coerce]
        G4 --> G5[5 · confirmation<br/>required for mutating]
    end

    G5 -->|allowed| H[Tool handler<br/>tenant-filtered data]
    H --> RD[PII redaction<br/>in + out]
    RD --> C

    G1 & G2 & G3 & G4 & G5 -.->|denied| A
    RD -.-> A[(Audit log<br/>append-only JSONL)]
    T -.-> G5

Chain order is cheap-before-expensive, and confirmation is deliberately last so it binds to validated arguments — otherwise {"qty": "5"} and {"qty": 5} would hash differently and describe the same call.

Every outcome, allowed or denied, is written to the audit log with arguments redacted.


Demo transcript

Produced by npm run demo on Node 24.18.0. Reproduced verbatim; only long JSON bodies are elided where the demo itself truncates them.

--- 1. What the model actually sees: tools/list ---
Tools exposed over MCP: 4
  - search_tools: Find domain tools by keyword. Returns name, domain, one-line s...
  - describe_tool: Return the full description and JSON Schema for one tool disco...
  - confirm_action: Issue a single-use, time-limited confirmation token for a muta...
  - invoke_tool: Run a domain tool by name. Arguments are validated against the...

Domain tools hidden behind them: 43. The model never receives these 43 schemas up front.

--- 2. Tool-search discovery (instead of prompt-dumping) ---
OK      search_tools({query:"money back to a buyer"})
        {
          "query": "money back to a buyer",
          "total_registered": 43,
          "returned": 1,
          "results": [
            {
              "name": "orders.refund_order",
              "domain": "orders",
              "summary": "Refund an order. Mutating; requires confirmation.",
              "mutating": true,
              "score": 2.5
            }
          ]
        }

--- 3. A normal read call (allowed) ---
OK      invoke_tool(invoices.total_outstanding)
        {
          "tool": "invoices.total_outstanding",
          "result": { "outstanding": 1500750 },
          "usage": { "requests": 1, "tokens": 9, "maxRequests": 30, "maxTokens": 20000 }
        }

--- 4. Argument validation rejects a malformed call ---
DENIED  invoke_tool(orders.search_by_status, status:"exploded")
        {
          "error": "INVALID_ARGUMENTS",
          "message": "status: Invalid enum value. Expected 'pending' | 'paid' | 'shipped' | 'cancelled' | 'refunded', received 'exploded'; limit: Number must be less than or equal to 100",
          ...
        }

--- 5. Out-of-scope tool is denied (tenant-style isolation) ---
DENIED  invoke_tool(admin.purge_tenant_data)
        {
          "error": "OUT_OF_SCOPE",
          "message": "Tool 'admin.purge_tenant_data' is not in this session's allow-list.",
          "details": { "tool": "admin.purge_tenant_data", "tenantId": "acme" }
        }

--- 6. Write blocked pending confirmation, then allowed after confirm ---
DENIED  invoke_tool(orders.refund_order) — no token
        {
          "error": "CONFIRMATION_REQUIRED",
          "message": "Tool 'orders.refund_order' is mutating and requires confirmation. Call 'confirm_action' with the same tool and arguments to obtain a confirmation_token, then retry.",
          "details": { "tool": "orders.refund_order", "mutating": true }
        }
OK      confirm_action(orders.refund_order)
        {
          "confirmation_token": "14060221-8c4e-4e1d-a784-b068c624c5a7",
          "expires_at": "2026-08-16T06:29:07.880Z",
          "single_use": true,
          "preview": "orders.refund_order({\"orderId\":\"ORD-1002\",\"amount\":25000}) — mutating action, expires in 120s, single use."
        }
OK      invoke_tool(orders.refund_order) — with token
        {
          "tool": "orders.refund_order",
          "result": { "orderId": "ORD-1002", "status": "refunded", "refunded": 25000 },
          "usage": { "requests": 4, "tokens": 43, "maxRequests": 30, "maxTokens": 20000 }
        }

--- 7. Replaying the same confirmation token is rejected (single-use) ---
DENIED  invoke_tool(orders.refund_order) — token replayed
        {
          "error": "CONFIRMATION_REPLAYED",
          "message": "Confirmation token has already been used. Tokens are single-use.",
          "details": { "tool": "orders.refund_order", "rejection": "replayed" }
        }

--- 8. PII redaction on tool output ---
OK      invoke_tool(customers.get_by_id)
        {
          "tool": "customers.get_by_id",
          "result": {
            "id": "CUST-0002",
            "tenantId": "acme",
            "name": "Eli Santoso",
            "email": "[REDACTED:EMAIL]",
            "phone": "[REDACTED:PHONE]",
            "tier": "free",
            "country": "MY",
            "createdAt": "2025-09-18"
          },
          ...
        }

--- 9. Rate limit fires on a burst against one tool ---
Rate limit triggered on call #6: {"error":"RATE_LIMITED","message":"Rate limit hit for 'orders.count_by_status'. Retry in 9995ms.","details":{"tool":"orders.count_by_status","retryAfterMs":9995}}

--- 10. Request budget hard cut-off ---
Budget cut-off after 19 further calls -> {"error":"BUDGET_EXCEEDED","message":"Session request budget exhausted (30/30 requests used).","details":{"requestsUsed":30,"maxRequests":30}}

--- 11. Audit log (append-only JSONL) ---
32 entries written to audit/demo-audit.jsonl. First 6:

  allowed invoices.total_outstanding   ok
  denied  orders.search_by_status      INVALID_ARGUMENTS
  denied  admin.purge_tenant_data      OUT_OF_SCOPE
  denied  orders.refund_order          CONFIRMATION_REQUIRED
  allowed orders.refund_order          ok
  denied  orders.refund_order          CONFIRMATION_REPLAYED

  allowed: 13   denied: 19

An audit line in full:

{
  "ts": "2026-08-16T06:27:07.874Z",
  "sessionId": "635792ad-a119-4067-8dfb-100787b390c8",
  "tenantId": "acme",
  "tool": "orders.search_by_status",
  "args": { "status": "exploded", "limit": 999 },
  "decision": "denied",
  "durationMs": 1,
  "guard": "validation",
  "code": "INVALID_ARGUMENTS",
  "reason": "status: Invalid enum value. Expected 'pending' | 'paid' | 'shipped' | 'cancelled' | 'refunded', received 'exploded'; limit: Number must be less than or equal to 100"
}

Register with Claude Desktop / Claude Code

After npm run build, add to your MCP client config (claude_desktop_config.json, or .mcp.json for Claude Code):

{
  "mcpServers": {
    "guarded-tools": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-guarded-tools/dist/server/stdio.js"],
      "env": {
        "MCP_TENANT_ID": "acme",
        "MCP_AUDIT_LOG": "/absolute/path/to/audit/session.jsonl",
        "MCP_MAX_REQUESTS": "50",
        "MCP_RATE_MAX": "5"
      }
    }
  }
}

All env keys are optional. Defaults: tenant acme, 50 requests, 20,000 tokens, 5 calls per 10s window, in-memory audit log.


Design decisions

Why tool-search instead of dumping every schema

Registering 43 tools means every request carries 43 schemas. The measurement above puts that at 2,913 tokens versus 486 — but the stronger argument is selection quality: a model choosing among 4 well-described meta-tools makes a different kind of mistake than one choosing among 43 similarly-named ones. search_tools also gives a natural place to filter by scope, so a session never sees tools it could not call anyway.

The trade-off is latency and the run-time token cost quantified above: two extra round trips before the first real call. The break-even (~10 discovery cycles) is stated rather than hidden.

Search here is BM25-flavoured lexical scoring, not embeddings — deliberately, so the demo runs offline with no model, no index build, and deterministic ranking that tests can assert on. Query-side stopwords are dropped; without that, "money back to a buyer" ranked an unrelated tool first because a and to matched its prose. A production deployment would swap the scorer for a vector or hybrid index behind the same interface; the meta-tool surface would not change.

Why confirmation tokens are single-use, expiring, and argument-bound

A mutating tool call is denied unless it carries a token from confirm_action. That token is:

  • Single-use — consumed on first successful check. An agent stuck in a retry loop cannot turn one human approval into N writes. The demo shows the replay rejected with CONFIRMATION_REPLAYED.
  • Time-limited (default 120s) — an approval granted in a different context an hour ago should not authorise a write now.
  • Bound to a SHA-256 hash of the validated arguments — so approval of "refund 25,000" cannot be redirected to "refund 25,000,000". Argument keys are sorted before hashing, so key order does not matter.

What this does not give you: it is an interlock, not an authorisation system. It assumes the confirming caller is the human (or a trusted supervisor). If the same agent both requests and confirms with no human in the loop, this reduces to a two-step ritual and buys you a preview line in the audit log, nothing more. There is no identity, signature, or approver record on the token.

Why the audit log is append-only JSONL

AuditLog exposes append, all, and bySession — there is no update, delete, or clear method, so no later code path can rewrite history (a test asserts this). Records are one JSON object per line so the file stays greppable, streamable, and survives a partial write: a corrupted tail loses the final record rather than invalidating the whole document the way a truncated JSON array would. Writes are synchronous — an audit record lost on crash is worse than a few microseconds of latency.

This is tamper-evident only in the weak sense that the process never rewrites lines itself. It is not tamper-proof: anyone with write access to the file can edit it. Real immutability needs append-only media (WORM storage, an external log service, or hash-chaining) — none of which is implemented here.

What regex PII redaction does and does not give you

It gives you: removal of well-formed emails, international/grouped phone numbers, 4-group card-shaped numbers, and 16-digit national-ID-shaped numbers, on both inputs and outputs, before values reach the handler, the client, or the audit log. Patterns are configurable and applied narrowest-first (card before phone) because ordering changes the labels.

It does not give you a compliance control. Verified gaps, each reproducible against the shipped patterns:

Input Result
amex 3782 822463 10005 not redacted — 4-6-5 grouping isn't matched
bob [at] acme [dot] io not redacted — obfuscated form
ZW1haWxAdGVzdC5jb20= not redacted — base64-encoded email
name: Eli Santoso, Jl. Sudirman 45 Jakarta not redacted — names/addresses have no regex shape
card 4111111111111111 redacted, but mislabelled NATIONAL_ID (both are 16 digits — genuinely ambiguous without a Luhn check)

Regex redaction reduces casual leakage of structured identifiers. It does not detect PII described in prose, split across fields, encoded, or misspelled, and it will mislabel ambiguous digit runs. Treat it as a blast-radius reducer, not a boundary you can rely on.

What the guardrails do and do not cover

Stated precisely, because "secure" would be the wrong word for all of it:

  • Scope bounds which tools a session can reach (default-deny allow-list, plus tenant filtering in the data layer as defence in depth). It does not authenticate the caller — there is no identity layer here.
  • Budgets bound how much a session can consume. Denied calls are charged too, so a retry loop on bad arguments still terminates. It does not distinguish a useful call from a wasteful one.
  • Rate limits bound how fast, per session and tool. A slow loop stays under them indefinitely — that is what budgets are for.
  • Validation rejects malformed arguments with field-level messages. It cannot tell a well-formed malicious call from a well-formed legitimate one.
  • Confirmation puts a human decision point in front of writes, with the caveats above.
  • Audit makes calls reviewable after the fact. It prevents nothing on its own.

None of these stop a determined attacker who controls the client, and none address prompt injection — a model convinced to call refund_order with plausible arguments will be allowed to, once confirmed. What they do is make the damage from a confused or looping agent bounded and reviewable rather than open-ended.


The demo domain

A fictional multi-tenant B2B commerce back-office, generated from a fixed seed (Mulberry32 PRNG) so every run is reproducible. No external services.

Domain Tools Notes
orders 11 2 mutating (cancel_order, refund_order)
inventory 11 2 mutating (adjust_stock, set_price)
invoices 8 2 mutating (void_invoice, mark_paid)
customers 6 contact fields exercise PII redaction
analytics 5 read-only aggregates
admin 2 mutating; never granted to demo sessions — demonstrates scope denial
Total 43 35 read, 8 mutating

Every read tool filters by tenant in the handler as well as at the scope guard, so a scope misconfiguration still cannot return another tenant's rows.


Tests

62 tests, offline, no credentials:

✓ tests/redaction.test.ts  (12)  patterns, ordering, nesting, no false positives on IDs/dates/SKUs
✓ tests/registry.test.ts   (13)  registration, ranking, filters, deterministic dataset
✓ tests/guardrails.test.ts (19)  scope, budget, rate limit, validation, confirmation, handler errors
✓ tests/audit.test.ts       (7)  append-only surface, JSONL on disk, PII never logged raw
✓ tests/e2e.test.ts        (11)  real client ↔ server over stdio, spawned as a child process

The e2e suite spawns the actual built server and drives it over the real MCP stdio transport — it is not a mock. Confirmation coverage includes expiry, replay, argument-mismatch, tool-mismatch, and unknown tokens.


Project layout

src/
  core/       types, config (Zod), session state, budgets, tokens, audit, executor
  guardrails/ scope, budget, rate-limit, validation, confirmation, redaction
  registry/   tool registry + keyword search
  domain/     seeded dataset + 43 demo tools
  server/     McpServer wiring, stdio entrypoint
  demo/       e2e transcript, token report
tests/        vitest suites

Guardrails implement a single Guardrail interface and are composed as an ordered array in GuardedExecutor, so each is independently testable and the chain is reorderable in one place.


Limitations and what is not built

  • stdio transport only. The SDK also ships StreamableHTTPServerTransport; it is not wired up here, and no HTTP/SSE code path is tested.
  • Single session per server process. buildServer() creates one session; multi-session/multi-tenant routing over one transport is not implemented.
  • In-memory state. Sessions, budgets, and confirmation tokens die with the process. The audit log is the only durable artifact.
  • No authentication or identity. Scope is configuration, not authorisation.
  • Search is lexical, not semantic. Stated above as a deliberate offline trade-off.
  • PII redaction is regex-based, with the verified gaps tabulated above.
  • The dataset is synthetic; all names, emails, and phone numbers are generated.

License

MIT © Muhammad Ridwan

推荐服务器

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 模型以安全和受控的方式获取实时的网络信息。

官方
精选