Skip to content
Open
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
119 changes: 116 additions & 3 deletions libs/arcade-core/arcade_core/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,89 @@
)


def _render_declared_type(value_schema: Any) -> str:
"""Render a parameter's declared type, e.g. ``string`` or ``array[string]``."""
val_type = str(value_schema.val_type)
inner = getattr(value_schema, "inner_val_type", None)
if val_type == "array" and inner:
return f"array[{inner}]"
return val_type


def _expected_shape_guidance(
definition: ToolDefinition | None,
rejected_fields: list[str],
input_model: type[BaseModel] | None = None,
) -> str:
"""Describe the declared shape of the parameters that were rejected.

Built strictly from the tool's own ``ToolDefinition`` -- never from the
submitted values, which may contain secrets or PII (see the note in
``_serialize_input``). Only the rejected parameters are described: the
caller already received the full schema from ``tools/list``, so echoing all
of it on every failure is noise that buries the actionable part.

``rejected_fields`` holds Pydantic field names, which are the *Python*
parameter names. A two-string ``Annotated[T, "wire_name", "description"]``
renames the parameter for the wire, so ``InputParameter.name`` is not always
the Pydantic field name and a name-only lookup would silently miss every
renamed parameter. ``input_model`` supplies the Pydantic side so both names
resolve. ``create_input_definition`` and ``create_func_models`` walk the same
``inspect.signature`` in the same order and skip ``ToolContext`` the same
way, so the two sequences line up positionally.

Returns an empty string when there is nothing useful to add, so callers can
append unconditionally.
"""
if definition is None or not rejected_fields:
return ""

try:
declared = list(definition.input.parameters)
except AttributeError:
return ""

# Wire names first; Python field names only fill gaps, so a parameter whose
# wire name collides with another's Python name is never shadowed.
parameters = {param.name: param for param in declared}
if input_model is not None:
try:
field_names = list(input_model.model_fields)
except AttributeError:
field_names = []
for field_name, declared_param in zip(field_names, declared):
parameters.setdefault(field_name, declared_param)

lines: list[str] = []
described: set[int] = set()
for name in rejected_fields:
param = parameters.get(name)
if param is None:
# A rejected key with no declared parameter (e.g. an unexpected
# extra argument) has no shape to describe.
continue
Comment thread
cursor[bot] marked this conversation as resolved.
if id(param) in described:
# One renamed parameter can be rejected under both of its names at
# once ("py_param: Field required; wire_name: Extra inputs are not
# permitted"). Describe it once.
continue
described.add(id(param))
qualifier = "required" if param.required else "optional"
line = f" - {param.name} ({_render_declared_type(param.value_schema)}, {qualifier})"
if param.description:
line += f": {param.description}"
enum_values = getattr(param.value_schema, "enum", None)
if enum_values:
line += f" [allowed values: {', '.join(str(v) for v in enum_values)}]"
lines.append(line)

if not lines:
return ""

rendered = "\n".join(lines)
return f"Expected:\n{rendered}\n\nFix these arguments and call the tool again."


class ToolExecutor:
@staticmethod
async def run(
Expand Down Expand Up @@ -46,7 +129,7 @@ async def run(

try:
# serialize the input model
inputs = await ToolExecutor._serialize_input(input_model, **kwargs)
inputs = await ToolExecutor._serialize_input(input_model, definition, **kwargs)

# prepare the arguments for the function call
func_args = inputs.model_dump()
Expand Down Expand Up @@ -90,9 +173,23 @@ async def run(
)

@staticmethod
async def _serialize_input(input_model: type[BaseModel], **kwargs: Any) -> BaseModel:
async def _serialize_input(
input_model: type[BaseModel],
definition: ToolDefinition | None = None,
/,
**kwargs: Any,
) -> BaseModel:
"""
Serialize the input to a tool function.

``input_model`` and ``definition`` are positional-only: ``**kwargs`` holds
the caller-supplied tool arguments, and a tool is free to declare a
parameter named ``definition`` (or ``input_model``). Positional-only
placement keeps such an argument in ``kwargs`` instead of colliding with
these parameters.

``definition`` is optional enrichment used to describe the expected shape
of rejected parameters; validation works without it.
"""
try:
# TODO Logging and telemetry
Expand All @@ -115,8 +212,24 @@ async def _serialize_input(input_model: type[BaseModel], **kwargs: Any) -> BaseM
f"{'.'.join(str(loc) for loc in err['loc']) or '<root>'}[{err['type']}]"
for err in e.errors()
)
# Field paths of the rejected arguments, de-duplicated in the order
# Pydantic reported them. Only the top-level name is used, since that
# is what maps onto a declared tool parameter.
rejected_fields: list[str] = []
for err in e.errors():
if not err["loc"]:
continue
field = str(err["loc"][0])
if field not in rejected_fields:
rejected_fields.append(field)

message = f"Invalid input: {summary}"
guidance = _expected_shape_guidance(definition, rejected_fields, input_model)
if guidance:
message = f"{message}\n\n{guidance}"

raise ToolInputError(
message=f"Invalid input: {summary}",
message=message,
developer_message=f"Pydantic validation failed: {developer_summary}",
) from e

Expand Down
2 changes: 1 addition & 1 deletion libs/arcade-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "arcade-core"
version = "4.18.0"
version = "4.19.0"
description = "Arcade Core - Core library for Arcade platform"
readme = "README.md"
license = { text = "MIT" }
Expand Down
20 changes: 19 additions & 1 deletion libs/arcade-mcp-server/arcade_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1600,9 +1600,27 @@ async def _handle_call_tool(
session and not session.has_feature("tool_execution")
):
self._tracker.track_tool_call(False, "invalid tool input")
# Surface the curated user-facing message, never
# ``str(error)``: that renders the whole ToolCallError
# model, so ``developer_message`` and ``stacktrace``
# would reach the client unconditionally. The stacktrace
# of an input-validation failure is a Pydantic traceback
# embedding ``input_value=``, i.e. the rejected argument
# itself — which the executor deliberately keeps out of
# the surfaced fields because it may hold secrets or PII.
# Route internals through the debug-flag gate instead, so
# this branch matches the 2025-11-25 one below.
legacy_message = error.message
if error.additional_prompt_content:
legacy_message += f"\n\n{error.additional_prompt_content}"
legacy_message = augment_error_message_for_debug(
legacy_message,
error.developer_message,
error.stacktrace,
)
return JSONRPCError(
id=message.id,
error={"code": INVALID_PARAMS, "message": str(error)},
error={"code": INVALID_PARAMS, "message": legacy_message},
)

error_text = error.message
Expand Down
2 changes: 1 addition & 1 deletion libs/arcade-mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "arcade-mcp-server"
version = "1.30.1"
version = "1.30.2"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
Expand Down
Loading