Skip to content

feat(tools): data_uri opt-in + records-mode pivot + envelope unwrap on create_* tools - #7

Merged
romer8 merged 2 commits into
mainfrom
feat/data-uri-opt-in
May 19, 2026
Merged

feat(tools): data_uri opt-in + records-mode pivot + envelope unwrap on create_* tools#7
romer8 merged 2 commits into
mainfrom
feat/data-uri-opt-in

Conversation

@romer8

@romer8 romer8 commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Receiving side of chatbox-core's MCP result-by-reference protocol (plan
2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md in the firoh workspace — Unit 5).

The three create_* tools that take inline data arrays gain an optional data_uri: str | list[str] arg. After chatbox-core's substitution layer (engine/uri-substitution.js — chatbox-core PR pending) resolves the URI from its IndexedDB cache, the server sees the call as if the LLM had passed data directly — no MCP wire-contract change for the mediated path.

Why this matters

Production observation 2026-05-18: a 200-second response on the deployed tethysdash chatbox was traced to LLM transcription of a 240-row data array between two MCP servers. The LLM regenerated the data token-by-token for the chart call. The earlier description-bias fix (PR #6 — open) didn't move the needle. This is the actual architectural fix: cache the data client-side, pass a reference, skip the regeneration entirely.

Changes

tethysdash_mcp/_uri_field.py (new) — shared Pydantic Field-factory for the *_uri opt-in pattern:

  • uri_field(inline_arg_name=...) — Annotated metadata for the URI arg with regex validation against ^mcp\+cache://<conv-id>/<token>$ and max_length=128
  • ensure_exactly_one_set(inline, inline_name, uri, uri_name) — server-side validator for the mutual-exclusion contract

Per-tool changes in tethysdash_mcp/mcp_server.py:

  • create_plotly_chart (line 317), create_data_table (line 465), create_card (line 635) each gain an optional data_uri arg alongside data (now also Optional). Tool descriptions for data recommend data_uri when the data came from a prior tool call in the conversation.
  • Three rejection paths in each tool body:
    • both data and data_uri set → exactly-one-of error
    • neither set (chart, table only — card allows empty placeholder) → must-provide-one error
    • data_uri arrived unresolved at the server → "unresolved URI" error with a fix_hint explaining chatbox-core mediation is expected (catches unmediated clients like Claude Desktop)

Backward compat

Mediated happy path (chatbox-core substitutes data_uridata and drops data_uri before dispatch) flows through the existing inline-data branch unchanged. Every existing call that passes only data continues to work — verified by 3 regression tests, one per create_* tool.

Tests

13 initial tests in test_data_uri_opt_in.py covering:

  • Backward compat (3 tools × inline form)
  • Unmediated client rejection (3 tools)
  • Both-set conflict (3 tools)
  • Neither-set rejection (2 tools — card exempted by design)
  • Pydantic pattern enforcement on bad URIs (2 schema-rejection cases)

Companion changes

Independent value

This PR is independently safe to merge ahead of the chatbox-core companion: the schema change is additive (existing inline-data calls work unchanged), the unmediated-client rejection path is a clear error for any non-chatbox-core MCP client that might encounter the new arg in tools/list.


Follow-up commit: records-mode + envelope unwrap (116ea40)

Smoke-testing the cache+URI path mid-shipment on Ollama Cloud (nemotron-3-{nano,super}, qwen-3.5-397b, deepseek-pro-4) exposed a structural mismatch that this PR's original commit didn't fully handle, plus a closely-related LLM-as-ETL bypass on chart creation. Both fixed in 116ea40.

The bug

create_plotly_chart and create_data_table rejected calls with a recurring Pydantic 2-error signature:

data.list[dict[str,any]] (type=list_type)
data.str (type=string_type)

Translation: data arrived as neither a list nor a string — typically a dict. Causal chain: nrds_mcps emits {ok, rows, columns, data:[records], ...} (~25KB envelope) → chatbox-core caches it + mints _cache_uri → truncation drops the data array but preserves _cache_uri → LLM correctly passes data_uri = "mcp+cache://..." to viz tool → chatbox-core's substitution layer writes the WHOLE cached envelope into data (it's intentionally dumb — copies the payload verbatim) → Pydantic rejects.

A third layer surfaces even after envelope unwrap: nrds emits records ([{feature_id, time, flow}, ...]) but Plotly expects traces ([{x:[...], y:[...], type:"scatter"}, ...]). The records→traces pivot was implicitly done by the LLM before the URI protocol; bypassing the LLM bypasses the transform.

The fix (server-side defenses on mcp_server.py)

  1. BeforeValidator(_unwrap_data_envelope) on create_plotly_chart.data and create_data_table.data — unwraps dict envelopes (extracts first list-valued data / rows / records key) before Pydantic's Union check. Published schema is unchanged — the LLM-visible type stays Union[List, str].
  2. Records-mode pivot on create_plotly_chart — three new optional args (x_field, y_field, series_field). Server detects records (list of dicts without Plotly trace keys) and pivots into traces. LLM names columns instead of constructing trace arrays — removes the LLM-as-ETL step.
  3. None-string coercion + JSON-string decoding on layout / config (Optional[Dict] args). Models emit "None" / "null" / "" for genuinely-empty optional dicts; server coerces to actual None. Models also emit nested dicts as JSON strings; server decodes with json.loads.
  4. Softened _uri_field.py description — dropped "DO NOT" / "WRONG" / "wasting tokens" framing that was suspected of biasing weak models.

Tests + verification

24 additional tests in test_data_uri_opt_in.py:

  • 11 covering None-string coercion + JSON-string acceptance + production-failure reproduction
  • 13 covering envelope unwrap (data/rows/records keys, rejection of dicts without list-valued keys) and records-mode pivot (single trace, series grouping, missing-field rejection, backward-compat trace passthrough, end-to-end envelope→records→traces)

Combined suite: 821/821 passing.

End-to-end smoke test: 240-row time-series chart prompt against nemotron-3-super and nemotron-3-nano-30b on Ollama Cloud — chart renders successfully after server restart.

Documentation

Solution captured at docs/solutions/integration-issues/mcp-data-envelope-unwrap-and-records-pivot-2026-05-19.md (firoh workspace). Lightweight cross-link refresh applied to two related best-practices docs.

Plan + companion PR

…table, create_card

Plan: docs/plans/2026-05-18-002-feat-mcp-result-by-reference-protocol-plan.md
(in the firoh workspace) — Unit 5.

Receiving side of chatbox-core's MCP result-by-reference protocol.
The three create_* tools that take inline `data` arrays gain an
optional `data_uri: str | list[str]` arg. After chatbox-core's
substitution layer (engine/uri-substitution.js, plan Unit 3) resolves
the URI from its IndexedDB cache, the server sees the call as if the
LLM had passed `data` directly — no MCP wire-contract change for the
mediated path.

Two new pieces:

1. `tethysdash_mcp/_uri_field.py` — shared Pydantic Field-factory for
   the `*_uri` opt-in pattern. Provides:
   - `uri_field(inline_arg_name=...)` builds the standard Annotated
     metadata: scalar OR list of strings, regex-validated against the
     `mcp+cache://<conv-id>/<token>` shape, max_length=128.
   - `ensure_exactly_one_set(inline_value, inline_name, uri_value,
     uri_name)` validates the mutual-exclusion contract — exactly one
     of the two args must be non-empty.

2. Per-tool changes in `mcp/tethysdash_mcps/tethysdash_mcp/mcp_server.py`:
   - `create_plotly_chart` (line 317), `create_data_table` (line 465),
     `create_card` (line 635) each gain an optional `data_uri` arg
     alongside `data` (now also Optional). Tool descriptions for
     `data` recommend `data_uri` when the data came from a prior tool
     call in the conversation, biasing the LLM toward the URI form.
   - Tool body validators run BEFORE the existing JSON-string decode
     and empty-data checks. Three rejection paths:
       - both `data` and `data_uri` set → exactly-one-of error
       - neither set (chart, table only — card allows empty placeholder)
         → must-provide-one error
       - `data_uri` arrived unresolved at the server → "unresolved URI"
         error with a fix_hint explaining chatbox-core mediation is
         expected (catches unmediated clients like Claude Desktop)

The mediated happy path (chatbox-core substitutes `data_uri` → `data`
and drops `data_uri` before dispatch) flows through the existing
inline-data branch unchanged. Backward-compat: every existing call
that passes only `data` continues to work — verified by 3 regression
tests, one per create_* tool.

Tests: 13 new in test_data_uri_opt_in.py covering backward compat
(3 tools × inline form), unmediated client rejection (3 tools),
both-set conflict (3 tools), neither-set rejection (2 tools — card
exempted by design), and Pydantic pattern enforcement on bad URIs
(2 schema-rejection cases). Suite: 786 → 799 passed.
Three converging defenses against malformed `data` payloads observed
across nemotron-3-{nano,super}, qwen-3.5-397b, and deepseek-pro-4 on
2026-05-18/19 against the cache+URI protocol on `feat/data-uri-opt-in`:

1. Records-mode pivot on `create_plotly_chart`. New optional `x_field`
   / `y_field` / `series_field` args let the LLM name source columns
   instead of constructing Plotly trace arrays. Server pivots records
   into traces, removing the LLM-as-ETL transformation step entirely.
   Detection: list of dicts without Plotly trace keys (x / y / type).

2. Envelope unwrap via `BeforeValidator(_unwrap_data_envelope)` on
   `create_plotly_chart.data` and `create_data_table.data`. The
   cache+URI substitution layer writes the full upstream envelope
   (`{ok, rows, columns, data:[records], ...}`) into the `data` slot;
   without unwrap the dict fails the Union[List, str] check with two
   Pydantic errors (`list_type` + `string_type`). The pre-validator
   extracts the first list-valued `data` / `rows` / `records` key
   before strict validation.

3. None-string coercion + JSON-string decoding on `layout` / `config`
   (Optional[Dict] args). Models emit the Python literal `None` /
   `null` / empty string for genuinely-empty optional dicts; coerce
   to actual None. Models also emit nested dicts as JSON strings to
   avoid output complexity; decode with json.loads. Both paths
   widened to `Union[Dict, str]` with body coercion.

Tightened `_uri_field.py` description: drops the prior "DO NOT" /
"WRONG" / "wasting tokens" framing that was suspected of biasing
weak models, replaces with imperative direction on when to use the
URI form vs. inline.

Test coverage: 24 new tests in test_data_uri_opt_in.py — 11 covering
None-coercion + JSON-string acceptance + production-failure-mode
reproduction, 13 covering envelope unwrap (data/rows/records keys,
rejection of envelope-less dicts) and records-mode pivot (single
trace, series grouping, missing-field rejections, backward-compat
trace passthrough, end-to-end envelope→records→traces). Suite 821/821.
@romer8 romer8 changed the title feat(tools): data_uri opt-in on create_plotly_chart, create_data_table, create_card feat(tools): data_uri opt-in + records-mode pivot + envelope unwrap on create_* tools May 19, 2026
@romer8
romer8 merged commit 5ba0c26 into main May 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant