All posts
MCPAI AgentsSecurityIntegration

Operating MCP servers with write access safely: architecture and practical tests

11 min readThomas Stermole
Editorial illustration in ivory and forest green: a lime data card passes through a narrow gateway in front of a database stack.

An MCP server with write access needs permissions enforced on the server, bounded operations and a defined response to ambiguous outcomes. A confirmation in chat is insufficient. After a timeout, the agent must not simply write again: the first change may already have been committed.

When extending my NocoDB MCP gateway, I implemented and tested these boundaries separately. The documented configuration on 13 October 2026 contained 17 read tools, one validation-only write preview and five mutation tools. A complete Create/Read/Update/Read/Delete test succeeded using a new synthetic record in an isolated test table. For this article, I also reran the 51 associated gateway, transport and write tests locally on 19 October: all passed, without connecting to the production database.

This is a practical account of a bounded integration path, not a general security certification. It is intended for developers and IT leads whose agents need to change records. It does not focus on a general introduction to MCP. For evaluating agent execution more broadly, see AI Agent Evaluation.

Architecture: the model proposes, the gateway constrains

The gateway sits between the MCP client and the existing NocoDB MCP endpoint. It exposes narrowly defined record operations and translates them into the upstream tools actually discovered in that installation. It accepts neither arbitrary SQL nor arbitrary tool names.

Controlled write path

  1. 01

    MCP client

    Selects an allowed operation and supplies explicit parameters.

  2. 02

    Gateway

    Checks feature flags, target schema, field IDs and any required deletion approval.

  3. 03

    NocoDB

    Enforces its own permissions and database constraints. The client then reads back the state.

Simplified logical architecture. Preview, execution and read-back are separate calls.

This separation has a clear boundary: the gateway controls only requests routed through it. Direct database access, operating system privileges and other integrations sit outside that boundary. The agent must therefore not receive an alternative route that bypasses the same restrictions.

Separate read and write permissions at two layers

Mutations are disabled by default in the gateway. Four independent configuration flags distinguish general writes, deletion, bulk creation and bulk deletion. Missing or different values enable nothing; only the exact value true enables the relevant capability. Deletion and bulk operations additionally require general write permission.

CapabilityBoundary in the tested gateway
ReadingExplicit allowlist of known read tools
Creating and updatingGeneral write permission; exactly one record per operation
Single-record deletionAdditional delete permission and explicit confirmation; administrative one-use approval by default
Bulk creationSeparate flag; at most ten records per call
Bulk deletionSeparate flag; at most ten distinct IDs and mandatory approval bound to those targets

Disabled write tools are absent from tools/list. That alone would not enforce access control: a client could invoke a known tool name directly. The execution handler therefore checks permission as well. Generic forwarding calls and native upstream bulk mutation formats remain blocked. Executable notifications and JSON-RPC batches are rejected too.

The second boundary belongs in the target system: a dedicated user or token with the narrowest possible access to the required resources. Gateway flags reduce exposed capabilities, but do not automatically reduce the upstream token's permissions. In the documented project configuration, that token still had broad resource access; full least privilege at this layer was therefore not demonstrated.

The current NocoDB MCP permissions documentation describes resource and tool permissions whose availability depends on the deployment model and licence. Check the installed version and its actual discovery output. Neither this project's tool count nor another edition's permissions interface is a universal NocoDB property.

A preview validates inputs; it does not approve execution

The separate previewRecordWrite tool can validate an operation without changing data. It also works when writes are disabled. Execution validates the target and fields again; an earlier preview does not grant an automatically effective approval.

Here is a shortened call for a synthetic example. Every ID is an intentional placeholder. This illustrates the gateway interface, not a universal NocoDB request:

{
  "name": "previewRecordWrite",
  "arguments": {
    "operation": "updateRecord",
    "workspaceId": "WORKSPACE_ID",
    "baseId": "BASE_ID",
    "tableId": "TABLE_ID",
    "recordId": 42,
    "fields": { "STATUS_FIELD_ID": "reviewed" }
  }
}

Validation checks target membership, the current schema and allowed field values. Writes address fields by field ID; title/ID collisions are rejected. Computed fields, system fields and types unsupported by the tested record path cannot be written.

The preview does not read existing records or reserve their state. It proves neither actual write permission nor all database constraints. Another process can change data between preview and execution. Preventing conflicting updates additionally requires a precondition checked atomically by the target system, such as an expected version number. The preview described here does not implement that mechanism.

A confirmation boolean does not prove human consent

A model can generate confirmDeletion=true as an argument. The flag expresses an explicit deletion call, but does not prove that a person reviewed and approved the specific targets.

For bulk deletion, the gateway therefore requires administrative approval outside the MCP access path. Approval binds the operation, workspace, base, table, exact ID set and expiry time. No MCP tool can create that approval. The operating procedure also requires human confirmation of that exact table, ID list and count.

The approval file must belong to the gateway user, reside in a protected directory and have restrictive file permissions. Symlinks, expired approvals and mismatched targets are rejected. A file lock and inode comparison protect consumption against competing gateway processes; the file is atomically renamed before the upstream call.

It remains consumed after a timeout. Restoring approval after an error could allow the same request to execute a second time even though the first deletion already happened.

For single-record deletion, an explicitly enabled operator option can replace the administrative file requirement. It never applies to bulk deletion. Repeated single-record calls must not bypass bulk approval; that requirement belongs to the operating procedure. A cumulative deletion budget across calls is not demonstrated here. More autonomous agents would benefit from an additional server-side count or time limit.

Ambiguous write responses: reconcile before deciding

The gateway does not retry mutations automatically. Once the upstream mutation call has begun, it conservatively treats a subsequent error as potentially effective. A schema check that fails before that point is distinguishable from this state.

An illustrative, shortened error response looks like this:

{
  "isError": true,
  "structuredContent": {
    "error": {
      "code": "TIMEOUT",
      "message": "NocoDB request did not complete.",
      "outcomeUnknown": true,
      "automaticRetry": false
    }
  }
}

These are project-specific result fields, not a standard MCP recovery interface. The MCP tools specification defines features including isError and structured results; the application defines how this integration handles an unknown execution outcome.

The gateway also validates apparently successful upstream responses. If a response does not confirm the expected number of IDs, it does not report completion. For updates and single-record deletion, the returned ID must match the target; bulk deletion must confirm the complete requested ID set. HTTP success without this evidence is not a reliable write receipt.

ObservationNext action
Input validation fails before the mutation callResolve parameters or permissions; do not loop over an unchanged request
Timeout or incomplete response after mutation beginsStop automatic writes; reconcile the target state
Read-back shows the expected valuesDo not issue another mutation; document the observed state
Missing record, conflicting values or incomplete read visibilityKeep the outcome unresolved and hand over; do not infer certain failure
A new deletion becomes necessary after reconciliationObtain new specific consent and, where required, a new one-use approval

For creation, a unique test marker generated before the call helps when the response containing the new ID is lost. However, an empty search result does not justify safe replay if visibility is delayed or read permissions are restricted. Automatic replay would require additional reliable idempotency mechanisms: for example, a unique operation key enforced by the target system, atomic execution and stored results. Those mechanisms were not implemented in the tested gateway.

Read-back must compare more than the record count

After creation, read the generated record and compare its unique marker. After an update, verify both the requested values and fields that must remain unchanged. After deletion, check the specific ID and marker for absence. Only then compare the overall record count.

This standalone Python example illustrates the difference between a matching total count and a matching record. It is an offline example of the verification idea, not an MCP client or an automatic retry mechanism:

def verify_update(before, after, marker, expected_status):
    if not isinstance(before, dict) or not isinstance(after, dict):
        return "unresolved"
    if any(key not in before or key not in after
           for key in ("id", "marker", "status")):
        return "unresolved"
    if before["id"] != after["id"]:
        return "unresolved"
    if before["marker"] != marker or after["marker"] != marker:
        return "unresolved"
    if after["status"] != expected_status:
        return "unresolved"
    return "expected_state_observed"


before = {"id": 42, "marker": "synthetic-example", "status": "new"}
after = {**before, "status": "reviewed"}
assert verify_update(before, after, "synthetic-example", "reviewed") == (
    "expected_state_observed"
)
assert verify_update(before, None, "synthetic-example", "reviewed") == "unresolved"
assert verify_update(before, {**after, "id": 43},
                     "synthetic-example", "reviewed") == "unresolved"

A matching read-back state alone does not prove which concurrent process produced it. Additional version or operation evidence would be needed for attribution. In an isolated synthetic test, identity and expected values can nevertheless be checked explicitly.

What the practical tests actually demonstrate

The anonymised extract below summarises my test report from 13 October. Original logs and the gateway repository are private; this account is not an independent assessment and does not include publicly retrievable raw data.

StepObserved result
Initial stateIsolated test table with one existing synthetic record
Separate creation previewValidated; no mutation
Create and readExactly one new record; ID, complete marker and initial status match
Update and readStatus changed; marker unchanged
Single deletion and absence checkID no longer present; targeted marker query returns no match
Final comparisonOne record again; existing record identical to its initial state

All 51 locally rerun mock and transport tests also passed on 19 October. They cover blocked direct calls, schema mismatches, field collisions, invalid data types, previews without mutation, incorrect response IDs, timeouts without retries, and approval expiry, file permissions and one-use consumption.

The documented live test did not perform a real bulk deletion. Bulk approval was checked through automated tests and a live preview. The successful single-record cycle also does not establish general safety under concurrent updates, across all field types or on a compromised operating system.

What still needs checking before production use

MCP tool annotations help clients present and classify operations. They do not replace server-side access control; the schema reference describes them as hints. Read-only access can still expose sensitive data. Record contents must be treated as data and must not establish new write permission.

For a concrete integration, I would first restrict the upstream account to the required resources, remove direct bypass routes and validate a complete synthetic write cycle together with failure cases. Deletion also requires a verified recovery path; the test described here provides no restore evidence. Concurrent writers or automatic replay require version checks or idempotency before approval.

Google Cloud's guidance for MCP agents also recommends combining a minimally privileged technical identity with native database controls. For broader planning, see my enterprise AI architecture checklist. RAG permissions and security trimming covers permissions for read-oriented knowledge retrieval.

Review a concrete MCP write path

Connecting an agent to a CRM, database or business application requires target operations, permissions, approvals and ambiguous-outcome handling to work together. As an AI Solution Architect & Hands-on AI Engineer, I help design, implement and test that specific integration path.

Discuss an MCP integration project – include the target system, intended changes and actions that must always require approval. For an existing pilot, an AI production readiness review can provide a starting point.

Primary sources and evidence

Next step

Sounds relevant for your company?

In a no-obligation initial call, we clarify within 30 minutes whether and where getting started is worthwhile for you — honestly and without sales pressure.

Request an initial call