Skip to content

fix(mcp): skip non-dict models/columns/rels in list and describe - #2547

Closed
Bartok9 wants to merge 2 commits into
Canner:mainfrom
Bartok9:fix/mcp-skip-non-dict-models
Closed

fix(mcp): skip non-dict models/columns/rels in list and describe#2547
Bartok9 wants to merge 2 commits into
Canner:mainfrom
Bartok9:fix/mcp-skip-non-dict-models

Conversation

@Bartok9

@Bartok9 Bartok9 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • MCP list_models / describe_model called .get on every model/column/relationship without type checks.
  • Non-dict rows in context load output crash tool handlers with AttributeError.
  • Skip non-dict models/columns/relationships; treat non-list columns as empty.

Motivation

Defensive consistency with memory/schema_indexer guards on Apache-2.0 core/**. MCP tools should return partial valid inventories rather than fail closed on one corrupt row.

Verification

$ cd core/wren && .venv/bin/python -m pytest tests/unit/test_mcp_model_guards.py -q
2 passed

(Logic mirror of the mcp_server sequence which is nested inside factory registration.)

Apache-2.0

Touches only core/wren/**.

Summary by CodeRabbit

  • Bug Fixes
    • Improved “List Models” and “Describe Model” handling for unexpected or incomplete model, column, and relationship data.
    • Non-conforming entries are now ignored instead of causing failures.
    • Column counts and column listings now remain accurate when column data is missing or malformed.
  • Tests
    • Added unit coverage to ensure junk inputs are skipped for both model listing and model description, including malformed column entries.

@github-actions github-actions Bot added python Pull requests that update Python code core labels Jul 20, 2026
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 51427ded-9ec0-4570-af9c-23889a486c3d

📥 Commits

Reviewing files that changed from the base of the PR and between 14ad14f and fd107d3.

📒 Files selected for processing (1)
  • core/wren/src/wren/mcp_server.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/wren/src/wren/mcp_server.py

Walkthrough

The MCP model tools now filter malformed models, columns, and relationships, normalize invalid column collections, preserve description fallbacks, and include unit tests for junk entries and safe column handling.

Changes

MCP model data guards

Layer / File(s) Summary
Defensive model handling and regression coverage
core/wren/src/wren/mcp_server.py, core/wren/tests/unit/test_mcp_model_guards.py
list_models and describe_model validate model, column, and relationship entries before constructing responses; tests verify malformed entries are skipped and invalid column collections produce safe counts.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • Canner/WrenAI#2522: Adds similar defensive handling for malformed model data in related MCP tooling.

Poem

A bunny found some models out of line,
And skipped the junk with a hop divine.
Columns count safely, descriptions shine,
Relationships guard their little sign.
The MCP burrow is tidy and fine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: making MCP list and describe skip malformed non-dict models, columns, and relationships.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/wren/src/wren/mcp_server.py`:
- Around line 267-278: Prevent malformed scalar YAML values from reaching
collection operations: in core/wren/src/wren/mcp_server.py lines 267-278,
require model.get("columns") to be a list and require col.get("properties") to
be a dict before reading description; in lines 233-238, require
model.get("properties") to be a dict before accessing description; in lines
280-285, require load_relationships(ctx.project) and rel.get("models") to be
lists before iteration or membership checks. Mirror the properties guard in
core/wren/tests/unit/test_mcp_model_guards.py lines 9-14 and the columns guard
in lines 26-30.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e5581b2e-6af5-4145-a445-18a7b903bea1

📥 Commits

Reviewing files that changed from the base of the PR and between 2b0b335 and 1beb238.

📒 Files selected for processing (2)
  • core/wren/src/wren/mcp_server.py
  • core/wren/tests/unit/test_mcp_model_guards.py

Comment on lines +267 to +278
columns = []
for col in model.get("columns") or []:
if not isinstance(col, dict):
continue
columns.append(
{
"name": col.get("name"),
"type": col.get("type"),
"description": col.get("description")
or (col.get("properties") or {}).get("description"),
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Incomplete guards against scalar YAML values.

Based on the PR objectives to prevent failures from malformed context output, the current type checks are insufficient because they do not protect against truthy scalar types. If malformed YAML values for columns, properties, or relationships parse as integers or booleans, or [] and or {} will fall back to those exact scalars, leading to TypeError during iteration/membership tests or AttributeError when chaining .get(). Furthermore, unvalidated strings can cause unintended substring matches during in checks.

  • core/wren/src/wren/mcp_server.py#L267-L278: Guard model.get("columns") as a list before iterating, and verify col.get("properties") is a dict before calling .get("description").
  • core/wren/src/wren/mcp_server.py#L233-L238: Verify model.get("properties") is a dict before calling .get("description").
  • core/wren/src/wren/mcp_server.py#L280-L285: Guard load_relationships(ctx.project) and rel.get("models") as lists before iterating or performing in checks.
  • core/wren/tests/unit/test_mcp_model_guards.py#L9-L14: Mirror the properties dict check in the test helper.
  • core/wren/tests/unit/test_mcp_model_guards.py#L26-L30: Mirror the columns list check in the test helper.
🛡️ Proposed fixes

core/wren/src/wren/mcp_server.py

# Lines 233-238 (list_models fix)
-            description = model.get("description")
-            if description is None:
-                description = (model.get("properties") or {}).get("description")
-            columns = model.get("columns") or []
-            if not isinstance(columns, list):
-                columns = []
+            description = model.get("description")
+            if description is None:
+                props = model.get("properties")
+                if isinstance(props, dict):
+                    description = props.get("description")
+            columns = model.get("columns")
+            if not isinstance(columns, list):
+                columns = []

# Lines 267-278 (describe_model columns fix)
-        columns = []
-        for col in model.get("columns") or []:
-            if not isinstance(col, dict):
-                continue
-            columns.append(
-                {
-                    "name": col.get("name"),
-                    "type": col.get("type"),
-                    "description": col.get("description")
-                    or (col.get("properties") or {}).get("description"),
-                }
-            )
+        columns = []
+        raw_columns = model.get("columns")
+        if isinstance(raw_columns, list):
+            for col in raw_columns:
+                if not isinstance(col, dict):
+                    continue
+                desc = col.get("description")
+                if not desc:
+                    props = col.get("properties")
+                    if isinstance(props, dict):
+                        desc = props.get("description")
+                columns.append(
+                    {
+                        "name": col.get("name"),
+                        "type": col.get("type"),
+                        "description": desc,
+                    }
+                )

# Lines 280-285 (describe_model relationships fix)
-        relationships = []
-        for rel in load_relationships(ctx.project):
-            if not isinstance(rel, dict):
-                continue
-            if name in (rel.get("models") or []):
-                relationships.append(rel)
+        relationships = []
+        raw_rels = load_relationships(ctx.project)
+        if isinstance(raw_rels, list):
+            for rel in raw_rels:
+                if not isinstance(rel, dict):
+                    continue
+                models_list = rel.get("models")
+                if isinstance(models_list, list) and name in models_list:
+                    relationships.append(rel)

core/wren/tests/unit/test_mcp_model_guards.py

# Lines 9-14 (test helper fix)
-        description = model.get("description")
-        if description is None:
-            description = (model.get("properties") or {}).get("description")
-        columns = model.get("columns") or []
-        if not isinstance(columns, list):
-            columns = []
+        description = model.get("description")
+        if description is None:
+            props = model.get("properties")
+            if isinstance(props, dict):
+                description = props.get("description")
+        columns = model.get("columns")
+        if not isinstance(columns, list):
+            columns = []

# Lines 26-30 (test helper fix)
-    columns = []
-    for col in model.get("columns") or []:
-        if not isinstance(col, dict):
-            continue
-        columns.append({"name": col.get("name")})
+    columns = []
+    raw_columns = model.get("columns")
+    if isinstance(raw_columns, list):
+        for col in raw_columns:
+            if not isinstance(col, dict):
+                continue
+            columns.append({"name": col.get("name")})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
columns = []
for col in model.get("columns") or []:
if not isinstance(col, dict):
continue
columns.append(
{
"name": col.get("name"),
"type": col.get("type"),
"description": col.get("description")
or (col.get("properties") or {}).get("description"),
}
)
columns = []
raw_columns = model.get("columns")
if isinstance(raw_columns, list):
for col in raw_columns:
if not isinstance(col, dict):
continue
desc = col.get("description")
if not desc:
props = col.get("properties")
if isinstance(props, dict):
desc = props.get("description")
columns.append(
{
"name": col.get("name"),
"type": col.get("type"),
"description": desc,
}
)
📍 Affects 2 files
  • core/wren/src/wren/mcp_server.py#L267-L278 (this comment)
  • core/wren/src/wren/mcp_server.py#L233-L238
  • core/wren/src/wren/mcp_server.py#L280-L285
  • core/wren/tests/unit/test_mcp_model_guards.py#L9-L14
  • core/wren/tests/unit/test_mcp_model_guards.py#L26-L30
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/wren/src/wren/mcp_server.py` around lines 267 - 278, Prevent malformed
scalar YAML values from reaching collection operations: in
core/wren/src/wren/mcp_server.py lines 267-278, require model.get("columns") to
be a list and require col.get("properties") to be a dict before reading
description; in lines 233-238, require model.get("properties") to be a dict
before accessing description; in lines 280-285, require
load_relationships(ctx.project) and rel.get("models") to be lists before
iteration or membership checks. Mirror the properties guard in
core/wren/tests/unit/test_mcp_model_guards.py lines 9-14 and the columns guard
in lines 26-30.

list_models/describe_model assumed every MDL row was a dict. Malformed
context blobs then AttributeError mid-tool call. Skip junk rows.
@Bartok9
Bartok9 force-pushed the fix/mcp-skip-non-dict-models branch from 1beb238 to 14ad14f Compare July 20, 2026 12:15
@goldmedal

Copy link
Copy Markdown
Collaborator

Thanks for this — I'd like to pass on it, and suggest closing. Not because the underlying concern is wrong, but because of which layer it's fixed at, and because the reachable part is already covered by two other open PRs of yours.

The model guard is dead code. load_models dispatches to _load_models_v1 / _load_models_v2 and both already filter their items (context.py:544, context.py:568), so isinstance(model, dict) can't be false in either list_models or describe_model. Same conclusion as the models half of #2567.

What genuinely leaks is a loader bug, not a consumer bug. load_relationships (context.py:693-699):

data = yaml.safe_load(...) or {}
return data.get("relationships", []) if isinstance(data, dict) else []   # outer dict checked, items not

That's exactly the _load_views_v1 shape from #2567 / #2597 — the annotation promises list[dict] while items go unfiltered. And for the same reason as there, the guard belongs in the loader: load_relationships is read by validate_project, build_manifest / build_json and the MCP tools, so hardening describe_model alone leaves the other consumers exposed and implies re-validation is each caller's job.

And that work is already in flight — yours. #2607 puts the fix in context.py (the right layer), and #2608 rewrites the very same relationship loop in describe_model that this PR touches, so the two will conflict. #2547 is the redundant one of the three.

One note in case any of this comes back as a follow-up: the columns container check is legitimate (it comes straight from YAML), but it's only applied in list_modelsdescribe_model still does for col in model.get("columns") or [], so columns: 5 is still a TypeError there. If it's worth guarding, it should be consistent across both, along with properties (CodeRabbit's open comment) and rel["models"], where a non-list is worse than a crash: "orders" in "orders_extra" is True, so a relationship that doesn't exist gets returned silently.

Also, tests/unit/test_mcp_model_guards.py re-implements the handler logic as local functions instead of importing it, so it would still pass if mcp_server.py were reverted. The factory-registration concern in the description is already solved in this directory — tests/unit/test_mcp_server.py:21 has _get_tool(mcp, name), and recall_queries / get_context / run_sql / query_cube are all tested through build_server(ctx) that way (as does your own #2608). A fixture project plus _get_tool(build_server(ctx), "describe_model") is the pattern to follow.

So: closing in favour of #2607 for the loader fix. Thanks for splitting these out.

@goldmedal

Copy link
Copy Markdown
Collaborator

Correction to my comment above: I called #2607 "the loader fix" — that's wrong. #2607 changes validate_project, which is another consumer; no open PR touches load_relationships itself, so the loader's missing item filter is still unaddressed.

That doesn't change the recommendation, but it does change the reason. Accurately:

  • fix(mcp): skip non-list relationship models in describe_model #2608 supersedes this PR on the MCP side — same describe_model relationship loop, and its version is strictly better: it checks the container type (isinstance(rel_models, list)), which is what actually stops the "orders" in "orders_extra" substring match, and its test goes through build_server + _get_tool.
  • fix(context): treat non-list relationship models as validation error #2607 is correct as written for what it does: validate_project's job is to report structural problems, so raising a ValidationError there is the right behaviour, not something the loader should silently absorb.
  • Still open, and not covered by any of the four: load_relationships (context.py:693-699) not filtering its items, with list[dict] in the annotation.

So: close this one in favour of #2608, and the load_relationships item filter is worth a separate focused PR if you want it.

@Bartok9

Bartok9 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful trace, and for the correction re: #2607 vs #2608 — that's a better map of the layers than mine. Agreed: #2608 is the strictly-better fix on the MCP side (container-type check + test through build_server), so closing this in its favour. The load_relationships item filter (context.py:693-699) is the genuinely-uncovered piece; happy to send that as a separate focused PR if it's wanted. Closing here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants