NitroWatch

NitroWatch

A governance layer for MCP servers that classifies tools by risk, enforces agent permissions, lets safe tools earn autonomy through a track record, and keeps an audit trail. Requires human approval for irreversible actions and provides blast radius context.

Category
访问服务器

README

NitroWatch

Your AI agent just got a permission system.

Connect an AI agent to an MCP server today and it is all-or-nothing: either the agent can call every tool, or a human hand-approves every single call, forever. There is no policy layer, no notion of which tools are dangerous, no audit trail, and no way for trust to grow over time.

NitroWatch is the missing governance layer. It sits in front of any MCP server, classifies every tool by risk, enforces what the agent may do, lets safe tools earn autonomy through a proven track record, and records every decision permanently.

Built with the NitroStack TypeScript SDK for the NitroStack × SRM Hackathon 2026.

🛡️ Read the full walkthrough →

The problem MCP has today, the three risk tiers, how autonomy is earned, and the six bugs we found while building this (two of them in NitroStack's own framework, reported upstream).


The problem

Imagine a company's MCP server exposing three tools:

Tool Consequence
get_invoice Harmless. Reads data.
send_invoice Costs money, but can be voided.
delete_account Irreversible. Gone is gone.

MCP treats all three identically. There is no way to express "let the agent read freely, ask me before it spends money, and never let it delete anything."

Every team adopting MCP hits this wall immediately, and both usual answers are bad:

  • Approve everything manually → the human becomes the bottleneck and starts rubber-stamping.
  • Trust the agent fully → one hallucinated tool call destroys production data.

NitroWatch is the middle path nobody has built.

How it works

       AI AGENT
          │  request_action(serverId, toolName, args)
          ▼
  ┌───────────────────────────────────────────────┐
  │                 NITROWATCH                    │
  │                                               │
  │   1. classify   every tool → a risk tier      │
  │   2. enforce    tier decides what happens     │
  │   3. earn       clean record → autonomy       │
  │   4. audit      every decision recorded       │
  └───────────────────────────────────────────────┘
        │              │                  │
    read│    reversible│      irreversible│
        ▼              ▼                  ▼
    RUNS NOW    approval, until        ALWAYS
                  promoted            APPROVAL
                                  (never promotes)
          │
          ▼
   DOWNSTREAM MCP SERVER

The three tiers

Tier Meaning Behaviour
read No side effects Runs immediately, always
reversible Real effects, can be undone Gated — can earn autonomy
irreversible Cannot be undone Gated permanently

The hard rule: an irreversible tool can never be promoted to run unattended, regardless of how good its track record is. Trust is earned per tool, but the ceiling is set by consequence, not history. That single constraint is what makes NitroWatch safe to put in front of a production system.

Earned autonomy

A reversible tool that accumulates 3 clean approvals with zero denials becomes eligible for promotion. NitroWatch then offers — it never promotes itself. A human confirms with promote_tool, and can withdraw it instantly with revoke_tool.

A single denial permanently disqualifies a tool from autonomy. If a human ever said "no", the pattern isn't clean enough to stop asking.

Blast radius

Before approving, the human sees what the action would actually touch:

scoped to accountId="acc_991"
UNBOUNDED — "force" is set, so this may affect every record this tool can reach

"Approve this?" and "approve this, knowing it hits every record" are very different questions.


⚠️ The bug we found in our own classifier

We pointed the classifier at a domain it had never been tuned for, and it immediately made a dangerous mistake. This is the most important thing we learned building NitroWatch, so it gets its own section rather than a footnote.

The classifier was written and tested against a billing vocabulary. To check whether it generalised, we ran it over an infrastructure server — cluster operations, databases, backups. One result stopped us:

❌  read    scale_deployment    matched read-only verb "count"

scale_deployment was classified read.

In NitroWatch, read means autonomous from birth — it runs immediately, forever, with no approval. A tool that resizes production deployments would have run completely unsupervised.

Why it happened

The classifier read the tool name and its description as one bag of words. The description said:

"Change the replica count of a deployment."

count is in the read-verb list. One noun in a sentence of prose was enough to grant a mutating tool permanent unattended execution.

The same flaw hit rotate_credentials, which matched set in "invalidate the previous set" — again a noun, not a verb.

The two rules that came out of it

1. The tool name is authoritative. A name is written to state what a tool does. A description is prose. They are no longer read as one bag of words — the name decides the tier, and the description is consulted separately.

2. The description may only escalate risk — never reduce it. Prose can legitimately reveal that a tool is more dangerous than its name suggests — a cleanup tool that "permanently deletes archived records" really is irreversible, and we want that caught. But a description can never argue a tool down to read.

And an unrecognised name with a read-looking description now defaults to reversible, not read. No evidence from the name is not enough to grant unattended execution.

🔐 Rule 2 is a security property, not just a bug fix

If a description could lower a tool's tier, then the description becomes an attack surface. Anyone who controls the text of a tool — the author of a third-party MCP server you connected — could write prose designed to talk NitroWatch into treating delete_everything as read-only.

Under the current rules that is impossible. The worst a hostile description can do is make a tool look more dangerous than it is, which fails safe. The same principle governs the LLM classifier: on disagreement, the more dangerous verdict always wins.

What this cost, and what it bought

One test run against an unfamiliar vocabulary. It is now locked in by a regression test asserting scale_deployment can never be classified read, plus a generalisation test over the whole infrastructure vocabulary.

We would rather ship a governance tool that has been caught failing and fixed than one that has never been tested outside the domain it was written for.


Architecture

src/
├── index.ts                            McpApplicationFactory bootstrap
├── app.module.ts                       @McpApp root module
├── health/system.health.ts             health checks
└── modules/nitrowatch/
    ├── nitrowatch.module.ts            registers all controllers
    ├── nitrowatch.store.ts             state: servers, policies, trust, audit
    ├── nitrowatch.policy.ts            risk classifier + blast radius
    ├── nitrowatch.tools.ts             register / discover / burn rate / glue
    ├── nitrowatch.governance.tools.ts  classify / request / approve / promote
    ├── nitrowatch.resources.ts         policies, pending, audit, logs
    └── nitrowatch.prompts.ts           approval briefing, posture review

Design notes

  • nitrowatch.store.ts is the only stateful module. Everything else reads through its helpers, so swapping in a database touches exactly one file.
  • All execution funnels through one function (executeOnServer), so nothing can run without passing an audit point.
  • The classifier is deterministic and biased toward caution. Anything it cannot confidently read as read-only is treated as at least reversible — an over-cautious classifier costs a click, an under-cautious one costs a database.
  • NitroWatch is an MCP server that is also an MCP client. It speaks the protocol in both directions, which is what lets it govern arbitrary servers.

Tools

Tool Purpose
register_server Register an MCP server to be governed
discover_capabilities Connect to it and enumerate tools/resources/prompts
classify_tools Assign every tool a risk tier
request_action Agent asks to call a tool — allowed, or queued for approval
approve_action Human approves → executes, trust +1
deny_action Human denies → blocked, autonomy disqualified
promote_tool Grant standing autonomy after a clean record
revoke_tool Withdraw autonomy immediately
get_trust_status Track record and autonomy state per tool
get_audit_log Full decision history
get_burn_rate Token budget projection
generate_glue Stub connector between two registered servers

Resources

URI Contents
nitrowatch://servers All registered servers
nitrowatch://servers/{serverId}/logs Per-server log entries
nitrowatch://policies Risk tier + autonomy for every governed tool
nitrowatch://pending Actions awaiting a human decision
nitrowatch://audit Complete audit trail

Prompts

Prompt Purpose
approval_briefing Plain-language brief for a pending action
security_posture_review What runs unattended, what is gated, what looks risky

Try it — the companion demo server

nitrowatch-billing-demo is an intentionally ungoverned MCP server built to be governed by this one. It exposes five tools that land on all three risk tiers — including a delete_account that will permanently destroy an account and its invoices for anyone who asks, with no confirmation.

git clone https://github.com/Mohith535/nitrowatch-billing-demo.git
cd nitrowatch-billing-demo && npm install && npm run build
npx nitrostack-cli start --port 3100

Then, from NitroWatch:

register_server({ name: "Billing API", endpoint: "http://localhost:3100/sse" })
discover_capabilities({ serverId: "billing-api" })
classify_tools({ serverId: "billing-api" })
request_action({ serverId: "billing-api", toolName: "delete_account",
                 args: { accountId: "acc_991" } })   // → blocked

⚠️ Use --port — the PORT environment variable is silently ignored and the server would otherwise bind 3000, colliding with NitroWatch.

If you run NitroWatch from NitroCloud rather than locally, it cannot reach localhost on your machine. Run both locally, or deploy the billing server too and register its public URL.

Installation

Requirements: Node.js 20.x (18+ minimum), npm 9+

git clone https://github.com/Mohith535/nitrowatch.git
cd nitrowatch
npm install
npm run dev

Then open the project in NitroStudio → Studio App Canvas → Tools.

Production

npm run build      # → dist/
npm run start:prod

Environment setup

Copy .env.example to .env. No secrets are required to run locally.

Variable Default Purpose
NITRO_LOG_LEVEL info Log verbosity
NITROSTACK_APP_MODE universal NitroStack app mode
MCP_TRANSPORT_TYPE stdio dev / dual prod stdio · http · dual
PORT 3000 HTTP port when transport is http/dual
LLM_API_KEY unset Enables LLM classification. Unset is a valid state — the deterministic classifier runs instead.
LLM_BASE_URL https://api.openai.com/v1 Any OpenAI-compatible endpoint (Groq, OpenRouter, Cerebras, local)
LLM_MODEL gpt-4o-mini Model name for the above

API keys for governed servers are supplied per-server via register_server and are never committed.

Testing

npm test

18 tests over the security-critical logic — tier assignment, verb precedence, tokenization, the fail-safe default, blast-radius detection, description-escalation rules, cross-domain generalisation, and the promotion rules.

They earn their keep. They have caught three real bugs so far — the third is significant enough to have its own section above:

  1. Conjugated verbs didn't match. A description reading "permanently deletes archived records" classified as reversible, because the verb list held delete and the text said deletes. Fixed with suffix stripping — deliberately not prefix matching, which would make settings match the verb set and misclassify get_settings.
  2. camelCase argument keys were invisible to blast-radius detection. { accountId: "acc_991" } reported "scope inferred from: accountId" instead of the actual value, because the regex anchored on _ or start-of-string. Keys are now tokenized the same way tool names are.
  3. ⚠️ A mutating tool was classified read-only because a noun in its description matched a read verb — scale_deployment would have run unattended. See the section above. This is the one that mattered.

All three were in code that looked obviously correct.

Classification: two layers

classify_tools tries the LLM classifier first and falls back to the deterministic one. Three rules govern the interaction:

  1. Any failure falls back — no key, quota exhausted, timeout, malformed reply. Governance must never fail open.
  2. On disagreement, the more dangerous tier wins. Disagreement is a signal to be careful, not a coin flip.
  3. Rule 2 is also a prompt-injection defence. A hostile tool description cannot talk the system down from a tier the verb heuristic already flagged — the worst it can do is talk it up.

This mirrors the deterministic classifier's own rule that a description may only escalate risk — see the bug we found. Both layers fail in the same safe direction, deliberately.

The LLM call is capped at 8 seconds so a slow classifier can't stall the governance path.

Known limitations

Stated plainly, because a governance tool that hides its gaps is worth less than one that names them.

  • Governance decisions are unauthenticated. Anyone who can reach the server can call approve_action or promote_tool. Production would gate these behind operator identity — NitroStack's @UseGuards is the natural mechanism. This is the most significant gap.
  • State is in-memory. A serverless cold start clears policies, approvals, and the audit trail. nitrowatch.store.ts is the only file that would change.
  • estimateBlastRadius reads arguments, not the target system. It cannot know that { status: "inactive" } matches 40,000 rows. It flags shape, not true magnitude.
  • get_burn_rate takes usage as an input rather than measuring it, so it projects rather than tracks.
  • generate_glue emits a stub and does not solve schema mapping between the two tools.

Usage

A full governance cycle:

// 1. Put a server under governance
register_server({ name: "Billing API", endpoint: "https://billing.example.com/sse" })
discover_capabilities({ serverId: "billing-api" })
classify_tools({ serverId: "billing-api" })
//   → get_invoice     read          runs freely
//   → send_invoice    reversible    gated, can earn autonomy
//   → delete_account  irreversible  gated forever

// 2. A read passes straight through
request_action({ serverId: "billing-api", toolName: "get_invoice", args: { id: "inv_1" } })
//   → { decision: "allowed", tier: "read" }

// 3. Something dangerous is stopped
request_action({ serverId: "billing-api", toolName: "delete_account", args: { accountId: "acc_991" } })
//   → { decision: "blocked", approvalId: "act_1", tier: "irreversible",
//       blastRadius: 'scoped to accountId="acc_991"' }

// 4. Human decides, with context
approval_briefing({ approvalId: "act_1" })
deny_action({ approvalId: "act_1", reason: "not authorised for bulk deletion" })

// 5. A reversible tool earns its way up
request_action(...) → approve_action(...)   // ×3
//   → promotionOffer: { eligible: true, ... }
promote_tool({ serverId: "billing-api", toolName: "send_invoice" })
//   → now runs unattended

// 6. But the irreversible one never can
promote_tool({ serverId: "billing-api", toolName: "delete_account" })
//   → Error: Refused — irreversible tools can never be promoted.

Roadmap

  • LLM classification — classifyWithLlm() in nitrowatch.policy.ts is the seam. Contract: LLM first, deterministic fallback on any failure, and on disagreement take the more dangerous tier.
  • Approval console widget — a React widget over nitrowatch://pending.
  • Durable state — swap the in-memory Maps in nitrowatch.store.ts for a database so policies survive a serverless cold start.

License

Apache-2.0

推荐服务器

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

官方
精选