ADR 007: Reject Nested-Wrapper Argument Shapes in CRUD Dispatch

Category: reference
Slug: adr-007-nested-wrapper-rejection
Status: Accepted
Date: 2026-06-05


Context

On 2026-06-05, validation testing against a live deployment exposed a silent data corruption path. An agent sent a contacts create call using the REST-convention nested wrapper shape:

{
  "method": "tools/call",
  "params": {
    "name": "contacts__create",
    "arguments": {
      "email": "agent@example.com",
      "first_name": "Agent",
      "data": {
        "notes": "nested field"
      }
    }
  }
}

More critically, agents familiar with REST API conventions sent the entire payload wrapped inside a data key:

{
  "arguments": {
    "data": {
      "email": "agent@example.com",
      "first_name": "Agent"
    }
  }
}

The server returned {"created": true, "id": "6b7e67eb-..."} — a real UUID, a real database row, a 201 status code. The agent had no reason to suspect failure. The row was empty. Three such records were confirmed in the demo database (6b7e67eb, c123f361, b1b58efa) from prior sessions hitting the same trap.

Root Cause Trace

The failure traverses two components:

registry.py — JSON Schema validation passes. The tool's inputSchema is generated by the discovery backend from the DRF serializer. It describes the declared fields (email, first_name, etc.) but does not set additionalProperties: false. JSON Schema draft-07 permits additional properties by default. When the agent sends {"data": {"email": "...", "first_name": "..."}}, the schema validator accepts it — data is additional, not declared, and additional properties are implicitly allowed.

invocation.py — body is forwarded as-is. SyncInvocation.invoke() calls _extract_list_body(body_args). That function checks whether the single key is in _LIST_BODY_KEYS ({"objects", "data", "items", "_items", "body"}) and the value is a list for bulk-create. {"email": "...", "first_name": "..."} is a dict, not a list, so _extract_list_body returns None. FK normalization runs on body_args but data is not in the tool's schema properties, so it is passed through unchanged. DRF receives {"data": {"email": "...", "first_name": "..."}}, its serializer encounters an undeclared data field, silently ignores it, and creates the record with all fields absent or at their empty defaults.

Why Not Fix the Schema

Setting additionalProperties: false on every auto-generated inputSchema is a breaking change. Legitimate tool call patterns use argument keys that are not field names: protocol arguments stripped before dispatch (help, continuation_token, mode, page, page_size, filter_keys, filters). These would be rejected if the schema enforced strict additionalProperties. Customizing the schema generation to exclude frisian_mcp protocol keys is possible but fragile — every new protocol argument requires a schema-generation update. The validation layer is the right place to detect semantic payload shape errors.

Design Question Posed

Three options were evaluated:

Approach A — normalize: Accept data: {...} as a flat-field alias. Unwrap the nested value and merge it at the top level before dispatch. Silent compatibility.

Approach B — reject: Raise a validation error for any argument whose key is not declared in the tool schema and whose value is a dict. Agent receives a clear actionable error. No silent record creation.

Approach C — normalize and warn: Accept and unwrap, but include a deprecation hint in the response.

Jeremy confirmed Approach B on 2026-06-05: "We don't need the agent thinking it is doing a good job and submitting bad data."


Decision

frisian-mcp adds a nested-wrapper guard to registry.py's ToolRegistry.dispatch() method. The guard fires after JSON Schema validation and before the tool callable is invoked.

Detection Algorithm

For each (key, value) in arguments:
    If isinstance(value, dict):
        If key not in tool.inputSchema["properties"]:
            → ToolInputError

A key with a dict value that is not declared in the tool's inputSchema.properties is a nested wrapper — the agent wrapped field data in a container the serializer will never unpack. This is always an error, regardless of how the key is named.

The check is applied only to non-dispatcher tools. Dispatcher tools (is_dispatcher=True) handle their own argument routing (action enum, resource key); their argument shape is intentionally opaque to the general guard. Sub-tools invoked by the dispatcher pass through dispatch() independently and receive the guard at that point.

The check is skipped for bulk-list-body calls (_is_list_body=True) because those have already been validated: the single-key dict pattern with a list value is the declared convention for bulk operations.

Error Response

The error is raised as ToolInputError, which the views layer converts to a JSON-RPC -32602 INVALID_PARAMS error. The error message is:

Argument 'data' is not a declared field and its value is a nested object.
Likely cause: REST-style wrapper — send fields flat at the top level.
Unwrap 'data': send "email", "first_name" directly, not inside "data".
Declared fields: ["email", "first_name", ...].

The message names the offending argument, names the fields inside the wrapper, and names the declared fields so the agent can self-correct without a round-trip to tools/list.

What Is Not Affected

FK and M2M dict values. Foreign-key fields have inputSchema entries with oneOf: [{"type": "string"}, {"type": "object"}]. These are declared in properties; the guard passes them through. An agent sending {"site": {"name": "hq-1"}} for a declared FK field does not trigger the guard.

Bulk-create list bodies. {"objects": [{...}, ...]}, {"data": [{...}, ...]}, and equivalent single-key list bodies match _is_list_body=True before the guard runs and are routed to the host serializer unchanged.

Protocol arguments. continuation_token, mode, page, page_size, filter_keys, filters are scalar values (strings, ints, lists of strings) — not dicts — and do not trigger the guard.

Custom tools that declare a data property. If a tool's inputSchema explicitly declares "data" as a property, the guard sees it in properties and permits it.

Implementation Surface

Changes are confined to the frisian_mcp package, registry.py only:

Change Detail
New helper _detect_nested_wrapper(arguments, input_schema) Returns str | None — the error message when the pattern is detected, None when clean
One call in ToolRegistry.dispatch() After jsonschema.validate(), before entry.fn() call; gated on not is_dispatcher_help and not _is_list_body and not entry.is_dispatcher

No changes to invocation.py, views.py, backends/, or any host application. No new settings. No opt-out.


Consequences

Positive. Silent data corruption is eliminated. Agents receive an actionable error with the field names they intended to send. No empty records are created.

Positive. The error message is self-correcting. It identifies the wrapper key, lists the fields inside it, and lists the declared fields — the agent knows exactly what to change without calling tools/list.

Positive. Implementation is minimal. One helper function, one call site, one file, no schema changes, no new infrastructure.

Positive. The check is passive for correct callers. Any agent that already sends flat arguments at the top level sees no change.

Negative. Approach A's silent compatibility is rejected. Agents that have learned to use data: {...} will break on the first call after the package is deployed and must update their tool-call arguments. The agent error is informative enough for self-correction, but there is a transition cost for agents running long sessions against a newly upgraded server.

Negative. The detection is structural, not semantic. The guard checks argument shape (nested dict, key absent from schema) without understanding the agent's intent. A tool that legitimately accepts a complex nested configuration object under a key like config would not be affected — only if config is absent from the schema. If a host tool inadvertently omits a complex-object property from its inputSchema, the guard would reject its callers. The fix is to declare the property correctly in the schema.

Negative. Dispatcher tool arguments are not checked at the dispatcher call level. If an agent sends {"action": "create", "resource": "contacts", "data": {...}} to a dispatcher tool, the data key reaches the dispatcher's routing logic. Whether and how the dispatcher forwards extra arguments to sub-tools is the dispatcher backend's concern; if forwarded, the sub-tool dispatch catches it. If silently dropped by the dispatcher, the data loss occurs before the guard. This is a narrower residual risk — it affects the dispatcher-mediated path specifically — and is tracked separately.


Validation

Test cases for T3:

  1. contacts__create with flat arguments — creates record with correct fields. Guard does not fire.
  2. contacts__create with data: {email, first_name} — returns INVALID_PARAMS with message identifying data, listing wrapped fields, listing declared fields.
  3. contacts__create with {"objects": [{...}]} — bulk-create proceeds; guard does not fire.
  4. dcim__devices with a declared FK {"site": {"name": "hq-1"}} — FK dict passes; guard does not fire.
  5. Existing records 6b7e67eb, c123f361, b1b58efa — cleaned up as part of T3 demo DB cleanup.

ADR maintained alongside the frisian-mcp source. Architecture decision records capture the reasoning behind durable design choices for future maintainers and adopters.