Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions MCPForUnity/Editor/Tools/ReadConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,17 @@ public static object HandleCommand(JObject @params)
// Extract parameters for 'get'
var types =
(@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList()
?? new List<string> { "error", "warning", "log" };
?? new List<string> { "error", "warning" };
int? count = @params["count"]?.ToObject<int?>();
int? pageSize =
@params["pageSize"]?.ToObject<int?>()
?? @params["page_size"]?.ToObject<int?>();
int? cursor = @params["cursor"]?.ToObject<int?>();
string filterText = @params["filterText"]?.ToString();
string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering
string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
string format = (@params["format"]?.ToString() ?? "plain").ToLower();
bool includeStacktrace =
@params["includeStacktrace"]?.ToObject<bool?>() ?? true;
@params["includeStacktrace"]?.ToObject<bool?>() ?? false;

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

return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
return GetConsoleEntries(
types,
count,
pageSize,
cursor,
filterText,
format,
includeStacktrace
);
}
else
{
Expand Down Expand Up @@ -218,16 +230,35 @@ private static object ClearConsole()
}
}

/// <summary>
/// Retrieves console log entries with optional filtering and paging.
/// </summary>
/// <param name="types">Log types to include (e.g., "error", "warning", "log").</param>
/// <param name="count">Maximum entries to return in non-paging mode. Ignored when paging is active.</param>
/// <param name="pageSize">Number of entries per page. Defaults to 50 when omitted.</param>
/// <param name="cursor">Starting index for paging (0-based). Defaults to 0.</param>
/// <param name="filterText">Optional text filter (case-insensitive substring match).</param>
/// <param name="format">Output format: "plain", "detailed", or "json".</param>
/// <param name="includeStacktrace">Whether to include stack traces in the output.</param>
/// <returns>A success response with entries, or an error response.</returns>
private static object GetConsoleEntries(
List<string> types,
int? count,
int? pageSize,
int? cursor,
string filterText,
string format,
bool includeStacktrace
)
{
List<object> formattedEntries = new List<object>();
int retrievedCount = 0;
int totalMatches = 0;
bool usePaging = pageSize.HasValue || cursor.HasValue;
// pageSize defaults to 50 when omitted; count is the overall non-paging limit only
int resolvedPageSize = Mathf.Clamp(pageSize ?? 50, 1, 500);
int resolvedCursor = Mathf.Max(0, cursor ?? 0);
int pageEndExclusive = resolvedCursor + resolvedPageSize;

try
{
Expand Down Expand Up @@ -338,27 +369,39 @@ bool includeStacktrace
break;
}

formattedEntries.Add(formattedEntry);
retrievedCount++;
totalMatches++;

// Apply count limit (after filtering)
if (count.HasValue && retrievedCount >= count.Value)
if (usePaging)
{
if (totalMatches > resolvedCursor && totalMatches <= pageEndExclusive)
{
formattedEntries.Add(formattedEntry);
retrievedCount++;
}
// Early exit: we've filled the page and only need to check if more exist
else if (totalMatches > pageEndExclusive)
{
// We've passed the page; totalMatches now indicates truncation
break;
}
}
else
{
break;
formattedEntries.Add(formattedEntry);
retrievedCount++;

// Apply count limit (after filtering)
if (count.HasValue && retrievedCount >= count.Value)
{
break;
}
}
}
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
// Ensure EndGettingEntries is called even if there's an error during iteration
try
{
_endGettingEntriesMethod.Invoke(null, null);
}
catch
{ /* Ignore nested exception */
}
// EndGettingEntries will be called in the finally block
return new ErrorResponse($"Error retrieving log entries: {e.Message}");
}
finally
Expand All @@ -375,6 +418,26 @@ bool includeStacktrace
}
}

if (usePaging)
{
bool truncated = totalMatches > pageEndExclusive;
string nextCursor = truncated ? pageEndExclusive.ToString() : null;
var payload = new
{
cursor = resolvedCursor,
pageSize = resolvedPageSize,
nextCursor = nextCursor,
truncated = truncated,
total = totalMatches,
items = formattedEntries,
};

return new SuccessResponse(
$"Retrieved {formattedEntries.Count} log entries.",
payload
);
}

// Return the filtered and formatted list (might be empty)
return new SuccessResponse(
$"Retrieved {formattedEntries.Count} log entries.",
Expand Down
40 changes: 25 additions & 15 deletions Server/src/services/tools/read_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@
from transport.legacy.unity_connection import async_send_command_with_retry


def _strip_stacktrace_from_list(items: list) -> None:
"""Remove stacktrace fields from a list of log entries."""
for item in items:
if isinstance(item, dict) and "stacktrace" in item:
item.pop("stacktrace", None)


@mcp_for_unity_tool(
description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
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')."
)
async def read_console(
ctx: Context,
Expand All @@ -21,10 +28,12 @@ async def read_console(
types: Annotated[list[Literal['error', 'warning',
'log', 'all']], "Message types to get"] | None = None,
count: Annotated[int | str,
"Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
"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,
filter_text: Annotated[str, "Text filter for messages"] | None = None,
since_timestamp: Annotated[str,
"Get messages after this timestamp (ISO 8601)"] | None = None,
page_size: Annotated[int | str, "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None,
cursor: Annotated[int | str, "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None,
Comment on lines +35 to +36
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clarify where paging defaults are applied.

The annotations state "Defaults to 50 when omitted" and "Defaults to 0", but the Python code passes None to the Unity handler (lines 52-53). The actual defaults are applied in the C# handler (ReadConsole.cs lines 259-260), not here.

Consider revising to: "Page size for paginated reads. Unity handler defaults to 50 if omitted." and "Paging cursor (0-based offset). Unity handler defaults to 0 if omitted."

🤖 Prompt for AI Agents
In Server/src/services/tools/read_console.py around lines 35 to 36, the
parameter annotations incorrectly imply Python-level defaults (50 and 0) while
the actual defaults are applied in the Unity C# handler; update the annotation
strings to state that these defaults are applied by the Unity handler (e.g.,
"Page size for paginated reads. Unity handler defaults to 50 if omitted." and
"Paging cursor (0-based offset). Unity handler defaults to 0 if omitted.") so
callers understand where defaults are enforced and no code behavior needs to
change.

format: Annotated[Literal['plain', 'detailed',
'json'], "Output format"] | None = None,
include_stacktrace: Annotated[bool | str,
Expand All @@ -35,11 +44,13 @@ async def read_console(
unity_instance = get_unity_instance_from_context(ctx)
# Set defaults if values are None
action = action if action is not None else 'get'
types = types if types is not None else ['error', 'warning', 'log']
format = format if format is not None else 'detailed'
types = types if types is not None else ['error', 'warning']
format = format if format is not None else 'plain'
# Coerce booleans defensively (strings like 'true'/'false')

include_stacktrace = coerce_bool(include_stacktrace, default=True)
include_stacktrace = coerce_bool(include_stacktrace, default=False)
coerced_page_size = coerce_int(page_size, default=None)
coerced_cursor = coerce_int(cursor, default=None)

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

if action == "get" and count is None:
count = 200
count = 10

# Prepare parameters for the C# handler
params_dict = {
Expand All @@ -65,6 +76,8 @@ async def read_console(
"count": count,
"filterText": filter_text,
"sinceTimestamp": since_timestamp,
"pageSize": coerced_page_size,
"cursor": coerced_cursor,
"format": format.lower() if isinstance(format, str) else format,
"includeStacktrace": include_stacktrace
}
Expand All @@ -83,16 +96,13 @@ async def read_console(
# Strip stacktrace fields from returned lines if present
try:
data = resp.get("data")
# Handle standard format: {"data": {"lines": [...]}}
if isinstance(data, dict) and "lines" in data and isinstance(data["lines"], list):
for line in data["lines"]:
if isinstance(line, dict) and "stacktrace" in line:
line.pop("stacktrace", None)
# Handle legacy/direct list format if any
if isinstance(data, dict):
for key in ("lines", "items"):
if key in data and isinstance(data[key], list):
_strip_stacktrace_from_list(data[key])
break
elif isinstance(data, list):
for line in data:
if isinstance(line, dict) and "stacktrace" in line:
line.pop("stacktrace", None)
_strip_stacktrace_from_list(data)
except Exception:
pass
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
105 changes: 101 additions & 4 deletions Server/tests/integration/test_read_console_truncate.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def test_read_console_full_default(monkeypatch):

captured = {}

async def fake_send(cmd, params, **kwargs):
async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
return {
"success": True,
Expand All @@ -54,10 +54,10 @@ async def fake_send(cmd, params, **kwargs):
resp = await read_console(ctx=DummyContext(), action="get", count=10)
assert resp == {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
"data": {"lines": [{"level": "error", "message": "oops", "time": "t"}]},
}
assert captured["params"]["count"] == 10
assert captured["params"]["includeStacktrace"] is True
assert captured["params"]["includeStacktrace"] is False


@pytest.mark.asyncio
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (testing): Paging integration test payload is now out of sync with the Unity handler’s paging response shape

test_read_console_paging still expects a lines/hasMore payload, but the C# handler now returns paging data as items, cursor, next_cursor, truncated, and total. Since fake_send is acting as Unity, it should be updated to mirror this new shape and the assertions adjusted to verify the new fields instead of hasMore, so the test accurately validates the real paging contract and can catch regressions.

Expand All @@ -67,7 +67,7 @@ async def test_read_console_truncated(monkeypatch):

captured = {}

async def fake_send(cmd, params, **kwargs):
async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
return {
"success": True,
Expand All @@ -86,3 +86,100 @@ async def fake_send(cmd, params, **kwargs):
assert resp == {"success": True, "data": {
"lines": [{"level": "error", "message": "oops"}]}}
assert captured["params"]["includeStacktrace"] is False


@pytest.mark.asyncio
async def test_read_console_default_count(monkeypatch):
"""Test that read_console defaults to count=10 when not specified."""
tools = setup_tools()
read_console = tools["read_console"]

captured = {}

async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
return {
"success": True,
"data": {"lines": [{"level": "error", "message": f"error {i}"} for i in range(15)]},
}

# Patch the send_command_with_retry function in the tools module
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send,
)

# Call without specifying count - should default to 10
resp = await read_console(ctx=DummyContext(), action="get")
assert resp["success"] is True
# Verify that the default count of 10 was used
assert captured["params"]["count"] == 10


@pytest.mark.asyncio
async def test_read_console_paging(monkeypatch):
"""Test that read_console paging works with page_size and cursor."""
tools = setup_tools()
read_console = tools["read_console"]

captured = {}

async def fake_send(_cmd, params, **_kwargs):
captured["params"] = params
# Simulate Unity returning paging info matching C# structure
page_size = params.get("pageSize", 10)
cursor = params.get("cursor", 0)
# Simulate 25 total messages
all_messages = [{"level": "error", "message": f"error {i}"} for i in range(25)]

# Return a page of results
start = cursor
end = min(start + page_size, len(all_messages))
messages = all_messages[start:end]

return {
"success": True,
"data": {
"items": messages,
"cursor": cursor,
"pageSize": page_size,
"nextCursor": str(end) if end < len(all_messages) else None,
"truncated": end < len(all_messages),
"total": len(all_messages),
},
}

# Patch the send_command_with_retry function in the tools module
import services.tools.read_console
monkeypatch.setattr(
services.tools.read_console,
"async_send_command_with_retry",
fake_send,
)

# First page - get first 5 entries
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=0)
assert resp["success"] is True
assert captured["params"]["pageSize"] == 5
assert captured["params"]["cursor"] == 0
assert len(resp["data"]["items"]) == 5
assert resp["data"]["truncated"] is True
assert resp["data"]["nextCursor"] == "5"
assert resp["data"]["total"] == 25

# Second page - get next 5 entries
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=5)
assert resp["success"] is True
assert captured["params"]["cursor"] == 5
assert len(resp["data"]["items"]) == 5
assert resp["data"]["truncated"] is True
assert resp["data"]["nextCursor"] == "10"

# Last page - get remaining entries
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=20)
assert resp["success"] is True
assert len(resp["data"]["items"]) == 5
assert resp["data"]["truncated"] is False
assert resp["data"]["nextCursor"] is None
2 changes: 1 addition & 1 deletion Server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading