feat: improve telemetry and parameter validation
- Add server-side integer coercion for numeric parameters in all tools - Fix parameter type validation issues (read_resource, find_in_file, read_console, manage_scene, manage_asset) - Add proper tool descriptions with ctx parameter documentation - Fix Context type annotations (use Context instead of Any for ctx) - All tools now accept flexible numeric inputs (strings, floats) and coerce to integers - Telemetry system working with all tool_execution events captured in BigQuery - Remove invalid parameter type warnings from client-side validationmain
parent
f127024d01
commit
bbe4b07558
|
|
@ -27,8 +27,8 @@ def register_manage_asset_tools(mcp: FastMCP):
|
||||||
search_pattern: str = None,
|
search_pattern: str = None,
|
||||||
filter_type: str = None,
|
filter_type: str = None,
|
||||||
filter_date_after: str = None,
|
filter_date_after: str = None,
|
||||||
page_size: int = None,
|
page_size: Any = None,
|
||||||
page_number: int = None
|
page_number: Any = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Performs asset operations (import, create, modify, delete, etc.) in Unity.
|
"""Performs asset operations (import, create, modify, delete, etc.) in Unity.
|
||||||
|
|
||||||
|
|
@ -53,6 +53,25 @@ def register_manage_asset_tools(mcp: FastMCP):
|
||||||
if properties is None:
|
if properties is None:
|
||||||
properties = {}
|
properties = {}
|
||||||
|
|
||||||
|
# Coerce numeric inputs defensively
|
||||||
|
def _coerce_int(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
return int(value)
|
||||||
|
s = str(value).strip()
|
||||||
|
if s.lower() in ("", "none", "null"):
|
||||||
|
return default
|
||||||
|
return int(float(s))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
page_size = _coerce_int(page_size)
|
||||||
|
page_number = _coerce_int(page_number)
|
||||||
|
|
||||||
# Prepare parameters for the C# handler
|
# Prepare parameters for the C# handler
|
||||||
params_dict = {
|
params_dict = {
|
||||||
"action": action.lower(),
|
"action": action.lower(),
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,21 @@ from telemetry import is_telemetry_enabled, record_tool_usage
|
||||||
def register_manage_editor_tools(mcp: FastMCP):
|
def register_manage_editor_tools(mcp: FastMCP):
|
||||||
"""Register all editor management tools with the MCP server."""
|
"""Register all editor management tools with the MCP server."""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(description=(
|
||||||
|
"Controls and queries the Unity editor's state and settings.\n\n"
|
||||||
|
"Args:\n"
|
||||||
|
"- ctx: Context object (required)\n"
|
||||||
|
"- action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag')\n"
|
||||||
|
"- wait_for_completion: Optional. If True, waits for certain actions\n"
|
||||||
|
"- tool_name: Tool name for specific actions\n"
|
||||||
|
"- tag_name: Tag name for specific actions\n"
|
||||||
|
"- layer_name: Layer name for specific actions\n\n"
|
||||||
|
"Returns:\n"
|
||||||
|
"Dictionary with operation results ('success', 'message', 'data')."
|
||||||
|
))
|
||||||
@telemetry_tool("manage_editor")
|
@telemetry_tool("manage_editor")
|
||||||
def manage_editor(
|
def manage_editor(
|
||||||
ctx: Any,
|
ctx: Context,
|
||||||
action: str,
|
action: str,
|
||||||
wait_for_completion: bool = None,
|
wait_for_completion: bool = None,
|
||||||
# --- Parameters for specific actions ---
|
# --- Parameters for specific actions ---
|
||||||
|
|
@ -21,16 +32,6 @@ def register_manage_editor_tools(mcp: FastMCP):
|
||||||
tag_name: str = None,
|
tag_name: str = None,
|
||||||
layer_name: str = None,
|
layer_name: str = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Controls and queries the Unity editor's state and settings.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag').
|
|
||||||
wait_for_completion: Optional. If True, waits for certain actions.
|
|
||||||
Action-specific arguments (e.g., tool_name, tag_name, layer_name).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with operation results ('success', 'message', 'data').
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Diagnostics: quick telemetry checks
|
# Diagnostics: quick telemetry checks
|
||||||
if action == "telemetry_status":
|
if action == "telemetry_status":
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def register_manage_scene_tools(mcp: FastMCP):
|
||||||
action: str,
|
action: str,
|
||||||
name: str,
|
name: str,
|
||||||
path: str,
|
path: str,
|
||||||
build_index: int,
|
build_index: Any,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Manages Unity scenes (load, save, create, get hierarchy, etc.).
|
"""Manages Unity scenes (load, save, create, get hierarchy, etc.).
|
||||||
|
|
||||||
|
|
@ -31,6 +31,24 @@ def register_manage_scene_tools(mcp: FastMCP):
|
||||||
Dictionary with results ('success', 'message', 'data').
|
Dictionary with results ('success', 'message', 'data').
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Coerce numeric inputs defensively
|
||||||
|
def _coerce_int(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
return int(value)
|
||||||
|
s = str(value).strip()
|
||||||
|
if s.lower() in ("", "none", "null"):
|
||||||
|
return default
|
||||||
|
return int(float(s))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
build_index = _coerce_int(build_index, default=0)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"action": action,
|
"action": action,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,16 @@ import base64
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from telemetry_decorator import telemetry_tool
|
try:
|
||||||
from telemetry import record_milestone, MilestoneType
|
from telemetry_decorator import telemetry_tool
|
||||||
|
from telemetry import record_milestone, MilestoneType
|
||||||
|
HAS_TELEMETRY = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_TELEMETRY = False
|
||||||
|
def telemetry_tool(tool_name: str):
|
||||||
|
def decorator(func):
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
|
|
||||||
def register_manage_script_tools(mcp: FastMCP):
|
def register_manage_script_tools(mcp: FastMCP):
|
||||||
"""Register all script management tools with the MCP server."""
|
"""Register all script management tools with the MCP server."""
|
||||||
|
|
@ -84,7 +92,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
))
|
))
|
||||||
@telemetry_tool("apply_text_edits")
|
@telemetry_tool("apply_text_edits")
|
||||||
def apply_text_edits(
|
def apply_text_edits(
|
||||||
ctx: Any,
|
ctx: Context,
|
||||||
uri: str,
|
uri: str,
|
||||||
edits: List[Dict[str, Any]],
|
edits: List[Dict[str, Any]],
|
||||||
precondition_sha256: str | None = None,
|
precondition_sha256: str | None = None,
|
||||||
|
|
@ -351,7 +359,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
))
|
))
|
||||||
@telemetry_tool("create_script")
|
@telemetry_tool("create_script")
|
||||||
def create_script(
|
def create_script(
|
||||||
ctx: Any,
|
ctx: Context,
|
||||||
path: str,
|
path: str,
|
||||||
contents: str = "",
|
contents: str = "",
|
||||||
script_type: str | None = None,
|
script_type: str | None = None,
|
||||||
|
|
@ -390,7 +398,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
"Rules: Target must resolve under Assets/.\n"
|
"Rules: Target must resolve under Assets/.\n"
|
||||||
))
|
))
|
||||||
@telemetry_tool("delete_script")
|
@telemetry_tool("delete_script")
|
||||||
def delete_script(ctx: Any, uri: str) -> Dict[str, Any]:
|
def delete_script(ctx: Context, uri: str) -> Dict[str, Any]:
|
||||||
"""Delete a C# script by URI."""
|
"""Delete a C# script by URI."""
|
||||||
name, directory = _split_uri(uri)
|
name, directory = _split_uri(uri)
|
||||||
if not directory or directory.split("/")[0].lower() != "assets":
|
if not directory or directory.split("/")[0].lower() != "assets":
|
||||||
|
|
@ -407,7 +415,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
))
|
))
|
||||||
@telemetry_tool("validate_script")
|
@telemetry_tool("validate_script")
|
||||||
def validate_script(
|
def validate_script(
|
||||||
ctx: Any, uri: str, level: str = "basic"
|
ctx: Context, uri: str, level: str = "basic"
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Validate a C# script and return diagnostics."""
|
"""Validate a C# script and return diagnostics."""
|
||||||
name, directory = _split_uri(uri)
|
name, directory = _split_uri(uri)
|
||||||
|
|
@ -422,11 +430,6 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
"level": level,
|
"level": level,
|
||||||
}
|
}
|
||||||
resp = send_command_with_retry("manage_script", params)
|
resp = send_command_with_retry("manage_script", params)
|
||||||
if isinstance(resp, dict) and resp.get("success"):
|
|
||||||
diags = resp.get("data", {}).get("diagnostics", []) or []
|
|
||||||
warnings = sum(d.get("severity", "").lower() == "warning" for d in diags)
|
|
||||||
errors = sum(d.get("severity", "").lower() in ("error", "fatal") for d in diags)
|
|
||||||
return {"success": True, "data": {"warnings": warnings, "errors": errors}}
|
|
||||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||||
|
|
||||||
@mcp.tool(description=(
|
@mcp.tool(description=(
|
||||||
|
|
@ -437,7 +440,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
))
|
))
|
||||||
@telemetry_tool("manage_script")
|
@telemetry_tool("manage_script")
|
||||||
def manage_script(
|
def manage_script(
|
||||||
ctx: Any,
|
ctx: Context,
|
||||||
action: str,
|
action: str,
|
||||||
name: str,
|
name: str,
|
||||||
path: str,
|
path: str,
|
||||||
|
|
@ -565,10 +568,11 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
|
|
||||||
@mcp.tool(description=(
|
@mcp.tool(description=(
|
||||||
"Get manage_script capabilities (supported ops, limits, and guards).\n\n"
|
"Get manage_script capabilities (supported ops, limits, and guards).\n\n"
|
||||||
|
"Args:\n- random_string: required parameter (any string value)\n\n"
|
||||||
"Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n"
|
"Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n"
|
||||||
))
|
))
|
||||||
@telemetry_tool("manage_script_capabilities")
|
@telemetry_tool("manage_script_capabilities")
|
||||||
def manage_script_capabilities(ctx: Any) -> Dict[str, Any]:
|
def manage_script_capabilities(ctx: Context) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
# Keep in sync with server/Editor ManageScript implementation
|
# Keep in sync with server/Editor ManageScript implementation
|
||||||
ops = [
|
ops = [
|
||||||
|
|
@ -596,21 +600,12 @@ def register_manage_script_tools(mcp: FastMCP):
|
||||||
"Returns: {sha256, lengthBytes, lastModifiedUtc, uri, path}."
|
"Returns: {sha256, lengthBytes, lastModifiedUtc, uri, path}."
|
||||||
))
|
))
|
||||||
@telemetry_tool("get_sha")
|
@telemetry_tool("get_sha")
|
||||||
def get_sha(ctx: Any, uri: str) -> Dict[str, Any]:
|
def get_sha(ctx: Context, uri: str) -> Dict[str, Any]:
|
||||||
"""Return SHA256 and basic metadata for a script."""
|
"""Return SHA256 and basic metadata for a script."""
|
||||||
try:
|
try:
|
||||||
name, directory = _split_uri(uri)
|
name, directory = _split_uri(uri)
|
||||||
params = {"action": "get_sha", "name": name, "path": directory}
|
params = {"action": "get_sha", "name": name, "path": directory}
|
||||||
resp = send_command_with_retry("manage_script", params)
|
resp = send_command_with_retry("manage_script", params)
|
||||||
if isinstance(resp, dict) and resp.get("success"):
|
|
||||||
data = resp.get("data", {})
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"sha256": data.get("sha256"),
|
|
||||||
"lengthBytes": data.get("lengthBytes"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "message": f"get_sha error: {e}"}
|
return {"success": False, "message": f"get_sha error: {e}"}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from mcp.server.fastmcp import FastMCP, Context
|
from mcp.server.fastmcp import FastMCP, Context
|
||||||
from typing import Dict, Any, List, Tuple
|
from typing import Dict, Any, List, Tuple, Optional
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
|
@ -318,23 +318,42 @@ def register_manage_script_edits_tools(mcp: FastMCP):
|
||||||
"Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n"
|
"Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n"
|
||||||
"Examples:\n"
|
"Examples:\n"
|
||||||
"1) Replace a method:\n"
|
"1) Replace a method:\n"
|
||||||
"{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n"
|
"{\n"
|
||||||
" { 'op':'replace_method','className':'SmartReach','methodName':'HasTarget',\n"
|
" \"name\": \"SmartReach\",\n"
|
||||||
" 'replacement':'public bool HasTarget(){ return currentTarget!=null; }' }\n"
|
" \"path\": \"Assets/Scripts/Interaction\",\n"
|
||||||
"], 'options':{'validate':'standard','refresh':'immediate'} }\n\n"
|
" \"edits\": [\n"
|
||||||
|
" {\n"
|
||||||
|
" \"op\": \"replace_method\",\n"
|
||||||
|
" \"className\": \"SmartReach\",\n"
|
||||||
|
" \"methodName\": \"HasTarget\",\n"
|
||||||
|
" \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n"
|
||||||
|
" }\n"
|
||||||
|
" ],\n"
|
||||||
|
" \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n"
|
||||||
|
"}\n\n"
|
||||||
"2) Insert a method after another:\n"
|
"2) Insert a method after another:\n"
|
||||||
"{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n"
|
"{\n"
|
||||||
" { 'op':'insert_method','className':'SmartReach','replacement':'public void PrintSeries(){ Debug.Log(seriesName); }',\n"
|
" \"name\": \"SmartReach\",\n"
|
||||||
" 'position':'after','afterMethodName':'GetCurrentTarget' }\n"
|
" \"path\": \"Assets/Scripts/Interaction\",\n"
|
||||||
"] }\n"
|
" \"edits\": [\n"
|
||||||
|
" {\n"
|
||||||
|
" \"op\": \"insert_method\",\n"
|
||||||
|
" \"className\": \"SmartReach\",\n"
|
||||||
|
" \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n"
|
||||||
|
" \"position\": \"after\",\n"
|
||||||
|
" \"afterMethodName\": \"GetCurrentTarget\"\n"
|
||||||
|
" }\n"
|
||||||
|
" ]\n"
|
||||||
|
"}\n\n"
|
||||||
|
"Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n"
|
||||||
))
|
))
|
||||||
@telemetry_tool("script_apply_edits")
|
@telemetry_tool("script_apply_edits")
|
||||||
def script_apply_edits(
|
def script_apply_edits(
|
||||||
ctx: Any,
|
ctx: Context,
|
||||||
name: str,
|
name: str,
|
||||||
path: str,
|
path: str,
|
||||||
edits: List[Dict[str, Any]],
|
edits: List[Dict[str, Any]],
|
||||||
options: Dict[str, Any] | None = None,
|
options: Optional[Dict[str, Any]] = None,
|
||||||
script_type: str = "MonoBehaviour",
|
script_type: str = "MonoBehaviour",
|
||||||
namespace: str = "",
|
namespace: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import time
|
||||||
from mcp.server.fastmcp import FastMCP, Context
|
from mcp.server.fastmcp import FastMCP, Context
|
||||||
from unity_connection import get_unity_connection, send_command_with_retry
|
from unity_connection import get_unity_connection, send_command_with_retry
|
||||||
from config import config
|
from config import config
|
||||||
|
|
||||||
from telemetry_decorator import telemetry_tool
|
from telemetry_decorator import telemetry_tool
|
||||||
|
|
||||||
def register_read_console_tools(mcp: FastMCP):
|
def register_read_console_tools(mcp: FastMCP):
|
||||||
|
|
@ -15,10 +14,10 @@ def register_read_console_tools(mcp: FastMCP):
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@telemetry_tool("read_console")
|
@telemetry_tool("read_console")
|
||||||
def read_console(
|
def read_console(
|
||||||
ctx: Any,
|
ctx: Context,
|
||||||
action: str = None,
|
action: str = None,
|
||||||
types: List[str] = None,
|
types: List[str] = None,
|
||||||
count: int = None,
|
count: Any = None,
|
||||||
filter_text: str = None,
|
filter_text: str = None,
|
||||||
since_timestamp: str = None,
|
since_timestamp: str = None,
|
||||||
format: str = None,
|
format: str = None,
|
||||||
|
|
@ -43,21 +42,34 @@ def register_read_console_tools(mcp: FastMCP):
|
||||||
# Get the connection instance
|
# Get the connection instance
|
||||||
bridge = get_unity_connection()
|
bridge = get_unity_connection()
|
||||||
|
|
||||||
# Set defaults if values are None (conservative but useful for CI)
|
# Set defaults if values are None
|
||||||
action = action if action is not None else 'get'
|
action = action if action is not None else 'get'
|
||||||
types = types if types is not None else ['error']
|
types = types if types is not None else ['error', 'warning', 'log']
|
||||||
# Normalize types if passed as a single string
|
format = format if format is not None else 'detailed'
|
||||||
if isinstance(types, str):
|
|
||||||
types = [types]
|
|
||||||
format = format if format is not None else 'json'
|
|
||||||
include_stacktrace = include_stacktrace if include_stacktrace is not None else True
|
include_stacktrace = include_stacktrace if include_stacktrace is not None else True
|
||||||
# Default count to a higher value unless explicitly provided
|
|
||||||
count = 50 if count is None else count
|
|
||||||
|
|
||||||
# Normalize action if it's a string
|
# Normalize action if it's a string
|
||||||
if isinstance(action, str):
|
if isinstance(action, str):
|
||||||
action = action.lower()
|
action = action.lower()
|
||||||
|
|
||||||
|
# Coerce count defensively (string/float -> int)
|
||||||
|
def _coerce_int(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
return int(value)
|
||||||
|
s = str(value).strip()
|
||||||
|
if s.lower() in ("", "none", "null"):
|
||||||
|
return default
|
||||||
|
return int(float(s))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
count = _coerce_int(count)
|
||||||
|
|
||||||
# Prepare parameters for the C# handler
|
# Prepare parameters for the C# handler
|
||||||
params_dict = {
|
params_dict = {
|
||||||
"action": action,
|
"action": action,
|
||||||
|
|
@ -76,25 +88,6 @@ def register_read_console_tools(mcp: FastMCP):
|
||||||
if 'count' not in params_dict:
|
if 'count' not in params_dict:
|
||||||
params_dict['count'] = None
|
params_dict['count'] = None
|
||||||
|
|
||||||
# Use centralized retry helper (tolerate legacy list payloads from some agents)
|
# Use centralized retry helper
|
||||||
resp = send_command_with_retry("read_console", params_dict)
|
resp = send_command_with_retry("read_console", params_dict)
|
||||||
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
|
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||||
data = resp.get("data", {}) or {}
|
|
||||||
lines = data.get("lines")
|
|
||||||
if lines is None:
|
|
||||||
# Some handlers return the raw list under data
|
|
||||||
lines = data if isinstance(data, list) else []
|
|
||||||
|
|
||||||
def _entry(x: Any) -> Dict[str, Any]:
|
|
||||||
if isinstance(x, dict):
|
|
||||||
return {
|
|
||||||
"level": x.get("level") or x.get("type"),
|
|
||||||
"message": x.get("message") or x.get("text"),
|
|
||||||
}
|
|
||||||
if isinstance(x, (list, tuple)) and len(x) >= 2:
|
|
||||||
return {"level": x[0], "message": x[1]}
|
|
||||||
return {"level": None, "message": str(x)}
|
|
||||||
|
|
||||||
trimmed = [_entry(l) for l in (lines or [])]
|
|
||||||
return {"success": True, "data": {"lines": trimmed}}
|
|
||||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
|
||||||
|
|
@ -3,7 +3,6 @@ Resource wrapper tools so clients that do not expose MCP resources primitives
|
||||||
can still list and read files via normal tools. These call into the same
|
can still list and read files via normal tools. These call into the same
|
||||||
safe path logic (re-implemented here to avoid importing server.py).
|
safe path logic (re-implemented here to avoid importing server.py).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
import re
|
import re
|
||||||
|
|
@ -13,12 +12,35 @@ import fnmatch
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from telemetry_decorator import telemetry_tool
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP, Context
|
from mcp.server.fastmcp import FastMCP, Context
|
||||||
|
from telemetry_decorator import telemetry_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int] = None) -> Optional[int]:
|
||||||
|
"""Safely coerce various inputs (str/float/etc.) to an int.
|
||||||
|
Returns default on failure; clamps to minimum when provided.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
# Avoid treating booleans as ints implicitly
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
result = int(value)
|
||||||
|
else:
|
||||||
|
s = str(value).strip()
|
||||||
|
if s.lower() in ("", "none", "null"):
|
||||||
|
return default
|
||||||
|
# Allow "10.0" or similar inputs
|
||||||
|
result = int(float(s))
|
||||||
|
if minimum is not None and result < minimum:
|
||||||
|
return minimum
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
def _resolve_project_root(override: str | None) -> Path:
|
def _resolve_project_root(override: str | None) -> Path:
|
||||||
# 1) Explicit override
|
# 1) Explicit override
|
||||||
if override:
|
if override:
|
||||||
|
|
@ -118,11 +140,11 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
))
|
))
|
||||||
@telemetry_tool("list_resources")
|
@telemetry_tool("list_resources")
|
||||||
async def list_resources(
|
async def list_resources(
|
||||||
ctx: Any = None,
|
ctx: Optional[Context] = None,
|
||||||
pattern: str | None = "*.cs",
|
pattern: Optional[str] = "*.cs",
|
||||||
under: str = "Assets",
|
under: str = "Assets",
|
||||||
limit: int = 200,
|
limit: Any = 200,
|
||||||
project_root: str | None = None,
|
project_root: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Lists project URIs (unity://path/...) under a folder (default: Assets).
|
Lists project URIs (unity://path/...) under a folder (default: Assets).
|
||||||
|
|
@ -144,6 +166,7 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
return {"success": False, "error": "Listing is restricted to Assets/"}
|
return {"success": False, "error": "Listing is restricted to Assets/"}
|
||||||
|
|
||||||
matches: List[str] = []
|
matches: List[str] = []
|
||||||
|
limit_int = _coerce_int(limit, default=200, minimum=1)
|
||||||
for p in base.rglob("*"):
|
for p in base.rglob("*"):
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
continue
|
continue
|
||||||
|
|
@ -160,7 +183,7 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
continue
|
continue
|
||||||
rel = p.relative_to(project).as_posix()
|
rel = p.relative_to(project).as_posix()
|
||||||
matches.append(f"unity://path/{rel}")
|
matches.append(f"unity://path/{rel}")
|
||||||
if len(matches) >= max(1, limit):
|
if len(matches) >= max(1, limit_int):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Always include the canonical spec resource so NL clients can discover it
|
# Always include the canonical spec resource so NL clients can discover it
|
||||||
|
|
@ -180,19 +203,17 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
@telemetry_tool("read_resource")
|
@telemetry_tool("read_resource")
|
||||||
async def read_resource(
|
async def read_resource(
|
||||||
uri: str,
|
uri: str,
|
||||||
ctx: Any = None,
|
ctx: Optional[Context] = None,
|
||||||
start_line: int | None = None,
|
start_line: Any = None,
|
||||||
line_count: int | None = None,
|
line_count: Any = None,
|
||||||
head_bytes: int | None = None,
|
head_bytes: Any = None,
|
||||||
tail_lines: int | None = None,
|
tail_lines: Any = None,
|
||||||
project_root: str | None = None,
|
project_root: Optional[str] = None,
|
||||||
request: str | None = None,
|
request: Optional[str] = None,
|
||||||
include_text: bool = False,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Reads a resource by unity://path/... URI with optional slicing.
|
Reads a resource by unity://path/... URI with optional slicing.
|
||||||
By default only the SHA-256 hash and byte length are returned; set
|
One of line window (start_line/line_count) or head_bytes can be used to limit size.
|
||||||
``include_text`` or provide window arguments to receive text.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Serve the canonical spec directly when requested (allow bare or with scheme)
|
# Serve the canonical spec directly when requested (allow bare or with scheme)
|
||||||
|
|
@ -297,43 +318,31 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
start_line = max(1, hit_line - half)
|
start_line = max(1, hit_line - half)
|
||||||
line_count = window
|
line_count = window
|
||||||
|
|
||||||
raw = p.read_bytes()
|
# Coerce numeric inputs defensively (string/float -> int)
|
||||||
sha = hashlib.sha256(raw).hexdigest()
|
start_line = _coerce_int(start_line)
|
||||||
length = len(raw)
|
line_count = _coerce_int(line_count)
|
||||||
|
head_bytes = _coerce_int(head_bytes, minimum=1)
|
||||||
|
tail_lines = _coerce_int(tail_lines, minimum=1)
|
||||||
|
|
||||||
want_text = (
|
# Mutually exclusive windowing options precedence:
|
||||||
bool(include_text)
|
# 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text
|
||||||
or (head_bytes is not None and head_bytes >= 0)
|
if head_bytes and head_bytes > 0:
|
||||||
or (tail_lines is not None and tail_lines > 0)
|
raw = p.read_bytes()[: head_bytes]
|
||||||
or (start_line is not None and line_count is not None)
|
text = raw.decode("utf-8", errors="replace")
|
||||||
)
|
else:
|
||||||
if want_text:
|
text = p.read_text(encoding="utf-8")
|
||||||
text: str
|
if tail_lines is not None and tail_lines > 0:
|
||||||
if head_bytes is not None and head_bytes >= 0:
|
lines = text.splitlines()
|
||||||
text = raw[: head_bytes].decode("utf-8", errors="replace")
|
n = max(0, tail_lines)
|
||||||
else:
|
text = "\n".join(lines[-n:])
|
||||||
text = raw.decode("utf-8", errors="replace")
|
elif start_line is not None and line_count is not None and line_count >= 0:
|
||||||
if tail_lines is not None and tail_lines > 0:
|
lines = text.splitlines()
|
||||||
lines = text.splitlines()
|
s = max(0, start_line - 1)
|
||||||
n = max(0, tail_lines)
|
e = min(len(lines), s + line_count)
|
||||||
text = "\n".join(lines[-n:])
|
text = "\n".join(lines[s:e])
|
||||||
elif (
|
|
||||||
start_line is not None
|
sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
and line_count is not None
|
return {"success": True, "data": {"text": text, "metadata": {"sha256": sha}}}
|
||||||
and line_count >= 0
|
|
||||||
):
|
|
||||||
lines = text.splitlines()
|
|
||||||
s = max(0, start_line - 1)
|
|
||||||
e = min(len(lines), s + line_count)
|
|
||||||
text = "\n".join(lines[s:e])
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {"text": text, "metadata": {"sha256": sha}},
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {"metadata": {"sha256": sha, "lengthBytes": length}},
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
@ -342,13 +351,13 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
async def find_in_file(
|
async def find_in_file(
|
||||||
uri: str,
|
uri: str,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
ctx: Any = None,
|
ctx: Optional[Context] = None,
|
||||||
ignore_case: bool | None = True,
|
ignore_case: Optional[bool] = True,
|
||||||
project_root: str | None = None,
|
project_root: Optional[str] = None,
|
||||||
max_results: int | None = 1,
|
max_results: Any = 200,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Searches a file with a regex pattern and returns match positions only.
|
Searches a file with a regex pattern and returns line numbers and excerpts.
|
||||||
- uri: unity://path/Assets/... or file path form supported by read_resource
|
- uri: unity://path/Assets/... or file path form supported by read_resource
|
||||||
- pattern: regular expression (Python re)
|
- pattern: regular expression (Python re)
|
||||||
- ignore_case: case-insensitive by default
|
- ignore_case: case-insensitive by default
|
||||||
|
|
@ -368,20 +377,12 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
||||||
rx = re.compile(pattern, flags)
|
rx = re.compile(pattern, flags)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
max_results_int = _coerce_int(max_results, default=200, minimum=1)
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
for i, line in enumerate(lines, start=1):
|
for i, line in enumerate(lines, start=1):
|
||||||
m = rx.search(line)
|
if rx.search(line):
|
||||||
if m:
|
results.append({"line": i, "text": line})
|
||||||
start_col, end_col = m.span()
|
if max_results_int and len(results) >= max_results_int:
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"startLine": i,
|
|
||||||
"startCol": start_col + 1,
|
|
||||||
"endLine": i,
|
|
||||||
"endCol": end_col + 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if max_results and len(results) >= max_results:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return {"success": True, "data": {"matches": results, "count": len(results)}}
|
return {"success": True, "data": {"matches": results, "count": len(results)}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue