Sentinel — Eval-as-MCP-Server
An MCP server that scores AI agent responses before they reach a user, not after. Deterministic checks resolve the obvious cases for free; only genuinely ambiguous responses escalate to a judge model.
The problem & requirements
- Score an agent's response against configurable checks — groundedness, prompt injection — callable as an MCP tool from any agent runtime
- Deterministic pattern matching resolves clear-cut cases with no model call; ambiguous cases escalate to a judge, bounded by a per-metric latency budget
- Every tenant explicitly configures fail-open vs. fail-closed behavior on judge timeout — no system-wide default
- The same business logic runs behind two transports: stdio for local agents, stateless HTTP for remote deploy
- Zero-config local dev: full test suite and both tools run offline against stub judges, no API key required
- Fixed cost at idle: every layer of the stack scales to zero — Vercel functions, planned Neon/R2 persistence
- Reproducibility: every verdict pins its metric version and judge model, re-derivable later
- No unauthenticated tool calls: tenant ID comes only from a validated bearer token, never a request argument
Scale & constraints
A bootstrap build: single developer, low fixed cost, nothing requiring a company cloud account. The numbers below are the budget the architecture had to hold under from day one, not traffic figures yet.
API design
Data model
| Entity | Key fields |
|---|---|
| ScoringRequest | response, context[] (source, content, id), taskDescription?, correlationId?, policyOverride? |
| Verdict | metric, outcome (pass/warn/block/timeout), score, threshold, reason, citedContextIds[], decidedBy (heuristic/judge/cache), judge?, metricVersion, latencyMs |
| EvaluationResult | requestId, verdicts[], action, trustScore, totalLatencyMs |
| MetricPolicy | metric, enabled, threshold, onTimeout (fail_open/fail_closed), maxJudgeLatencyMs |
| TenantPolicy | tenantId, metrics[], aggregation (strictest/weighted), archivePayloads (opt-in, default false) |
decidedBy on every Verdict is what makes the cost story auditable — it's the field that proves a judge model wasn't called when it didn't need to be, not just a claim in a case study.
Architecture
Both tools converge on the same escalation ladder: deterministic checks first, a judge model only for what they can't resolve.
Fig. 2a — Most calls resolve at the deterministic tier. The judge is an escalation path, not the default route.
Two transports, one shared handler core. Neither entrypoint imports anything the other depends on.
Fig. 2b — Adding the HTTP deploy target was one new route file plus a thin auth module, not a parallel implementation.
Key decisions & trade-offs
registerTool signatures and different zod version requirements. Keeping handlers as plain functions with zero transport imports meant the HTTP deploy target was additive, not a rewrite.judges/ and persistence/.fetch option directly at the exported route handler proved the auth and scoring path end to end without depending on a live socket.Lessons learned
- Writing the demo's exact fabricated-clause scenario as a named regression test, before it ever caught anything for real, is what proved the negation-window fix worked — not the fact that it compiled.
- Re-validating input independently in the handler layer caught nothing in testing, but it's the reason a second transport couldn't silently skip a check the first one enforced.
- Keeping handlers completely transport-agnostic meant the Vercel deploy target was one new file plus a thin auth module, not a parallel implementation.
- I upgraded zod from v3 to v4 reactively, once the HTTP package's type requirements forced it, rather than checking both packages' peer dependencies before writing schema code.
- A background dev server can't survive between tool calls in every environment. I'd default to testing route handlers as plain functions in-process from the start, not assume a live server would be easy to stand up.
- The trust-score rollup shipped as an honest placeholder, but I didn't flag it as loudly in the code as the fail-open/fail-closed choice — a formula that looks precise gets trusted more than it should.