DEV Community

Lovanaut
Lovanaut

Posted on

118 MCP Tools, 4 Safety Levels: Building a Server-Enforced Form Ops Layer

When people hear that a product works from Claude, ChatGPT, Cursor, and other AI clients, the default assumption is: "So you added chat to your dashboard."

That is not the architecture. FORMLOVA is a chat-first form service where MCP is the primary operational interface -- 118 tools across 24 categories, covering everything from form creation to response analytics to email campaigns. The dashboard exists for dense visual inspection. Chat leads for intent-to-action sequences.

The interesting engineering problem is not "how do you expose tools over MCP." It is: how do you make conversation safe enough to carry real operations -- publishing live forms, sending bulk emails, deleting data -- when the LLM between you and the server will routinely ignore your instructions?

The Problem With Prompts: LLMs Skip Steps

Here is something I learned building this system: LLMs ignore or skip prompt instructions. You can write "ALWAYS confirm before sending email" in your system prompt. Models will skip the confirmation and call the tool directly. Not sometimes -- regularly.

This is not a theoretical concern. It happened in testing across multiple models and clients. The model reads the confirmation instruction, decides it has enough context to proceed, and fires the tool with user_confirmed=true on the first call.

This is why server-side enforcement exists. It is not a UX choice. It is a safety requirement born from observed model behavior. If the server does not enforce confirmation, confirmation does not happen.

Classifying 118 Tools by Blast Radius

Every MCP tool in the system is classified into one of four levels based on what it can break:

// Every tool maps to exactly one operation class and safety level
type OperationClass = "inspect" | "prepare" | "mutate" | "external-write";

// L0: inspect   — read-only (analytics, export, list)        ~50 tools
// L1: prepare   — reversible changes (design, field edit)    ~40 tools
// L2: mutate    — affects respondents (publish, unpublish)    ~4 tools
// L3: external-write — irreversible external side effects     11 tools
Enter fullscreen mode Exit fullscreen mode

L0 (inspect): Analytics queries, CSV exports, form listings. No confirmation needed. These tools cannot change state.

L1 (prepare): Design changes, field edits, settings updates. No confirmation needed either -- but every change is version-controlled, so any L1 operation can be rolled back.

L2 (mutate): Publishing a form, scheduling publication. These affect respondents. The publish_form tool implements a server-side review state machine that returns next_required_action, missing_requirements, and preview URLs on every call. The state machine advances only when real preconditions are met.

L3 (external-write): Sending emails, deleting data, removing team members. These 11 tools have irreversible external side effects. Every one requires a confirmation_token before execution.

The key design decision: the server decides what needs confirmation, not the LLM. L0 and L1 tools execute immediately. L2 and L3 tools enforce confirmation server-side. The INSTRUCTIONS tell the model to follow what the server returns -- nothing more.

The confirmation_token: HMAC-SHA256 Hard Block

For L2 and L3 operations, the server issues a cryptographic confirmation token. This is the mechanism that prevents models from bypassing safety checks:

interface ConfirmationPayload {
  tool_name: string;       // bound to the specific tool being confirmed
  user_id: string;         // bound to the authenticated user
  resource_key: string;    // bound to the target resource (form ID, email batch, etc.)
  issued_at: number;       // timestamp for TTL enforcement
  scope_summary: string;   // human-readable description of what will happen
}

// Token = HMAC-SHA256(secret, JSON.stringify(payload))
// TTL: 5 minutes
// One token, one operation, one user, one resource
Enter fullscreen mode Exit fullscreen mode

The flow works like this:

  1. Model calls a tool with user_confirmed=false (or omits the parameter)
  2. Server returns a confirmation prompt with scope details and a confirmation_token
  3. The user reviews the scope summary in their chat client
  4. Model calls the same tool with user_confirmed=true and the token
  5. Server validates: HMAC signature, TTL (5 minutes), tool name match, user ID match, resource key match
  6. If valid, the operation executes. If expired or mismatched, the server returns a fresh review summary and a new token -- no hard error, no broken flow

This design means a model cannot shortcut the confirmation. Even if it calls the tool with user_confirmed=true on the first attempt, the server rejects it because there is no valid token. The token only exists after the server has shown the user what is about to happen.

Why Preview Confirmation Uses URL Open-Tracking

The publish_form state machine has a specific requirement: the user must actually look at the form preview before publication. Saying "I confirmed the preview" in chat is not sufficient. The model can generate that text without the user having seen anything.

The solution: preview confirmation only advances when the preview URL is actually opened in a browser. This is a physical trigger that the LLM cannot shortcut. The server tracks whether the preview URL was visited, and publish_form checks that state on every call. If both the form preview and thank-you page preview have been opened, the user can confirm both in a single message. If not, the state machine stays put.

// publish_form returns this on every call
interface PublishReviewState {
  review_state: "pending_preview" | "pending_requirements" | "ready";
  next_required_action: string | null;
  missing_requirements: string[];
  form_preview_url: string;
  thankyou_preview_url: string;
  form_preview_opened: boolean;
  thankyou_preview_opened: boolean;
  confirmation_token?: string;  // only present when ready
}
Enter fullscreen mode Exit fullscreen mode

withFormResolver: Eliminating Chat Friction

One early problem: every tool call required a form_id, which meant users had to constantly specify which form they meant. This created unnecessary roundtrips.

The withFormResolver middleware auto-resolves form_id when it is omitted from the tool call:

  • 1 form in account: auto-select it
  • Multiple forms: prefer the most recently used form in the session (24h window)
  • Ambiguous: return a form list and let the user pick

This middleware wraps all 65 form-scoped tools. The Zod schemas keep form_id required at the type level but the middleware makes it optional at runtime, so type safety is preserved while the chat experience stays clean.

Why the Dashboard Survived

I built the dashboard form builder, then deliberately removed it. Forms are entry points, not the product. The real value is in post-publish operations: response routing, analytics, email sequences, A/B testing.

But I also tested a dashboard-less mode, and it failed. When you manage dozens of forms, checking status across all of them through chat requires too many roundtrips. Chat is sequential; a dashboard is parallel.

The architecture landed here: chat leads for intent-to-action sequences, the dashboard supports for dense visual inspection. This is not a compromise. It is what the two interfaces are respectively good at.

The turning point in my conviction that MCP could serve as a primary business interface came when freee and MoneyForward announced MCP-based accounting workflows. That was confirmation that MCP works for business operations beyond developer tooling.

Responses to Route to Notify: Implementation Internals

The clearest test of whether conversation is truly operational is a multi-step post-publish flow. Take a live inquiry form where respondents indicate their intent (demo request, comparing options, just browsing).

From a conversational thread:

  1. get_response_analytics returns intent distribution (L0, no confirmation)
  2. filter_responses isolates high-intent respondents (L0, carries filtered set forward)
  3. send_filtered_email drafts a sales notification for those leads (L3, requires confirmation_token)

Step 3 is where the safety design earns its keep. The server returns the confirmation prompt with:

  • Exact recipient count from the filtered set
  • Email subject and body preview
  • Sender identity
  • A scoped confirmation_token bound to this specific email batch

The filtered set from step 2 persists across turns through the tool's resource references. The model does not need to re-query or re-filter. This carry-forward context is what makes the sequence feel like an operational workflow rather than a series of disconnected tool calls.

The Standard I Care About

The question is simple: can the product carry multi-step operational work through a conversational thread without losing context, scope, or trust?

Trust is the hardest part. It requires that the system does not rely on LLM compliance for safety. The confirmation_token, the operation classification, the preview open-tracking -- these are all server-side mechanisms precisely because the model-side equivalent (prompt instructions) is unreliable.

118 tools across 24 categories is a large surface area. Classifying every tool by blast radius and enforcing confirmation at the server level is what makes that surface area safe to expose through conversation.


FORMLOVA is free to start. One inquiry form is enough to see how the safety harness and post-publish ops work in practice. If you are building MCP-first products, I would be interested to hear how you handle the confirmation problem.


If you want the thought piece or the founder story behind these decisions:

Top comments (4)

Collapse
 
ali_muwwakkil_a776a21aa9c profile image
Ali Muwwakkil

A surprising pattern we've seen is that integrating AI tools like ChatGPT into existing workflows often reveals underlying process inefficiencies that teams weren't aware of. Simply adding an AI agent can expose gaps in communication, data flow, or even security protocols. In practice, the real challenge isn't the AI itself but aligning the organization’s operations to fully leverage these capabilities without disruption. - Ali Muwwakkil (ali-muwwakkil on LinkedIn)

Collapse
 
lovanaut55 profile image
Lovanaut

Exactly this. When we started building FORMLOVA on MCP, the first thing that surfaced wasn't an AI problem — it was how fragmented post-publish form
operations already were. Reviewing responses in one place, sending follow-ups from another, checking status in a dashboard, exporting to a spreadsheet.
AI didn't create that mess, but it made it impossible to ignore.

That's why we spent most of our engineering time on the harness — not making AI do more, but making sure it does the right thing safely when it touches
real operations.

Collapse
 
vuleolabs profile image
vuleolabs

"This is one of the most thoughtful and production-grade MCP implementations I’ve seen. Really impressive work.
What stands out most is how seriously you treat server-side enforcement instead of trusting the LLM. The confirmation_token using HMAC-SHA256 + TTL + scoped binding is exactly the kind of hard control we need when giving LLMs access to real operations. The fact that you classify all 118 tools into 4 safety levels (L0–L3) and handle L2/L3 differently shows real maturity.
Especially clever:

Using preview URL open-tracking as a physical confirmation trigger (LLM can’t fake that)
withFormResolver middleware to reduce chat friction while keeping type safety
Carrying filtered result sets across tool calls for multi-step workflows

Quick questions for you:

How has the confirmation flow felt in real usage with different models (Claude 3.5/4, GPT-4o, etc.)? Do some models still try to bypass more than others?
For L3 tools (external-write), have you considered adding a secondary approval (e.g. email/Slack confirmation) for very high-risk operations?

This level of safety engineering is rare in the MCP space right now. Respect.
Will definitely check out FORMLOVA — the post-publish routing + intent-based email sounds extremely useful."

Collapse
 
lovanaut55 profile image
Lovanaut

On model behavior differences:

Yes, models differ noticeably. In our testing, some models are more aggressive about skipping confirmation — they interpret conversational context as
implicit approval and fire user_confirmed=true on the first call. Others are more cautious but still occasionally skip steps when the user's intent
seems "obvious" to them. That inconsistency across models is exactly why we moved confirmation out of the prompt layer entirely. The server doesn't care
which model is calling — no valid token, no execution. The behavior gap between models became irrelevant once the enforcement moved server-side.

On secondary approval for high-risk L3 operations:

We considered it. The short answer is: not yet, but the architecture supports it. Right now the confirmation_token flow is the single gate, and it covers
the core risk (LLM executing without user review). For operations like bulk email to hundreds of recipients, a secondary channel confirmation (email or
Slack) would add meaningful safety. The token system is already scoped per-tool, per-user, per-resource, so layering an additional approval step on top
of specific L3 tools is straightforward to implement. It's on the roadmap as an optional layer — we want to offer it without making every L3 operation
feel heavy for solo operators who are the primary user today.

Appreciate the thoughtful engagement. If you end up testing the confirmation flow yourself, I'd be curious to hear how it feels from the user side.