Skip to content

Commit 8ba56b0

Browse files
committed
feat: convert to yaml to save 14.1% over json, >95.5% total measured token compression
1 parent f5129fe commit 8ba56b0

4 files changed

Lines changed: 155 additions & 12 deletions

File tree

plane_mcp/journey/tools/create_update.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from plane_mcp.client import get_plane_client_context
1212
from plane_mcp.journey.base import JourneyBase, mcp_error_boundary
13+
from plane_mcp.journey.yaml_formatter import with_yaml
1314
from plane_mcp.resolver import EntityResolver
1415
from plane_mcp.sanitize import sanitize_html
1516

@@ -96,9 +97,8 @@ def create_ticket(
9697
"priorities": ctx.get("priorities", []),
9798
"stickies": ctx.get("stickies", [])
9899
}
99-
import json
100-
return llm_content
101100

101+
return llm_content
102102
project_id = self.resolver.resolve_project(project_slug)
103103
client, workspace_slug = get_plane_client_context()
104104

@@ -275,9 +275,8 @@ def create_ticket(
275275
if project_slug.lower() == 'help':
276276
return raw_data
277277

278-
import json
279-
return raw_data
280278

279+
return raw_data
281280
create_ticket.__doc__ = """
282281
Create a new ticket with automatic resolution of labels and cycles.
283282
Missing labels or cycles will be automatically created.
@@ -292,9 +291,10 @@ def create_ticket(
292291
labels: List of label names.
293292
cycle_name: Name of the cycle to add this ticket to.
294293
"""
295-
create_ticket = mcp.tool()(mcp_error_boundary(create_ticket))
294+
create_ticket = mcp.tool()(with_yaml(mcp_error_boundary(create_ticket)))
296295

297296
@mcp.tool()
297+
@with_yaml
298298
@mcp_error_boundary
299299
def update_ticket(
300300
ticket_id: str,
@@ -325,5 +325,5 @@ def update_ticket(
325325
ticket_id, new_title, append_text, append_after_snippet, replace_text, replace_target_snippet, comment
326326
)
327327

328-
import json
328+
329329
return raw_data

plane_mcp/journey/tools/read.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from plane_mcp.client import get_plane_client_context
99
from plane_mcp.journey.base import JourneyBase, mcp_error_boundary
1010
from plane_mcp.journey.lod import LODProfile
11+
from plane_mcp.journey.yaml_formatter import with_yaml
1112
from plane_mcp.resolver import EntityResolver
1213

1314

@@ -258,9 +259,10 @@ def search_tickets(
258259
cursor: Pagination cursor for getting the next set of results.
259260
lod: Level of Detail profile ("summary", "standard", or "full"). Default is "standard".
260261
"""
261-
search_tickets = mcp.tool()(mcp_error_boundary(search_tickets))
262+
search_tickets = mcp.tool()(with_yaml(mcp_error_boundary(search_tickets)))
262263

263264
@mcp.tool()
265+
@with_yaml
264266
@mcp_error_boundary
265267
def read_ticket(
266268
ticket_id: str, lod: Literal["summary", "standard", "full"] = "standard", comments: bool = False

plane_mcp/journey/tools/workflow.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from plane_mcp.client import get_plane_client_context
1313
from plane_mcp.journey.base import JourneyBase, mcp_error_boundary
1414
from plane_mcp.journey.lod import LODProfile
15+
from plane_mcp.journey.yaml_formatter import with_yaml
1516
from plane_mcp.resolver import EntityResolutionError, EntityResolver
1617
from plane_mcp.sanitize import sanitize_html
1718

@@ -176,7 +177,7 @@ def transition_ticket(ticket_id: str, state_name: str) -> dict:
176177
resolver = EntityResolver(client, workspace_slug)
177178
journey = WorkflowJourney(resolver)
178179
raw_data = journey.transition_ticket(ticket_id, state_name)
179-
import json
180+
180181
return raw_data
181182

182183
transition_ticket.__doc__ = """
@@ -188,9 +189,10 @@ def transition_ticket(ticket_id: str, state_name: str) -> dict:
188189
ticket_id: The globally unique, human-readable identifier (e.g., ENG-123).
189190
state_name: The name of the state to transition to (e.g. 'In Progress').
190191
"""
191-
transition_ticket = mcp.tool()(mcp_error_boundary(transition_ticket))
192+
transition_ticket = mcp.tool()(with_yaml(mcp_error_boundary(transition_ticket)))
192193

193194
@mcp.tool()
195+
@with_yaml
194196
@mcp_error_boundary
195197
def begin_work(ticket_ids: list[str], cycle_name: str) -> dict:
196198
"""
@@ -207,10 +209,10 @@ def begin_work(ticket_ids: list[str], cycle_name: str) -> dict:
207209
resolver = EntityResolver(client, workspace_slug)
208210
journey = WorkflowJourney(resolver)
209211
raw_data = journey.begin_work(ticket_ids, cycle_name)
210-
import json
211-
return raw_data
212212

213+
return raw_data
213214
@mcp.tool()
215+
@with_yaml
214216
@mcp_error_boundary
215217
def complete_work(ticket_id: str, comment: str) -> dict:
216218
"""
@@ -227,5 +229,5 @@ def complete_work(ticket_id: str, comment: str) -> dict:
227229
resolver = EntityResolver(client, workspace_slug)
228230
journey = WorkflowJourney(resolver)
229231
raw_data = journey.complete_work(ticket_id, comment)
230-
import json
232+
231233
return raw_data
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""YAML output formatter for Journey tools.
2+
3+
Converts dict/list tool responses into a compact, LLM-friendly YAML format.
4+
5+
Design decisions:
6+
- Lists use flow-style (inline) to save tokens: [foo, bar, baz]
7+
- Multiline strings use literal block scalar (|) for readability
8+
- None, empty string, and empty list/dict values are stripped
9+
- 2-space indentation (YAML default)
10+
- No line-wrapping (width=1000) to prevent arbitrary breaks
11+
- Values containing colons are forced-quoted for parser safety
12+
- Values containing commas or spaces are auto-quoted by PyYAML
13+
14+
Missing-field semantics:
15+
Fields with None, "", [], or {} values are omitted from output.
16+
Consumers should treat a missing field as empty/unset.
17+
Falsy-but-meaningful values (False, 0) are preserved.
18+
19+
Behavioral contracts:
20+
- Input: any dict, list, or primitive returned by a Journey tool
21+
- Output: YAML string for dict/list inputs; passthrough for primitives
22+
- Failure mode: on any exception, returns original data unchanged (no data loss)
23+
"""
24+
25+
import logging
26+
from collections.abc import Callable
27+
from functools import wraps
28+
from typing import Any, TypeVar, cast
29+
30+
import yaml
31+
32+
logger = logging.getLogger(__name__)
33+
34+
T = TypeVar("T", bound=Callable[..., Any])
35+
36+
37+
class FlowList(list):
38+
"""A list subclass that YAML serializes in flow style: [a, b, c].
39+
40+
PyYAML's SafeDumper will render normal lists as block-style (- item).
41+
By registering a custom representer for this subclass, we get inline
42+
arrays that are more token-efficient for LLM consumption.
43+
"""
44+
45+
pass
46+
47+
48+
def _flow_list_representer(dumper: yaml.SafeDumper, data: FlowList) -> yaml.Node:
49+
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
50+
51+
52+
def _str_presenter(dumper: yaml.SafeDumper, data: str) -> yaml.Node:
53+
"""Use literal block scalar for multiline strings.
54+
55+
Forces single-quoting for values containing colons to prevent
56+
YAML parser ambiguity (e.g. cursor values like '3:-1:1').
57+
"""
58+
if len(data.splitlines()) > 1:
59+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
60+
if ":" in data:
61+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
62+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
63+
64+
65+
yaml.SafeDumper.add_representer(FlowList, _flow_list_representer)
66+
yaml.SafeDumper.add_representer(str, _str_presenter)
67+
68+
69+
def _clean_and_convert(obj: Any) -> Any:
70+
"""Recursively strip empty values and convert lists to FlowList.
71+
72+
Removes: None, "", [], {}
73+
Preserves: False, 0, and other falsy-but-meaningful values.
74+
"""
75+
if isinstance(obj, dict):
76+
cleaned = {}
77+
for k, v in obj.items():
78+
val = _clean_and_convert(v)
79+
if val not in (None, "", [], {}):
80+
cleaned[k] = val
81+
return cleaned
82+
elif isinstance(obj, list):
83+
cleaned_list = [_clean_and_convert(v) for v in obj]
84+
is_primitive = all(not isinstance(item, (dict, list)) for item in cleaned_list)
85+
if is_primitive:
86+
return FlowList(cleaned_list)
87+
return cleaned_list
88+
return obj
89+
90+
91+
def format_as_yaml(raw_data: Any) -> Any:
92+
"""Convert a dict or list to compact YAML string.
93+
94+
Args:
95+
raw_data: Tool output (typically dict or list).
96+
97+
Returns:
98+
YAML string if input is dict/list, original value otherwise.
99+
On formatting failure, returns original data unchanged.
100+
"""
101+
if not isinstance(raw_data, (dict, list)):
102+
return raw_data
103+
104+
try:
105+
cleaned_data = _clean_and_convert(raw_data)
106+
yaml_str = yaml.safe_dump(
107+
cleaned_data,
108+
allow_unicode=True,
109+
sort_keys=False,
110+
default_flow_style=False,
111+
width=1000,
112+
)
113+
return yaml_str.strip()
114+
except Exception as e:
115+
logger.debug("YAML formatting failed: %s", e)
116+
return raw_data
117+
118+
119+
def with_yaml(func: T) -> T:
120+
"""Decorator that converts a tool's dict/list return value to YAML.
121+
122+
Must be applied OUTSIDE (above) mcp_error_boundary so that error
123+
dicts also get YAML-formatted. Stack order:
124+
125+
@mcp.tool()
126+
@with_yaml
127+
@mcp_error_boundary
128+
def my_tool(...) -> dict: ...
129+
"""
130+
131+
@wraps(func)
132+
def wrapper(*args: Any, **kwargs: Any) -> Any:
133+
result = func(*args, **kwargs)
134+
return format_as_yaml(result)
135+
136+
if 'return' in wrapper.__annotations__:
137+
wrapper.__annotations__['return'] = Any
138+
139+
return cast(T, wrapper)

0 commit comments

Comments
 (0)