Skip to content

fix(tools): apply case-fold args normalization to render_plugin + add_dynamic_map_layer - #15

Merged
romer8 merged 1 commit into
mainfrom
fix/render-plugin-and-dynamic-map-layer-args-case-normalize
May 22, 2026
Merged

fix(tools): apply case-fold args normalization to render_plugin + add_dynamic_map_layer#15
romer8 merged 1 commit into
mainfrom
fix/render-plugin-and-dynamic-map-layer-args-case-normalize

Conversation

@romer8

@romer8 romer8 commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Post-/ce-debug audit identified the same LLM snake-case bug from PR #14 in two more tools that accept plugin `args` dicts: `render_plugin` and `add_dynamic_map_layer`. This PR wires the existing `_fetch_plugin_arg_names` + `_normalize_args_case` helpers into both, plus tightens their descriptions.

Why these two

Audit summary table:

Tool Class B (args case-fold) Verdict
`configure_popup_modal_layer` ✅ Fixed (PR #14)
`render_plugin` ❌ Same bug This PR
`add_dynamic_map_layer` ❌ Same bug This PR
`add_esri_feature_layer` (Class A) Not applicable URL convention makes sublayer implicit
Other `add_*_layer` tools Lower risk `attribute_variables` keys are field names (usually already lowercase)

Changes

`render_plugin` (`mcp_server.py:4304-4350`)

  • Insert `_fetch_plugin_arg_names(source)` + `_normalize_args_case(args, ...)` before building the visualization spec
  • Collision case → structured `{error, fix_hint}` envelope
  • Description tightening: replace the concrete example `{"gauge_id": "${my_gauge}"}` (which reinforced LLM's snake_case habit) with case-sensitivity prose naming the `river_ID` counterexample

`add_dynamic_map_layer` (`mcp_server.py:4213-4285`)

  • Same helper calls between `_resolve_dynamic_map_layer_plugin` and `builder.set_plugin_source`
  • Description tightening on the `args` field with case-sensitivity prose

Tests

8 new tests, 4 per tool, mirroring `TestPopupConfigArgsCaseNormalization` from PR #14:

  • `test_visualization_contracts.py::TestRenderPluginArgsCaseNormalization`
  • `test_layer_contracts.py::TestAddDynamicMapLayerArgsCaseNormalization`

Each class covers: lowercase→canonical rewrite, exact preserved, fetch-failure soft-fail pass-through, case-fold collision rejected with structured envelope.

Full suite: 952 passed (944 baseline + 8 new). Zero regression — existing `TestRenderPlugin` + `TestAddDynamicMapLayer` tests pass because `TETHYSDASH_BASE_URL` is unset in the test env so `_fetch_plugin_arg_names` returns `None` and the helper passes through.

Soft-fail preserved

When `TETHYSDASH_BASE_URL` is unset OR fetch fails OR source isn't in the registry, args pass through unchanged (matches pre-fix behavior). Only the case-fold collision is a hard-fail path.

Audit by-product (for record)

While verifying, also confirmed that `add_esri_feature_layer` is NOT affected by Class A (ESRI sublayer-name resolution). React's `getArcGISFeatureServiceLayerAttributes` keys by user-supplied layerName, consistent with server's `attr_key=name`. ESRI Feature URL convention makes the layer implicit; no display-name vs service-name split exists.

Flagged for later: `popup_options.aliases` on `add_esri_image_layer` may still have a Class A issue (React popup-table render path probably keys by ESRI sublayer name, not user's display name). Not observed yet; will surface when popup-table-rename feature is exercised.

Manual smoke after merge

Restart MCP server (with `TETHYSDASH_BASE_URL` set). Try a prompt like:

Render the GeoGLOWS Forecast Plot for River ID = 12345

Expected: `render_plugin` called with `args = {"river_id": "12345"}`; server fetches `arg_names = ["river_ID"]`; persisted `args` contains `{"river_ID": "12345"}`. Plot fetches correctly.

…_dynamic_map_layer

Post-/ce-debug audit 2026-05-21: PR #14 fixed Bug B (LLM
snake-case-normalization habit) in configure_popup_modal_layer but
the same class of bug exists in render_plugin and add_dynamic_map_layer.
Both accept an args dict for an intake plugin; both passed it through
verbatim with zero normalization. LLM emits e.g. {"river_id": ...} when
the plugin declares river_ID → plugin runtime lookup fails silently.

This commit lifts the same normalization pattern (already implemented
in mcp_server.py via _fetch_plugin_arg_names + _normalize_args_case) and
wires it into both tools. No new helpers — direct re-use of PR #14's
shared infrastructure.

## render_plugin

- Insert _fetch_plugin_arg_names(source) + _normalize_args_case(args, ...)
  before building the visualization spec.
- Collision case (None return) → structured {error, fix_hint} envelope.
- Description tightening: replace the prior concrete example that read
  {"gauge_id": "${my_gauge}"} (which reinforced snake_case) with
  case-sensitivity prose that names the river_ID counterexample (an
  intentionally mixed-case canonical shape, NOT a value the LLM should
  copy verbatim per feedback_no_examples_in_tool_descriptions.md).

## add_dynamic_map_layer

- Same insertion: helpers called between _resolve_dynamic_map_layer_plugin
  and builder.set_plugin_source.
- Description tightening on the args field: case-sensitivity prose
  matching render_plugin.

## Audit findings (for record)

Class B (args case-fold) confirmed in render_plugin + add_dynamic_map_layer.
Class A (ESRI sublayer-name resolution) verified NOT applicable to
add_esri_feature_layer — React's getArcGISFeatureServiceLayerAttributes
keys by user-supplied layerName, consistent with server's attr_key=name.
ESRI Feature URL convention (.../FeatureServer/<layer_id>) makes the
layer implicit; no display-name vs service-name split.

Hidden secondary concern flagged for later: popup_options.aliases on
add_esri_image_layer may still have Class A issue (React popup-table
render path probably keys by ESRI sublayer name, not user's display
name). Not yet observed; will surface when popup-table-rename feature
is exercised next.

## Tests

8 new tests across 2 files, mirroring TestPopupConfigArgsCaseNormalization
pattern from PR #14:
- test_visualization_contracts.py::TestRenderPluginArgsCaseNormalization:
  - test_lowercase_key_rewritten_to_canonical_case
  - test_exact_match_preserved
  - test_fetch_failure_soft_fails_to_pass_through
  - test_case_fold_collision_rejected_with_structured_error
- test_layer_contracts.py::TestAddDynamicMapLayerArgsCaseNormalization:
  - same 4, parameterized for add_dynamic_map_layer

Full suite: 952 passed (944 baseline + 8 new). Zero regression — existing
TestRenderPlugin + TestAddDynamicMapLayer tests pass because
TETHYSDASH_BASE_URL is unset in the test env so _fetch_plugin_arg_names
returns None and the helper passes through.

## Soft-fail behavior preserved

When TETHYSDASH_BASE_URL is unset OR the fetch fails OR the source isn't
in the registry, args pass through unchanged (current pre-fix behavior).
Better to ship the LLM's verbatim args than to reject the whole flow
when the registry is briefly unreachable. The collision case (two LLM
keys map to the same canonical arg_name) is the only hard-fail path —
it indicates a genuine LLM mistake that the user should see.
@romer8
romer8 merged commit 5d16e29 into main May 22, 2026
2 checks passed
@romer8
romer8 deleted the fix/render-plugin-and-dynamic-map-layer-args-case-normalize branch May 22, 2026 02:28
romer8 added a commit that referenced this pull request May 22, 2026
Eight PRs from the 2026-05-21 popup-modal arc, grouped into Added /
Changed / Fixed / Tests / Documentation per Keep a Changelog. New tool
+ slash-prompt for configure_popup_modal_layer (PR #10), case-fold
args normalization for popup gridItems + render_plugin +
add_dynamic_map_layer (PRs #14 + #15), ESRI sublayer-ID extraction
from LAYERDEFS + ESRI Image guard widening (PRs #12 + #17), WMS
attr_key from wms_layers (PR #16), popup_options outer-key
normalization across all 11 add_*_layer tools (PR #11), tool
description tightening for case-sensitive arg_names + 100-col grid
(PR #13). Suite grew 930 → 961.
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