Skip to content

Commit 3c194cd

Browse files
authored
Merge pull request #115 from gbrlcustodio/fix/smoke-pre-merge-mcp-1-2
fix: smoke pre-merge — field condition actionId, search_pipes truncation, comment length UX
2 parents 5a1a21c + 44c87fa commit 3c194cd

9 files changed

Lines changed: 145 additions & 8 deletions

File tree

packages/mcp/src/pipefy_mcp/tools/pipe_tool_helpers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Any, Literal, cast
55

66
from pipefy_sdk import CardSearch, PipefyClient
7+
from pipefy_sdk.models.comment import MAX_COMMENT_TEXT_LENGTH
8+
from pydantic import ValidationError
79
from typing_extensions import TypedDict
810

911
from pipefy_mcp.tools.destructive_tool_guard import (
@@ -24,6 +26,37 @@ class UserCancelledError(Exception):
2426
"""Raised when a user cancels an interactive flow."""
2527

2628

29+
ADD_CARD_COMMENT_VALIDATION_FALLBACK_MSG = (
30+
"Invalid input. Please provide a valid 'card_id' and non-empty 'text'."
31+
)
32+
33+
34+
def message_for_add_card_comment_validation_error(
35+
exc: ValidationError,
36+
*,
37+
raw_text: str,
38+
) -> str:
39+
"""Map Pydantic errors from ``CommentInput`` to a user-visible MCP message.
40+
41+
Args:
42+
exc: Validation failure from constructing ``CommentInput``.
43+
raw_text: Original ``text`` argument (length hint if the error omits input).
44+
45+
Returns:
46+
Actionable English message; long-text failures cite the 1000-character cap.
47+
"""
48+
for err in exc.errors():
49+
if err.get("type") not in ("string_too_long", "too_long"):
50+
continue
51+
loc = err.get("loc") or ()
52+
if "text" not in loc:
53+
continue
54+
inp = err.get("input")
55+
got = len(inp) if isinstance(inp, str) else len(raw_text)
56+
return f"text exceeds {MAX_COMMENT_TEXT_LENGTH}-character limit (got {got})."
57+
return ADD_CARD_COMMENT_VALIDATION_FALLBACK_MSG
58+
59+
2760
# The ``Legacy*SuccessPayload`` TypedDicts below describe the flag=false shape
2861
# only. Under the default ``PIPEFY_MCP_UNIFIED_ENVELOPE=true``, helpers return
2962
# ``ToolSuccessPayload`` instead (see ADR-0001).

packages/mcp/src/pipefy_mcp/tools/pipe_tools.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
map_delete_card_error_to_message,
5757
map_delete_comment_error_to_message,
5858
map_update_comment_error_to_message,
59+
message_for_add_card_comment_validation_error,
5960
)
6061
from pipefy_mcp.tools.relation_tool_helpers import (
6162
build_relation_error_payload,
@@ -302,9 +303,11 @@ async def add_card_comment(
302303
# Privacy: never log the full comment text (it may contain sensitive data).
303304
try:
304305
comment_input = CommentInput(card_id=card_id, text=text)
305-
except ValidationError:
306+
except ValidationError as exc:
306307
return build_add_card_comment_error_payload(
307-
message="Invalid input. Please provide a valid 'card_id' and non-empty 'text'."
308+
message=message_for_add_card_comment_validation_error(
309+
exc, raw_text=text
310+
)
308311
)
309312

310313
try:
@@ -1054,7 +1057,9 @@ async def search_pipes(
10541057
Without a name filter, each organization returns at most ``max_pipes_per_org``
10551058
pipes (capped 1--500) to avoid huge responses. With a name filter, the API
10561059
receives a server-side ``name_search`` hint; results are still capped per org
1057-
after scoring. Check ``search_limits`` and per-org ``pipes_truncated`` when present.
1060+
after scoring. Check ``search_limits`` and per-org ``pipes_truncated`` when
1061+
present; when unfiltered, ``pipes_truncated`` is True if the API returned fewer
1062+
pipes than ``pipesCount`` for that org (incomplete visible list vs org total).
10581063
10591064
Args:
10601065
pipe_name: Optional pipe name to search for (case-insensitive partial match).

packages/mcp/tests/tools/test_pipe_tool_helpers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from unittest.mock import AsyncMock, MagicMock
99

1010
import pytest
11+
from pipefy_sdk import CommentInput
12+
from pydantic import ValidationError
1113

1214
from pipefy_mcp.tools.graphql_error_helpers import (
1315
extract_error_strings,
@@ -16,6 +18,7 @@
1618
with_debug_suffix,
1719
)
1820
from pipefy_mcp.tools.pipe_tool_helpers import (
21+
ADD_CARD_COMMENT_VALIDATION_FALLBACK_MSG,
1922
FIND_CARDS_EMPTY_MESSAGE,
2023
UserCancelledError,
2124
_filter_editable_field_definitions,
@@ -27,6 +30,7 @@
2730
find_label_dependents,
2831
map_add_card_comment_error_to_message,
2932
map_delete_card_error_to_message,
33+
message_for_add_card_comment_validation_error,
3034
)
3135
from pipefy_mcp.tools.tool_error_envelope import tool_error
3236

@@ -81,6 +85,31 @@ def test_build_add_card_comment_success_payload_parametrized_flag(envelope_flag)
8185
assert out == {"success": True, "comment_id": "42"}
8286

8387

88+
# =============================================================================
89+
# message_for_add_card_comment_validation_error
90+
# =============================================================================
91+
92+
93+
@pytest.mark.unit
94+
def test_message_for_add_card_comment_validation_error_string_too_long():
95+
long_text = "x" * 1001
96+
with pytest.raises(ValidationError) as exc_info:
97+
CommentInput(card_id=1, text=long_text)
98+
msg = message_for_add_card_comment_validation_error(
99+
exc_info.value, raw_text=long_text
100+
)
101+
assert "1000" in msg
102+
assert "got 1001" in msg
103+
104+
105+
@pytest.mark.unit
106+
def test_message_for_add_card_comment_validation_error_fallback_for_blank_text():
107+
with pytest.raises(ValidationError) as exc_info:
108+
CommentInput(card_id=1, text=" ")
109+
msg = message_for_add_card_comment_validation_error(exc_info.value, raw_text=" ")
110+
assert msg == ADD_CARD_COMMENT_VALIDATION_FALLBACK_MSG
111+
112+
84113
# =============================================================================
85114
# extract_error_strings
86115
# =============================================================================

packages/mcp/tests/tools/test_pipe_tools.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,28 @@ async def test_api_exception_returns_mapped_error_payload(
13021302
payload
13031303
) or "card_id" in tool_error_message(payload)
13041304

1305+
@pytest.mark.parametrize("client_session", [None], indirect=True)
1306+
async def test_validation_error_text_over_limit_returns_explicit_length_message(
1307+
self,
1308+
client_session,
1309+
mock_pipefy_client,
1310+
extract_payload,
1311+
):
1312+
"""Comment text over max length surfaces cap and length, not generic card_id hint."""
1313+
long_text = "a" * 1001
1314+
async with client_session as session:
1315+
result = await session.call_tool(
1316+
"add_card_comment",
1317+
{"card_id": 123, "text": long_text},
1318+
)
1319+
assert result.isError is False
1320+
mock_pipefy_client.add_card_comment.assert_not_called()
1321+
payload = extract_payload(result)
1322+
assert payload["success"] is False
1323+
msg = tool_error_message(payload)
1324+
assert "1000" in msg
1325+
assert "got 1001" in msg
1326+
13051327

13061328
@pytest.mark.anyio
13071329
class TestGetPhaseFieldsTool:

packages/sdk/src/pipefy_sdk/queries/pipe_config_queries.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@
221221
}
222222
}
223223
actions {
224+
actionId
224225
phaseFieldId
225226
}
226227
}
@@ -247,6 +248,7 @@
247248
}
248249
}
249250
actions {
251+
actionId
250252
phaseFieldId
251253
}
252254
}

packages/sdk/src/pipefy_sdk/queries/pipe_queries.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
organizations {
5858
id
5959
name
60+
pipesCount
6061
pipes(name_search: $nameSearch) {
6162
id
6263
name

packages/sdk/src/pipefy_sdk/services/pipe_service.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ def _clamp_max_pipes_per_org(value: int) -> int:
2424
return max(SEARCH_PIPES_MAX_PER_ORG_MIN, min(SEARCH_PIPES_MAX_PER_ORG_CAP, value))
2525

2626

27+
def _coerce_org_pipes_count(raw: object) -> int | None:
28+
"""Parse GraphQL ``Organization.pipesCount`` for truncation checks."""
29+
if raw is None:
30+
return None
31+
try:
32+
return int(raw)
33+
except (TypeError, ValueError):
34+
return None
35+
36+
2737
class PipeService(BasePipefyClient):
2838
"""Service for Pipe-related operations."""
2939

@@ -112,6 +122,11 @@ async def search_pipes(
112122
If pipe_name is provided, only pipes matching the name are included,
113123
sorted by match score (best matches first).
114124
May include ``search_limits`` describing caps applied.
125+
126+
When ``pipe_name`` is omitted, per-org ``pipes_truncated`` and
127+
``search_limits.pipes_truncated`` are True if the client sliced the list
128+
to ``max_pipes_per_org`` **or** the API returned fewer ``pipes`` than
129+
``pipesCount`` (user-visible subset vs org total).
115130
"""
116131
stripped = pipe_name.strip() if pipe_name else None
117132
name_search = stripped if stripped else None
@@ -132,17 +147,22 @@ async def search_pipes(
132147
organizations: list[dict] = []
133148
for org in raw_orgs:
134149
pipes = list(org.get("pipes") or [])
135-
truncated = len(pipes) > per_org_cap
136-
if truncated:
150+
pipes_count = _coerce_org_pipes_count(org.get("pipesCount"))
151+
truncated = len(pipes) > per_org_cap or (
152+
pipes_count is not None and len(pipes) < pipes_count
153+
)
154+
if len(pipes) > per_org_cap:
137155
pipes = pipes[:per_org_cap]
138-
limits["pipes_truncated"] = True
139156
row: dict = {
140157
"id": org.get("id"),
141158
"name": org.get("name"),
142159
"pipes": pipes,
143160
}
161+
if pipes_count is not None:
162+
row["pipesCount"] = pipes_count
144163
if truncated:
145164
row["pipes_truncated"] = True
165+
limits["pipes_truncated"] = True
146166
organizations.append(row)
147167
return {"organizations": organizations, "search_limits": limits}
148168

packages/sdk/tests/services/pipefy/test_pipe_config_service.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ async def test_get_field_conditions_success(mock_settings):
922922
},
923923
],
924924
},
925-
"actions": [{"phaseFieldId": "pf-9"}],
925+
"actions": [{"actionId": "hide", "phaseFieldId": "pf-9"}],
926926
},
927927
],
928928
},
@@ -932,6 +932,7 @@ async def test_get_field_conditions_success(mock_settings):
932932

933933
query, variables = service.execute_query.call_args[0]
934934
assert query is GET_FIELD_CONDITIONS_QUERY
935+
assert "actionId" in GET_FIELD_CONDITIONS_QUERY.loc.source.body
935936
assert variables == {"phaseId": "404"}
936937
assert result == api_payload
937938

@@ -945,14 +946,15 @@ async def test_get_field_condition_success(mock_settings):
945946
"name": "Rule B",
946947
"phase": {"id": "88", "name": "Form"},
947948
"condition": {"expressions": []},
948-
"actions": [],
949+
"actions": [{"actionId": "hide", "phaseFieldId": "pf-9"}],
949950
},
950951
}
951952
service = _make_service(mock_settings, api_payload)
952953
result = await service.get_field_condition("fc-2")
953954

954955
query, variables = service.execute_query.call_args[0]
955956
assert query is GET_FIELD_CONDITION_QUERY
957+
assert "actionId" in GET_FIELD_CONDITION_QUERY.loc.source.body
956958
assert variables == {"id": "fc-2"}
957959
assert result == api_payload
958960

packages/sdk/tests/services/test_pipe_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,29 @@ async def test_search_pipes_truncates_per_org_when_over_cap(mock_settings):
425425
assert result["search_limits"]["pipes_truncated"] is True
426426

427427

428+
@pytest.mark.unit
429+
@pytest.mark.asyncio
430+
async def test_search_pipes_truncates_per_org_when_api_returns_fewer_than_pipes_count(
431+
mock_settings,
432+
):
433+
"""When API returns fewer pipes than Organization.pipesCount, flag as truncated."""
434+
pipes = [{"id": str(i), "name": f"P{i}"} for i in range(10)]
435+
mock_orgs = [{"id": "1", "name": "Org", "pipesCount": 271, "pipes": pipes}]
436+
service = _make_service(mock_settings, {"organizations": mock_orgs})
437+
result = await service.search_pipes(max_pipes_per_org=500)
438+
439+
assert len(result["organizations"][0]["pipes"]) == 10
440+
assert result["organizations"][0]["pipes_truncated"] is True
441+
assert result["organizations"][0]["pipesCount"] == 271
442+
assert result["search_limits"]["pipes_truncated"] is True
443+
444+
445+
@pytest.mark.unit
446+
def test_search_pipes_query_selects_pipes_count():
447+
printed = print_ast(SEARCH_PIPES_QUERY)
448+
assert "pipesCount" in printed
449+
450+
428451
@pytest.mark.unit
429452
def test_get_phase_fields_query_selects_internal_id_and_uuid():
430453
printed = print_ast(GET_PHASE_FIELDS_QUERY)

0 commit comments

Comments
 (0)