Skip to content

Commit b0f7a80

Browse files
authored
Codex/optimize and paginate read console tool (#511)
* Optimize read_console defaults and paging * Fix read_console truncate test expectations * Reduce read_console default count from 50 to 10 Further optimize token usage by reducing the default count from 50 to 10 entries. Even 10-20 messages with stack traces can be token-heavy. Added tests for default behavior and paging functionality. Updated tool description to document defaults and paging support. * Fix ReadConsoleTests to include log type messages The default types filter changed to ['error', 'warning'] (excluding 'log'), so tests using Debug.Log() need to explicitly request log messages. Also added format='detailed' to HandleCommand_Get_Works test since it accesses structured message fields. * Address CodeRabbit review feedback - Fix property naming consistency: next_cursor -> nextCursor (C# camelCase) - Remove redundant EndGettingEntries call from catch block (already in finally) - Extract stacktrace stripping to helper function (reduce duplication) - Fix test mock to match actual C# response structure (items, nextCursor, truncated, total) * perf: add early exit optimization for ReadConsole paging - Add early exit in paging loop once page is filled, avoiding iteration through remaining console entries (total becomes 'at least N') - Prefix unused mock arguments with underscores in test_read_console_truncate.py to suppress Ruff linter warnings * refactor: give pageSize independent default, clarify count semantics - Change pageSize resolution from 'pageSize ?? count ?? 50' to 'pageSize ?? 50' so pageSize has its own default independent of count - count now only serves as the non-paging limit - Add XML docs to GetConsoleEntries with clear parameter descriptions - Update Python tool annotations to document pageSize default (50) and clarify that count is ignored when paging
1 parent 96b81ca commit b0f7a80

File tree

5 files changed

+211
-40
lines changed

5 files changed

+211
-40
lines changed

MCPForUnity/Editor/Tools/ReadConsole.cs

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,17 @@ public static object HandleCommand(JObject @params)
165165
// Extract parameters for 'get'
166166
var types =
167167
(@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList()
168-
?? new List<string> { "error", "warning", "log" };
168+
?? new List<string> { "error", "warning" };
169169
int? count = @params["count"]?.ToObject<int?>();
170+
int? pageSize =
171+
@params["pageSize"]?.ToObject<int?>()
172+
?? @params["page_size"]?.ToObject<int?>();
173+
int? cursor = @params["cursor"]?.ToObject<int?>();
170174
string filterText = @params["filterText"]?.ToString();
171175
string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering
172-
string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
176+
string format = (@params["format"]?.ToString() ?? "plain").ToLower();
173177
bool includeStacktrace =
174-
@params["includeStacktrace"]?.ToObject<bool?>() ?? true;
178+
@params["includeStacktrace"]?.ToObject<bool?>() ?? false;
175179

176180
if (types.Contains("all"))
177181
{
@@ -186,7 +190,15 @@ public static object HandleCommand(JObject @params)
186190
// Need a way to get timestamp per log entry.
187191
}
188192

189-
return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
193+
return GetConsoleEntries(
194+
types,
195+
count,
196+
pageSize,
197+
cursor,
198+
filterText,
199+
format,
200+
includeStacktrace
201+
);
190202
}
191203
else
192204
{
@@ -218,16 +230,35 @@ private static object ClearConsole()
218230
}
219231
}
220232

233+
/// <summary>
234+
/// Retrieves console log entries with optional filtering and paging.
235+
/// </summary>
236+
/// <param name="types">Log types to include (e.g., "error", "warning", "log").</param>
237+
/// <param name="count">Maximum entries to return in non-paging mode. Ignored when paging is active.</param>
238+
/// <param name="pageSize">Number of entries per page. Defaults to 50 when omitted.</param>
239+
/// <param name="cursor">Starting index for paging (0-based). Defaults to 0.</param>
240+
/// <param name="filterText">Optional text filter (case-insensitive substring match).</param>
241+
/// <param name="format">Output format: "plain", "detailed", or "json".</param>
242+
/// <param name="includeStacktrace">Whether to include stack traces in the output.</param>
243+
/// <returns>A success response with entries, or an error response.</returns>
221244
private static object GetConsoleEntries(
222245
List<string> types,
223246
int? count,
247+
int? pageSize,
248+
int? cursor,
224249
string filterText,
225250
string format,
226251
bool includeStacktrace
227252
)
228253
{
229254
List<object> formattedEntries = new List<object>();
230255
int retrievedCount = 0;
256+
int totalMatches = 0;
257+
bool usePaging = pageSize.HasValue || cursor.HasValue;
258+
// pageSize defaults to 50 when omitted; count is the overall non-paging limit only
259+
int resolvedPageSize = Mathf.Clamp(pageSize ?? 50, 1, 500);
260+
int resolvedCursor = Mathf.Max(0, cursor ?? 0);
261+
int pageEndExclusive = resolvedCursor + resolvedPageSize;
231262

232263
try
233264
{
@@ -338,27 +369,39 @@ bool includeStacktrace
338369
break;
339370
}
340371

341-
formattedEntries.Add(formattedEntry);
342-
retrievedCount++;
372+
totalMatches++;
343373

344-
// Apply count limit (after filtering)
345-
if (count.HasValue && retrievedCount >= count.Value)
374+
if (usePaging)
375+
{
376+
if (totalMatches > resolvedCursor && totalMatches <= pageEndExclusive)
377+
{
378+
formattedEntries.Add(formattedEntry);
379+
retrievedCount++;
380+
}
381+
// Early exit: we've filled the page and only need to check if more exist
382+
else if (totalMatches > pageEndExclusive)
383+
{
384+
// We've passed the page; totalMatches now indicates truncation
385+
break;
386+
}
387+
}
388+
else
346389
{
347-
break;
390+
formattedEntries.Add(formattedEntry);
391+
retrievedCount++;
392+
393+
// Apply count limit (after filtering)
394+
if (count.HasValue && retrievedCount >= count.Value)
395+
{
396+
break;
397+
}
348398
}
349399
}
350400
}
351401
catch (Exception e)
352402
{
353403
Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
354-
// Ensure EndGettingEntries is called even if there's an error during iteration
355-
try
356-
{
357-
_endGettingEntriesMethod.Invoke(null, null);
358-
}
359-
catch
360-
{ /* Ignore nested exception */
361-
}
404+
// EndGettingEntries will be called in the finally block
362405
return new ErrorResponse($"Error retrieving log entries: {e.Message}");
363406
}
364407
finally
@@ -375,6 +418,26 @@ bool includeStacktrace
375418
}
376419
}
377420

421+
if (usePaging)
422+
{
423+
bool truncated = totalMatches > pageEndExclusive;
424+
string nextCursor = truncated ? pageEndExclusive.ToString() : null;
425+
var payload = new
426+
{
427+
cursor = resolvedCursor,
428+
pageSize = resolvedPageSize,
429+
nextCursor = nextCursor,
430+
truncated = truncated,
431+
total = totalMatches,
432+
items = formattedEntries,
433+
};
434+
435+
return new SuccessResponse(
436+
$"Retrieved {formattedEntries.Count} log entries.",
437+
payload
438+
);
439+
}
440+
378441
// Return the filtered and formatted list (might be empty)
379442
return new SuccessResponse(
380443
$"Retrieved {formattedEntries.Count} log entries.",

Server/src/services/tools/read_console.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@
1111
from transport.legacy.unity_connection import async_send_command_with_retry
1212

1313

14+
def _strip_stacktrace_from_list(items: list) -> None:
15+
"""Remove stacktrace fields from a list of log entries."""
16+
for item in items:
17+
if isinstance(item, dict) and "stacktrace" in item:
18+
item.pop("stacktrace", None)
19+
20+
1421
@mcp_for_unity_tool(
15-
description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
22+
description="Gets messages from or clears the Unity Editor console. Defaults to 10 most recent entries. Use page_size/cursor for paging. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
1623
)
1724
async def read_console(
1825
ctx: Context,
@@ -21,10 +28,12 @@ async def read_console(
2128
types: Annotated[list[Literal['error', 'warning',
2229
'log', 'all']], "Message types to get"] | None = None,
2330
count: Annotated[int | str,
24-
"Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
31+
"Max messages to return in non-paging mode (accepts int or string, e.g., 5 or '5'). Ignored when paging with page_size/cursor."] | None = None,
2532
filter_text: Annotated[str, "Text filter for messages"] | None = None,
2633
since_timestamp: Annotated[str,
2734
"Get messages after this timestamp (ISO 8601)"] | None = None,
35+
page_size: Annotated[int | str, "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
36+
cursor: Annotated[int | str, "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
2837
format: Annotated[Literal['plain', 'detailed',
2938
'json'], "Output format"] | None = None,
3039
include_stacktrace: Annotated[bool | str,
@@ -35,11 +44,13 @@ async def read_console(
3544
unity_instance = get_unity_instance_from_context(ctx)
3645
# Set defaults if values are None
3746
action = action if action is not None else 'get'
38-
types = types if types is not None else ['error', 'warning', 'log']
39-
format = format if format is not None else 'detailed'
47+
types = types if types is not None else ['error', 'warning']
48+
format = format if format is not None else 'plain'
4049
# Coerce booleans defensively (strings like 'true'/'false')
4150

42-
include_stacktrace = coerce_bool(include_stacktrace, default=True)
51+
include_stacktrace = coerce_bool(include_stacktrace, default=False)
52+
coerced_page_size = coerce_int(page_size, default=None)
53+
coerced_cursor = coerce_int(cursor, default=None)
4354

4455
# Normalize action if it's a string
4556
if isinstance(action, str):
@@ -56,7 +67,7 @@ async def read_console(
5667
count = coerce_int(count)
5768

5869
if action == "get" and count is None:
59-
count = 200
70+
count = 10
6071

6172
# Prepare parameters for the C# handler
6273
params_dict = {
@@ -65,6 +76,8 @@ async def read_console(
6576
"count": count,
6677
"filterText": filter_text,
6778
"sinceTimestamp": since_timestamp,
79+
"pageSize": coerced_page_size,
80+
"cursor": coerced_cursor,
6881
"format": format.lower() if isinstance(format, str) else format,
6982
"includeStacktrace": include_stacktrace
7083
}
@@ -83,16 +96,13 @@ async def read_console(
8396
# Strip stacktrace fields from returned lines if present
8497
try:
8598
data = resp.get("data")
86-
# Handle standard format: {"data": {"lines": [...]}}
87-
if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list):
88-
for line in data["lines"]:
89-
if isinstance(line, dict) and "stacktrace" in line:
90-
line.pop("stacktrace", None)
91-
# Handle legacy/direct list format if any
99+
if isinstance(data, dict):
100+
for key in ("lines", "items"):
101+
if key in data and isinstance(data[key], list):
102+
_strip_stacktrace_from_list(data[key])
103+
break
92104
elif isinstance(data, list):
93-
for line in data:
94-
if isinstance(line, dict) and "stacktrace" in line:
95-
line.pop("stacktrace", None)
105+
_strip_stacktrace_from_list(data)
96106
except Exception:
97107
pass
98108
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}

Server/tests/integration/test_read_console_truncate.py

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ async def test_read_console_full_default(monkeypatch):
3636

3737
captured = {}
3838

39-
async def fake_send(cmd, params, **kwargs):
39+
async def fake_send(_cmd, params, **_kwargs):
4040
captured["params"] = params
4141
return {
4242
"success": True,
@@ -54,10 +54,10 @@ async def fake_send(cmd, params, **kwargs):
5454
resp = await read_console(ctx=DummyContext(), action="get", count=10)
5555
assert resp == {
5656
"success": True,
57-
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
57+
"data": {"lines": [{"level": "error", "message": "oops", "time": "t"}]},
5858
}
5959
assert captured["params"]["count"] == 10
60-
assert captured["params"]["includeStacktrace"] is True
60+
assert captured["params"]["includeStacktrace"] is False
6161

6262

6363
@pytest.mark.asyncio
@@ -67,7 +67,7 @@ async def test_read_console_truncated(monkeypatch):
6767

6868
captured = {}
6969

70-
async def fake_send(cmd, params, **kwargs):
70+
async def fake_send(_cmd, params, **_kwargs):
7171
captured["params"] = params
7272
return {
7373
"success": True,
@@ -86,3 +86,100 @@ async def fake_send(cmd, params, **kwargs):
8686
assert resp == {"success": True, "data": {
8787
"lines": [{"level": "error", "message": "oops"}]}}
8888
assert captured["params"]["includeStacktrace"] is False
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_read_console_default_count(monkeypatch):
93+
"""Test that read_console defaults to count=10 when not specified."""
94+
tools = setup_tools()
95+
read_console = tools["read_console"]
96+
97+
captured = {}
98+
99+
async def fake_send(_cmd, params, **_kwargs):
100+
captured["params"] = params
101+
return {
102+
"success": True,
103+
"data": {"lines": [{"level": "error", "message": f"error {i}"} for i in range(15)]},
104+
}
105+
106+
# Patch the send_command_with_retry function in the tools module
107+
import services.tools.read_console
108+
monkeypatch.setattr(
109+
services.tools.read_console,
110+
"async_send_command_with_retry",
111+
fake_send,
112+
)
113+
114+
# Call without specifying count - should default to 10
115+
resp = await read_console(ctx=DummyContext(), action="get")
116+
assert resp["success"] is True
117+
# Verify that the default count of 10 was used
118+
assert captured["params"]["count"] == 10
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_read_console_paging(monkeypatch):
123+
"""Test that read_console paging works with page_size and cursor."""
124+
tools = setup_tools()
125+
read_console = tools["read_console"]
126+
127+
captured = {}
128+
129+
async def fake_send(_cmd, params, **_kwargs):
130+
captured["params"] = params
131+
# Simulate Unity returning paging info matching C# structure
132+
page_size = params.get("pageSize", 10)
133+
cursor = params.get("cursor", 0)
134+
# Simulate 25 total messages
135+
all_messages = [{"level": "error", "message": f"error {i}"} for i in range(25)]
136+
137+
# Return a page of results
138+
start = cursor
139+
end = min(start + page_size, len(all_messages))
140+
messages = all_messages[start:end]
141+
142+
return {
143+
"success": True,
144+
"data": {
145+
"items": messages,
146+
"cursor": cursor,
147+
"pageSize": page_size,
148+
"nextCursor": str(end) if end < len(all_messages) else None,
149+
"truncated": end < len(all_messages),
150+
"total": len(all_messages),
151+
},
152+
}
153+
154+
# Patch the send_command_with_retry function in the tools module
155+
import services.tools.read_console
156+
monkeypatch.setattr(
157+
services.tools.read_console,
158+
"async_send_command_with_retry",
159+
fake_send,
160+
)
161+
162+
# First page - get first 5 entries
163+
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=0)
164+
assert resp["success"] is True
165+
assert captured["params"]["pageSize"] == 5
166+
assert captured["params"]["cursor"] == 0
167+
assert len(resp["data"]["items"]) == 5
168+
assert resp["data"]["truncated"] is True
169+
assert resp["data"]["nextCursor"] == "5"
170+
assert resp["data"]["total"] == 25
171+
172+
# Second page - get next 5 entries
173+
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=5)
174+
assert resp["success"] is True
175+
assert captured["params"]["cursor"] == 5
176+
assert len(resp["data"]["items"]) == 5
177+
assert resp["data"]["truncated"] is True
178+
assert resp["data"]["nextCursor"] == "10"
179+
180+
# Last page - get remaining entries
181+
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=20)
182+
assert resp["success"] is True
183+
assert len(resp["data"]["items"]) == 5
184+
assert resp["data"]["truncated"] is False
185+
assert resp["data"]["nextCursor"] is None

Server/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)